148 lines
5.1 KiB
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
|
|
}
|