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
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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue