Plan independent BFF and adapt selected Sendico foundations (#34)
This commit is contained in:
1 parent
1e191fca42
commit
45666d33fc
17 files changed
+854
-15
No files matched your search
@@ -0,0 +1,118 @@
|
||||
// Adapted from Sendico edge/shared/auth/browser; see backend/PROVENANCE.md.
|
||||
package browser
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
SessionCookie = "__Host-sxchnge_session"
|
||||
CSRFHeader = "X-CSRF-Protection"
|
||||
CSRFValue = "1"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrOrigin = errors.New("browser origin is not allowed")
|
||||
ErrCSRF = errors.New("browser CSRF protection is required")
|
||||
ErrSession = errors.New("browser session is missing or invalid")
|
||||
)
|
||||
|
||||
type Policy struct{ origins map[string]struct{} }
|
||||
|
||||
// New accepts explicit HTTPS origins. Browser session transport is chosen by the
|
||||
// server route; a client-controlled transport header cannot turn protection off.
|
||||
func New(allowedOrigins []string) (*Policy, error) {
|
||||
p := &Policy{origins: make(map[string]struct{}, len(allowedOrigins))}
|
||||
for _, value := range allowedOrigins {
|
||||
origin, ok := canonicalOrigin(value)
|
||||
if !ok {
|
||||
return nil, ErrOrigin
|
||||
}
|
||||
p.origins[origin] = struct{}{}
|
||||
}
|
||||
if len(p.origins) == 0 {
|
||||
return nil, ErrOrigin
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func canonicalOrigin(value string) (string, bool) {
|
||||
u, err := url.Parse(value)
|
||||
if err != nil || u.Scheme != "https" || u.Hostname() == "" || u.User != nil || u.Path != "" || u.RawQuery != "" || u.ForceQuery || u.Fragment != "" || u.Opaque != "" {
|
||||
return "", false
|
||||
}
|
||||
return "https://" + strings.ToLower(u.Host), true
|
||||
}
|
||||
|
||||
// Validate must guard every browser-session mutation, including login/logout.
|
||||
// It assumes CORS never grants credentialed access to untrusted origins.
|
||||
func (p *Policy) Validate(r *http.Request) error {
|
||||
if r == nil {
|
||||
return ErrCSRF
|
||||
}
|
||||
switch r.Method {
|
||||
case http.MethodGet, http.MethodHead, http.MethodOptions:
|
||||
return nil
|
||||
}
|
||||
if r.Header.Get(CSRFHeader) != CSRFValue {
|
||||
return ErrCSRF
|
||||
}
|
||||
origin, ok := canonicalOrigin(r.Header.Get("Origin"))
|
||||
if !ok || p == nil {
|
||||
return ErrOrigin
|
||||
}
|
||||
if _, allowed := p.origins[origin]; !allowed {
|
||||
return ErrOrigin
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func Credential(r *http.Request) (string, error) {
|
||||
if r == nil {
|
||||
return "", ErrSession
|
||||
}
|
||||
var value string
|
||||
for _, c := range r.Cookies() {
|
||||
if c.Name != SessionCookie {
|
||||
continue
|
||||
}
|
||||
if value != "" || c.Value == "" {
|
||||
return "", ErrSession
|
||||
}
|
||||
value = c.Value
|
||||
}
|
||||
if value == "" {
|
||||
return "", ErrSession
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func SetSession(w http.ResponseWriter, token string, now, expires time.Time) error {
|
||||
age := int(expires.Sub(now).Seconds())
|
||||
if token == "" || age < 1 {
|
||||
return ErrSession
|
||||
}
|
||||
c := cookie(token)
|
||||
c.Expires = expires.UTC()
|
||||
c.MaxAge = age
|
||||
if c.Valid() != nil {
|
||||
return ErrSession
|
||||
}
|
||||
http.SetCookie(w, c)
|
||||
return nil
|
||||
}
|
||||
|
||||
func ClearSession(w http.ResponseWriter) {
|
||||
c := cookie("")
|
||||
c.Expires = time.Unix(1, 0).UTC()
|
||||
c.MaxAge = -1
|
||||
http.SetCookie(w, c)
|
||||
}
|
||||
|
||||
func cookie(value string) *http.Cookie {
|
||||
return &http.Cookie{Name: SessionCookie, Value: value, Path: "/", HttpOnly: true, Secure: true, SameSite: http.SameSiteLaxMode}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package browser
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestMutationRequiresOriginAndHeader(t *testing.T) {
|
||||
p, err := New([]string{"https://app.example.com"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
origin, header, transport string
|
||||
valid bool
|
||||
}{
|
||||
{"https://app.example.com", "1", "", true},
|
||||
{"https://APP.example.com", "1", "", true},
|
||||
{"https://evil.example.com", "1", "", false},
|
||||
{"https://app.example.com.evil.test", "1", "", false},
|
||||
{"https://app.example.com/path", "1", "", false},
|
||||
{"https://app.example.com?query", "1", "", false},
|
||||
{"https://app.example.com#fragment", "1", "", false},
|
||||
{"https://app.example.com", "", "bearer", false},
|
||||
{"", "1", "bearer", false},
|
||||
{"null", "1", "cookie", false},
|
||||
} {
|
||||
r := httptest.NewRequest(http.MethodPost, "https://app.example.com/api/session", nil)
|
||||
r.Header.Set("Origin", tc.origin)
|
||||
r.Header.Set(CSRFHeader, tc.header)
|
||||
r.Header.Set("X-Auth-Transport", tc.transport)
|
||||
if got := p.Validate(r); (got == nil) != tc.valid {
|
||||
t.Errorf("%+v: %v", tc, got)
|
||||
}
|
||||
}
|
||||
if p.Validate(httptest.NewRequest(http.MethodGet, "https://app.example.com/api/session", nil)) != nil {
|
||||
t.Fatal("read rejected")
|
||||
}
|
||||
for _, origin := range []string{"*", "http://app.example.com", "https://user@app.example.com", "https://app.example.com/"} {
|
||||
if _, err := New([]string{origin}); err == nil {
|
||||
t.Errorf("accepted configuration %q", origin)
|
||||
}
|
||||
}
|
||||
if _, err := New(nil); err == nil {
|
||||
t.Fatal("empty origin policy accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionCookie(t *testing.T) {
|
||||
now := time.Date(2026, 9, 12, 0, 0, 0, 0, time.UTC)
|
||||
w := httptest.NewRecorder()
|
||||
if err := SetSession(w, "opaque-token", now, now.Add(time.Hour)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c := w.Result().Cookies()[0]
|
||||
if c.Name != SessionCookie || !c.Secure || !c.HttpOnly || c.Path != "/" || c.Domain != "" || c.SameSite != http.SameSiteLaxMode || c.MaxAge != 3600 {
|
||||
t.Fatalf("bad cookie: %+v", c)
|
||||
}
|
||||
r := httptest.NewRequest(http.MethodGet, "https://app.example.com/api/session", nil)
|
||||
r.AddCookie(c)
|
||||
if value, err := Credential(r); err != nil || value != "opaque-token" {
|
||||
t.Fatal("cookie not read")
|
||||
}
|
||||
r.AddCookie(c)
|
||||
if _, err := Credential(r); err == nil {
|
||||
t.Fatal("ambiguous credential accepted")
|
||||
}
|
||||
if err := SetSession(httptest.NewRecorder(), "token", now, now); err == nil {
|
||||
t.Fatal("expired cookie accepted")
|
||||
}
|
||||
w = httptest.NewRecorder()
|
||||
ClearSession(w)
|
||||
c = w.Result().Cookies()[0]
|
||||
if c.MaxAge != -1 || c.Value != "" || c.Path != "/" || !c.Secure {
|
||||
t.Fatal("cookie not cleared safely")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user