From 2250902a947eda7fb1d6c2e754c672f2142d9a9b Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Mon, 27 Apr 2026 10:05:45 +0700 Subject: [PATCH] Add RFC 6265bis cookie checks: name prefixes and per-cookie size --- checker/collect.go | 14 +- checker/rules_cookies_rfc6265bis.go | 148 +++++++++++++++++++++ checker/rules_cookies_rfc6265bis_test.go | 160 +++++++++++++++++++++++ checker/types.go | 10 ++ 4 files changed, 329 insertions(+), 3 deletions(-) create mode 100644 checker/rules_cookies_rfc6265bis.go create mode 100644 checker/rules_cookies_rfc6265bis_test.go diff --git a/checker/collect.go b/checker/collect.go index 23df414..ed12a90 100644 --- a/checker/collect.go +++ b/checker/collect.go @@ -290,8 +290,12 @@ func runProbe(ctx context.Context, host, ip, scheme string, port uint16, timeout probe.Headers[lk] = strings.Join(v, ", ") } - for _, c := range resp.Cookies() { - probe.Cookies = append(probe.Cookies, CookieInfo{ + // resp.Cookies() and resp.Header.Values("Set-Cookie") yield entries + // in the same order, so we can pair them positionally to recover the + // raw byte length of each Set-Cookie line for the size rule. + rawSetCookies := resp.Header.Values("Set-Cookie") + for i, c := range resp.Cookies() { + ci := CookieInfo{ Name: c.Name, Domain: c.Domain, Path: c.Path, @@ -299,7 +303,11 @@ func runProbe(ctx context.Context, host, ip, scheme string, port uint16, timeout HttpOnly: c.HttpOnly, SameSite: sameSiteString(c.SameSite), HasExpiry: !c.Expires.IsZero() || c.MaxAge > 0, - }) + } + if i < len(rawSetCookies) { + ci.Size = len(rawSetCookies[i]) + } + probe.Cookies = append(probe.Cookies, ci) } probe.RedirectChain = redirectChain diff --git a/checker/rules_cookies_rfc6265bis.go b/checker/rules_cookies_rfc6265bis.go new file mode 100644 index 0000000..5a511c7 --- /dev/null +++ b/checker/rules_cookies_rfc6265bis.go @@ -0,0 +1,148 @@ +// This file is part of the happyDomain (R) project. +// Copyright (c) 2020-2026 happyDomain +// Authors: Pierre-Olivier Mercier, et al. + +package checker + +import ( + "context" + "fmt" + "strings" + + sdk "git.happydns.org/checker-sdk-go/checker" +) + +func init() { + RegisterRule(&cookiePrefixesRule{}) + RegisterRule(&cookieSizeRule{}) +} + +// cookiePrefixesRule enforces the cookie name prefix semantics from +// RFC 6265bis §4.1.3: +// +// - Names starting with "__Secure-" MUST have the Secure attribute. +// - Names starting with "__Host-" MUST have Secure, MUST NOT have a +// Domain attribute, and MUST have Path="/". +// +// Browsers reject Set-Cookie that violates these constraints, so a +// failure here means the cookie is being silently dropped by every +// modern user agent. +type cookiePrefixesRule struct{} + +func (r *cookiePrefixesRule) Name() string { return "http.cookie_prefixes" } +func (r *cookiePrefixesRule) Description() string { + return "Verifies cookies using the __Secure- / __Host- name prefixes meet the RFC 6265bis constraints (Secure, Domain, Path)." +} + +func (r *cookiePrefixesRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState { + data, errSt := loadHTTPData(ctx, obs) + if errSt != nil { + return []sdk.CheckState{*errSt} + } + probes := successfulHTTPSProbes(data.Probes) + if len(probes) == 0 { + return []sdk.CheckState{unknownState("http.cookie_prefixes.no_https", "No successful HTTPS probe to evaluate.")} + } + + var states []sdk.CheckState + prefixed := 0 + for _, p := range probes { + for _, c := range p.Cookies { + switch { + case strings.HasPrefix(c.Name, "__Host-"): + prefixed++ + issues := hostPrefixIssues(c) + if len(issues) > 0 { + states = append(states, sdk.CheckState{ + Status: sdk.StatusWarn, + Code: "http.cookie_prefixes.invalid_host", + Subject: fmt.Sprintf("%s :: %s", p.Address, c.Name), + Message: fmt.Sprintf("Cookie %q violates the __Host- prefix contract (RFC 6265bis §4.1.3): %s", c.Name, strings.Join(issues, ", ")), + }) + } + case strings.HasPrefix(c.Name, "__Secure-"): + prefixed++ + if !c.Secure { + states = append(states, sdk.CheckState{ + Status: sdk.StatusWarn, + Code: "http.cookie_prefixes.invalid_secure", + Subject: fmt.Sprintf("%s :: %s", p.Address, c.Name), + Message: fmt.Sprintf("Cookie %q uses the __Secure- prefix but is not marked Secure (RFC 6265bis §4.1.3); the cookie will be rejected by browsers.", c.Name), + }) + } + } + } + } + if prefixed == 0 { + return []sdk.CheckState{{ + Status: sdk.StatusInfo, + Code: "http.cookie_prefixes.none", + Message: "No cookies use the __Host- or __Secure- name prefixes; consider them for high-value cookies (session, CSRF token, …).", + }} + } + if len(states) == 0 { + return []sdk.CheckState{passState("http.cookie_prefixes.ok", fmt.Sprintf("All %d prefixed cookies satisfy the RFC 6265bis constraints.", prefixed))} + } + return states +} + +// hostPrefixIssues returns the list of __Host- contract violations on +// the given cookie. Empty slice means the cookie is conformant. +func hostPrefixIssues(c CookieInfo) []string { + var issues []string + if !c.Secure { + issues = append(issues, "missing Secure") + } + if c.Domain != "" { + issues = append(issues, "Domain attribute is forbidden") + } + if c.Path != "/" { + issues = append(issues, fmt.Sprintf("Path must be \"/\", got %q", c.Path)) + } + return issues +} + +// cookieSizeRule flags cookies whose raw Set-Cookie line exceeds the +// per-cookie budget (4096 bytes) browsers are required to support per +// RFC 6265 §6.1. Anything over is at risk of being silently truncated +// or dropped by user agents. +type cookieSizeRule struct{} + +func (r *cookieSizeRule) Name() string { return "http.cookie_size" } +func (r *cookieSizeRule) Description() string { + return "Flags cookies whose Set-Cookie line exceeds the 4096-byte minimum browsers must support (RFC 6265 §6.1)." +} + +func (r *cookieSizeRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState { + data, errSt := loadHTTPData(ctx, obs) + if errSt != nil { + return []sdk.CheckState{*errSt} + } + probes := successfulHTTPSProbes(data.Probes) + if len(probes) == 0 { + return []sdk.CheckState{unknownState("http.cookie_size.no_https", "No successful HTTPS probe to evaluate.")} + } + + var states []sdk.CheckState + total := 0 + for _, p := range probes { + for _, c := range p.Cookies { + total++ + if c.Size > MaxCookieSize { + states = append(states, sdk.CheckState{ + Status: sdk.StatusWarn, + Code: "http.cookie_size.too_large", + Subject: fmt.Sprintf("%s :: %s", p.Address, c.Name), + Message: fmt.Sprintf("Cookie %q is %d bytes; RFC 6265 §6.1 only mandates support for cookies up to %d bytes, larger cookies may be silently dropped.", c.Name, c.Size, MaxCookieSize), + }) + } + } + } + if total == 0 { + return []sdk.CheckState{passState("http.cookie_size.none", "No cookies were set on the inspected responses.")} + } + if len(states) == 0 { + return []sdk.CheckState{passState("http.cookie_size.ok", fmt.Sprintf("All %d cookies fit within the %d-byte per-cookie budget.", total, MaxCookieSize))} + } + return states +} diff --git a/checker/rules_cookies_rfc6265bis_test.go b/checker/rules_cookies_rfc6265bis_test.go new file mode 100644 index 0000000..3c25a53 --- /dev/null +++ b/checker/rules_cookies_rfc6265bis_test.go @@ -0,0 +1,160 @@ +// This file is part of the happyDomain (R) project. +// Copyright (c) 2020-2026 happyDomain +// Authors: Pierre-Olivier Mercier, et al. + +package checker + +import ( + "strings" + "testing" + + sdk "git.happydns.org/checker-sdk-go/checker" +) + +func TestCookiePrefixesRule_NoHTTPS(t *testing.T) { + data := &HTTPData{Probes: []HTTPProbe{httpProbe("a:80")}} + states := runRule(t, &cookiePrefixesRule{}, data, nil) + mustStatus(t, states, sdk.StatusUnknown) + if !hasCode(states, "http.cookie_prefixes.no_https") { + t.Errorf("missing no_https code: %+v", states) + } +} + +func TestCookiePrefixesRule_NoPrefixed(t *testing.T) { + p := httpsProbe("a:443") + p.Cookies = []CookieInfo{{Name: "sid", Secure: true, HttpOnly: true, SameSite: "Lax"}} + states := runRule(t, &cookiePrefixesRule{}, &HTTPData{Probes: []HTTPProbe{p}}, nil) + mustStatus(t, states, sdk.StatusInfo) + if !hasCode(states, "http.cookie_prefixes.none") { + t.Errorf("missing 'none' code: %+v", states) + } +} + +func TestCookiePrefixesRule_HostOK(t *testing.T) { + p := httpsProbe("a:443") + p.Cookies = []CookieInfo{ + {Name: "__Host-sid", Secure: true, HttpOnly: true, SameSite: "Strict", Path: "/"}, + {Name: "__Secure-tok", Secure: true, HttpOnly: true, SameSite: "Lax", Path: "/app"}, + } + states := runRule(t, &cookiePrefixesRule{}, &HTTPData{Probes: []HTTPProbe{p}}, nil) + mustStatus(t, states, sdk.StatusOK) + if !hasCode(states, "http.cookie_prefixes.ok") { + t.Errorf("missing ok code: %+v", states) + } +} + +func TestCookiePrefixesRule_SecureMissingSecure(t *testing.T) { + p := httpsProbe("a:443") + p.Cookies = []CookieInfo{{Name: "__Secure-x", Secure: false, HttpOnly: true, SameSite: "Lax"}} + states := runRule(t, &cookiePrefixesRule{}, &HTTPData{Probes: []HTTPProbe{p}}, nil) + mustStatus(t, states, sdk.StatusWarn) + if !hasCode(states, "http.cookie_prefixes.invalid_secure") { + t.Errorf("missing invalid_secure code: %+v", states) + } +} + +func TestCookiePrefixesRule_HostViolations(t *testing.T) { + cases := []struct { + name string + cookie CookieInfo + want []string + }{ + { + name: "no Secure", + cookie: CookieInfo{Name: "__Host-a", Secure: false, Path: "/"}, + want: []string{"missing Secure"}, + }, + { + name: "Domain set", + cookie: CookieInfo{Name: "__Host-a", Secure: true, Domain: "example.test", Path: "/"}, + want: []string{"Domain attribute is forbidden"}, + }, + { + name: "wrong Path", + cookie: CookieInfo{Name: "__Host-a", Secure: true, Path: "/app"}, + want: []string{`Path must be "/"`}, + }, + { + name: "all three", + cookie: CookieInfo{Name: "__Host-a", Secure: false, Domain: "x", Path: "/x"}, + want: []string{"missing Secure", "Domain attribute is forbidden", `Path must be "/"`}, + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + p := httpsProbe("a:443") + p.Cookies = []CookieInfo{c.cookie} + states := runRule(t, &cookiePrefixesRule{}, &HTTPData{Probes: []HTTPProbe{p}}, nil) + mustStatus(t, states, sdk.StatusWarn) + if !hasCode(states, "http.cookie_prefixes.invalid_host") { + t.Fatalf("missing invalid_host code: %+v", states) + } + for _, w := range c.want { + if !strings.Contains(states[0].Message, w) { + t.Errorf("message missing %q: %s", w, states[0].Message) + } + } + }) + } +} + +func TestCookiePrefixesRule_LoadFailure(t *testing.T) { + states := (&cookiePrefixesRule{}).Evaluate(t.Context(), &fakeObs{failGet: true}, nil) + if len(states) != 1 || states[0].Status != sdk.StatusError { + t.Fatalf("expected single error state, got %+v", states) + } +} + +func TestCookieSizeRule_NoHTTPS(t *testing.T) { + data := &HTTPData{Probes: []HTTPProbe{httpProbe("a:80")}} + states := runRule(t, &cookieSizeRule{}, data, nil) + mustStatus(t, states, sdk.StatusUnknown) +} + +func TestCookieSizeRule_None(t *testing.T) { + data := &HTTPData{Probes: []HTTPProbe{httpsProbe("a:443")}} + states := runRule(t, &cookieSizeRule{}, data, nil) + mustStatus(t, states, sdk.StatusOK) + if !hasCode(states, "http.cookie_size.none") { + t.Errorf("missing 'none' code: %+v", states) + } +} + +func TestCookieSizeRule_OK(t *testing.T) { + p := httpsProbe("a:443") + p.Cookies = []CookieInfo{ + {Name: "small", Size: 200}, + {Name: "borderline", Size: MaxCookieSize}, // exactly the limit is acceptable + } + states := runRule(t, &cookieSizeRule{}, &HTTPData{Probes: []HTTPProbe{p}}, nil) + mustStatus(t, states, sdk.StatusOK) + if !hasCode(states, "http.cookie_size.ok") { + t.Errorf("missing ok code: %+v", states) + } +} + +func TestCookieSizeRule_TooLarge(t *testing.T) { + p := httpsProbe("a:443") + p.Cookies = []CookieInfo{ + {Name: "small", Size: 100}, + {Name: "huge", Size: MaxCookieSize + 1}, + } + states := runRule(t, &cookieSizeRule{}, &HTTPData{Probes: []HTTPProbe{p}}, nil) + if len(states) != 1 { + t.Fatalf("got %d states, want 1 (only the oversized cookie)", len(states)) + } + mustStatus(t, states, sdk.StatusWarn) + if !hasCode(states, "http.cookie_size.too_large") { + t.Errorf("missing too_large code: %+v", states) + } + if !strings.Contains(states[0].Message, "huge") { + t.Errorf("message should mention cookie name: %q", states[0].Message) + } +} + +func TestCookieSizeRule_LoadFailure(t *testing.T) { + states := (&cookieSizeRule{}).Evaluate(t.Context(), &fakeObs{failGet: true}, nil) + if len(states) != 1 || states[0].Status != sdk.StatusError { + t.Fatalf("expected single error state, got %+v", states) + } +} diff --git a/checker/types.go b/checker/types.go index 5106601..2c11eac 100644 --- a/checker/types.go +++ b/checker/types.go @@ -104,8 +104,18 @@ type CookieInfo struct { HttpOnly bool `json:"http_only"` SameSite string `json:"same_site,omitempty"` // "Strict", "Lax", "None", or "" HasExpiry bool `json:"has_expiry,omitempty"` + // Size is the byte length of the raw Set-Cookie header value + // (everything after "Set-Cookie: "), used to evaluate the + // per-cookie 4096-byte budget RFC 6265 §6.1 says browsers SHOULD + // support. + Size int `json:"size,omitempty"` } +// MaxCookieSize is the per-cookie size browsers are required to +// support per RFC 6265 §6.1. Cookies above this are likely to be +// silently dropped by some user agents. +const MaxCookieSize = 4096 + // HTMLResource is a