// 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} }