Something went wrong. Try again.
Cloudflare Tunnel client with NetBSD support
Something went wrong. Try again.
7.8 kB · 239 lines
Go
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240package quicktunnelauth
import ( "crypto/hmac" "crypto/sha256" "crypto/subtle" "encoding/base64" "encoding/json" "errors" "fmt" "io" "mime" "net/http" "net/url" "strings" "time")
const ( quickTunnelAuthCallbackContentType = "application/x-www-form-urlencoded" quickTunnelAuthCallbackMaxBodyBytes = 4 * 1024 quickTunnelAuthCallbackFormFieldCount = 2)
var errQuickTunnelAuthStateExpired = errors.New("authentication state expired")
// QuickTunnelAuthCallback contains the state validated from a broker callback.type QuickTunnelAuthCallback struct { State string Assertion string ReturnPath string ClearCookie *http.Cookie}
// ConsumeCallback validates the browser state attached to a broker callback// and returns the authenticated result.//// Callback state is intentionally stateless. A captured callback can be// replayed only while both its signed state cookie and short-lived broker// assertion remain valid. The handler clears the state cookie after processing// to prevent ordinary browser resubmission.func (m *QuickTunnelAuthStateManager) ConsumeCallback(r *http.Request) (*QuickTunnelAuthCallback, error) { if err := m.validateCallbackRequest(r); err != nil { return nil, fmt.Errorf("validate callback request: %w", err) }
state, assertion, err := parseQuickTunnelAuthCallbackForm(r) if err != nil { return nil, fmt.Errorf("parse callback form: %w", err) } if !isCanonicalQuickTunnelAuthState(state) { return nil, errors.New("callback state is not canonical") }
cookiePayload, err := m.verifyStateCookie(r, state) if err != nil { return nil, fmt.Errorf("verify authentication-state cookie: %w", err) } if !constantTimeStateEqual(state, cookiePayload.State) { return nil, errors.New("browser state does not match callback state") }
now := m.now().UTC() cookieExpiresAt := time.Unix(cookiePayload.ExpiresAt, 0).UTC() if !now.Before(cookieExpiresAt) { return nil, errQuickTunnelAuthStateExpired }
return &QuickTunnelAuthCallback{ State: state, Assertion: assertion, ReturnPath: cookiePayload.ReturnPath, ClearCookie: newQuickTunnelAuthClearStateCookie(state), }, nil}
// validateCallbackRequest verifies the callback method, hostname, URL, and content type.func (m *QuickTunnelAuthStateManager) validateCallbackRequest(r *http.Request) error { if r == nil { return errors.New("request cannot be nil") } if r.URL == nil { return errors.New("request URL cannot be nil") } if r.Method != http.MethodPost { return errors.New("callback method must be POST") } if !matchesQuickTunnelRequestHostname(r.Host, m.hostname) { return errors.New("callback hostname does not match protected Quick Tunnel") } if r.URL.EscapedPath() != QuickTunnelAuthCallbackPath || r.URL.RawQuery != "" || r.URL.Fragment != "" { return errors.New("unexpected callback URL") }
contentType, _, err := mime.ParseMediaType(r.Header.Get("Content-Type")) if err != nil { return fmt.Errorf("parse callback content type: %w", err) } if contentType != quickTunnelAuthCallbackContentType { return fmt.Errorf( "callback content type %q must be %q", contentType, quickTunnelAuthCallbackContentType, ) }
return nil}
func parseQuickTunnelAuthCallbackForm(r *http.Request) (state, assertion string, err error) { if r.Body == nil { return "", "", errors.New("callback body is missing") } if r.ContentLength > quickTunnelAuthCallbackMaxBodyBytes { return "", "", fmt.Errorf( "callback body is too large: got %d bytes, maximum is %d", r.ContentLength, quickTunnelAuthCallbackMaxBodyBytes, ) }
// Read one byte beyond the limit so an unknown-length body that exceeds the // maximum can be distinguished from a body exactly at the maximum. body, err := io.ReadAll(io.LimitReader(r.Body, quickTunnelAuthCallbackMaxBodyBytes+1)) if err != nil { return "", "", fmt.Errorf("read callback body: %w", err) } // ContentLength may be unknown, so enforce the limit using the bytes actually // read as well as the declared length checked above. if len(body) > quickTunnelAuthCallbackMaxBodyBytes { return "", "", errors.New("callback body is too large") }
form, err := url.ParseQuery(string(body)) if err != nil { return "", "", fmt.Errorf("parse URL-encoded callback body: %w", err) } if len(form) != quickTunnelAuthCallbackFormFieldCount { return "", "", fmt.Errorf( "malformed callback form: got %d fields, expected %d", len(form), quickTunnelAuthCallbackFormFieldCount, ) }
states := form["state"] if len(states) != 1 { return "", "", fmt.Errorf("callback state has %d values, expected one", len(states)) } if states[0] == "" { return "", "", errors.New("callback state must not be empty") }
assertions := form["assertion"] if len(assertions) != 1 { return "", "", fmt.Errorf("callback assertion has %d values, expected one", len(assertions)) } if assertions[0] == "" { return "", "", errors.New("callback assertion must not be empty") }
return states[0], assertions[0], nil}
func (m *QuickTunnelAuthStateManager) verifyStateCookie(r *http.Request, state string) (*quickTunnelAuthStateCookiePayload, error) { stateCookies := r.CookiesNamed(quickTunnelAuthStateCookieName(state)) if len(stateCookies) != 1 { return nil, fmt.Errorf( "request contains %d authentication-state cookies, expected one", len(stateCookies), ) } stateCookie := stateCookies[0]
encodedPayload, encodedSignature, ok := strings.Cut(stateCookie.Value, ".") if !ok || encodedPayload == "" || encodedSignature == "" || strings.Contains(encodedSignature, ".") { return nil, errors.New("malformed authentication-state cookie") }
signature, err := base64.RawURLEncoding.Strict().DecodeString(encodedSignature) if err != nil { return nil, fmt.Errorf("decode authentication-state cookie signature: %w", err) } if len(signature) != sha256.Size { return nil, errors.New("malformed authentication-state cookie signature") }
mac := hmac.New(sha256.New, m.signingKey) if _, err := mac.Write([]byte(encodedPayload)); err != nil { return nil, fmt.Errorf("verify authentication-state cookie signature: %w", err) } if !hmac.Equal(mac.Sum(nil), signature) { return nil, errors.New("invalid authentication-state cookie signature") }
payloadBytes, err := base64.RawURLEncoding.Strict().DecodeString(encodedPayload) if err != nil { return nil, fmt.Errorf("decode authentication-state cookie payload: %w", err) }
var payload quickTunnelAuthStateCookiePayload if err := json.Unmarshal(payloadBytes, &payload); err != nil { return nil, fmt.Errorf("decode authentication-state cookie payload: %w", err) }
if !isCanonicalQuickTunnelAuthState(payload.State) || payload.ExpiresAt <= 0 { return nil, errors.New("unsupported authentication-state cookie payload") }
return &payload, nil}
// constantTimeStateEqual compares sensitive authentication state values in constant time.func constantTimeStateEqual(left, right string) bool { return subtle.ConstantTimeCompare([]byte(left), []byte(right)) == 1}
// isCanonicalQuickTunnelAuthState reports whether state is an unpadded,// canonical base64url value that decodes to the required state size.func isCanonicalQuickTunnelAuthState(state string) bool { decodedState, err := base64.RawURLEncoding.Strict().DecodeString(state) return err == nil && len(decodedState) == quickTunnelAuthStateSize}
func newQuickTunnelAuthClearStateCookie(state string) *http.Cookie { return &http.Cookie{ Name: quickTunnelAuthStateCookieName(state), Value: "", Path: QuickTunnelAuthCallbackPath, Expires: time.Unix(1, 0).UTC(), MaxAge: -1, Secure: true, HttpOnly: true, SameSite: http.SameSiteLaxMode, }}