checker-http/checker/rules_cookies_rfc6265bis.go

148 lines
5.1 KiB
Go

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