119 lines
2.8 KiB
Go
119 lines
2.8 KiB
Go
// 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}
|
|
}
|