Add RFC 6265bis cookie checks: name prefixes and per-cookie size
This commit is contained in:
parent
01bdadd2ab
commit
2250902a94
4 changed files with 329 additions and 3 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
148
checker/rules_cookies_rfc6265bis.go
Normal file
148
checker/rules_cookies_rfc6265bis.go
Normal 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
|
||||
}
|
||||
160
checker/rules_cookies_rfc6265bis_test.go
Normal file
160
checker/rules_cookies_rfc6265bis_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue