Add RFC 6265bis cookie checks: name prefixes and per-cookie size

This commit is contained in:
nemunaire 2026-04-27 10:05:45 +07:00
commit 2250902a94
4 changed files with 329 additions and 3 deletions

View file

@ -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

View file

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

View file

@ -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)
}
}

View file

@ -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 <script src=...> or <link href=...> reference extracted
// from the HTML body, used to evaluate Subresource Integrity coverage.
type HTMLResource struct {