checker-http/checker/headers.go
Pierre-Olivier Mercier 603e93355b Deepen CSP, Permissions-Policy and cookie audits
Detect CSP weaknesses individually (unsafe-inline, unsafe-eval, missing
default-src/script-src, permissive sources on script-src or its
default-src fallback) instead of a single catch-all "unsafe" code, and
honour CSP3 fetch-directive fallback via EffectiveSources/WildcardSource
helpers. Validate Permissions-Policy values: warn when a powerful
feature (camera, microphone, geolocation, payment, sensors, …) is
granted to all origins. Add a SameSite aggregate state on cookie audits
so callers get the global ratio alongside per-cookie diagnostics.
2026-04-28 18:43:07 +07:00

240 lines
7.4 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 (
"fmt"
"strconv"
"strings"
)
// HSTSDirectives is the parsed form of a Strict-Transport-Security header
// (RFC 6797 §6.1). MaxAgeSet distinguishes an explicit max-age=0 from a
// header that omitted the (mandatory) directive entirely. Errors lists
// per-directive parse problems so callers can surface them instead of
// silently treating malformed values as max-age=0.
type HSTSDirectives struct {
MaxAge int64
MaxAgeSet bool
IncludeSub bool
Preload bool
Errors []string
}
// ParseHSTS pulls max-age, includeSubDomains and preload out of an HSTS
// value. Returns nil for an empty value so callers can distinguish "header
// absent" from "header present with max-age=0". Per RFC 6797 §6.1.1
// max-age is REQUIRED, MUST appear exactly once, and its value is a
// non-negative integer (optionally quoted); violations are reported via
// the Errors slice.
func ParseHSTS(v string) *HSTSDirectives {
v = strings.TrimSpace(v)
if v == "" {
return nil
}
h := &HSTSDirectives{}
for _, part := range strings.Split(v, ";") {
part = strings.TrimSpace(part)
if part == "" {
continue
}
lower := strings.ToLower(part)
switch {
case strings.HasPrefix(lower, "max-age="):
raw := strings.TrimSpace(part[len("max-age="):])
val, quoted := unquoteHSTS(raw)
if h.MaxAgeSet {
h.Errors = append(h.Errors, "max-age specified more than once")
continue
}
h.MaxAgeSet = true
n, err := strconv.ParseInt(val, 10, 64)
switch {
case err != nil:
h.Errors = append(h.Errors, fmt.Sprintf("max-age value %q is not a valid integer", raw))
case n < 0:
h.Errors = append(h.Errors, fmt.Sprintf("max-age value %d is negative", n))
case quoted && val == "":
h.Errors = append(h.Errors, "max-age value is empty")
default:
h.MaxAge = n
}
case lower == "max-age":
h.Errors = append(h.Errors, "max-age directive has no value")
h.MaxAgeSet = true
case lower == "includesubdomains":
h.IncludeSub = true
case lower == "preload":
h.Preload = true
}
// Unknown directives are ignored per RFC 6797 §6.1.
}
if !h.MaxAgeSet {
h.Errors = append(h.Errors, "max-age directive is missing")
}
return h
}
// unquoteHSTS strips a surrounding pair of double quotes from a directive
// value (RFC 6797 allows the quoted-string form). Returns the inner value
// and whether quotes were present, so callers can distinguish `max-age=""`
// from `max-age=`.
func unquoteHSTS(s string) (string, bool) {
if len(s) >= 2 && s[0] == '"' && s[len(s)-1] == '"' {
return s[1 : len(s)-1], true
}
return s, false
}
// CSPDirectives is the parsed form of a Content-Security-Policy header
// (W3C CSP3). Directive names are lowercased; source tokens keep their
// original casing because keywords like 'unsafe-inline' must round-trip
// verbatim when reported back to the user.
type CSPDirectives struct {
Raw string
Directives map[string][]string
}
// ParseCSP splits a CSP header into its directive → sources map.
func ParseCSP(v string) *CSPDirectives {
v = strings.TrimSpace(v)
if v == "" {
return nil
}
c := &CSPDirectives{Raw: v, Directives: map[string][]string{}}
for _, d := range strings.Split(v, ";") {
d = strings.TrimSpace(d)
if d == "" {
continue
}
fields := strings.Fields(d)
name := strings.ToLower(fields[0])
c.Directives[name] = fields[1:]
}
return c
}
// HasDirective reports whether the named directive is declared at all.
func (c *CSPDirectives) HasDirective(name string) bool {
if c == nil {
return false
}
_, ok := c.Directives[strings.ToLower(name)]
return ok
}
// HasSource reports whether the named directive lists the given source
// token (case-insensitive comparison; pass keywords with their quotes,
// e.g. "'unsafe-inline'").
func (c *CSPDirectives) HasSource(directive, source string) bool {
if c == nil {
return false
}
for _, s := range c.Directives[strings.ToLower(directive)] {
if strings.EqualFold(s, source) {
return true
}
}
return false
}
// cspFetchFallback maps CSP fetch directives to default-src per CSP3
// §6.1: when a directive is absent, the user agent falls back to
// default-src. Non-fetch directives (frame-ancestors, form-action,
// base-uri, …) have no fallback and are deliberately omitted.
var cspFetchFallback = map[string]string{
"child-src": "default-src",
"connect-src": "default-src",
"font-src": "default-src",
"frame-src": "default-src",
"img-src": "default-src",
"manifest-src": "default-src",
"media-src": "default-src",
"object-src": "default-src",
"prefetch-src": "default-src",
"script-src": "default-src",
"script-src-attr": "default-src",
"script-src-elem": "default-src",
"style-src": "default-src",
"style-src-attr": "default-src",
"style-src-elem": "default-src",
"worker-src": "default-src",
}
// EffectiveSources returns the source list that browsers will enforce
// for directive: the directive's own list when declared, otherwise its
// default-src fallback for fetch directives. The second return is true
// iff the policy explicitly declares the directive (or its fallback).
func (c *CSPDirectives) EffectiveSources(directive string) ([]string, bool) {
if c == nil {
return nil, false
}
name := strings.ToLower(directive)
if s, ok := c.Directives[name]; ok {
return s, true
}
if fb, ok := cspFetchFallback[name]; ok {
if s, ok := c.Directives[fb]; ok {
return s, true
}
}
return nil, false
}
func (c *CSPDirectives) effectiveHasSource(directive, source string) bool {
srcs, _ := c.EffectiveSources(directive)
for _, s := range srcs {
if strings.EqualFold(s, source) {
return true
}
}
return false
}
// HasUnsafeInline reports whether the effective script-src or style-src
// allows 'unsafe-inline'.
func (c *CSPDirectives) HasUnsafeInline() bool {
return c.effectiveHasSource("script-src", "'unsafe-inline'") ||
c.effectiveHasSource("style-src", "'unsafe-inline'")
}
// HasUnsafeEval reports whether the effective script-src allows
// 'unsafe-eval' (style-src does not enforce script execution, so we
// look at scripts only).
func (c *CSPDirectives) HasUnsafeEval() bool {
return c.effectiveHasSource("script-src", "'unsafe-eval'")
}
// WildcardSource returns a permissive token (the literal `*`, or one of
// the schemes `http:`, `https:`, `data:`, `blob:`) found in the
// effective sources of directive, or "" if none. These tokens
// effectively neutralise the directive.
func (c *CSPDirectives) WildcardSource(directive string) string {
srcs, _ := c.EffectiveSources(directive)
for _, s := range srcs {
switch strings.ToLower(s) {
case "*", "http:", "https:", "data:", "blob:":
return s
}
}
return ""
}
// ParsedHeaders bundles the structured headers we parse repeatedly. Fields
// are nil when the underlying header is absent on the probe; rules can
// nil-check or rely on the typed accessors which already handle nil.
type ParsedHeaders struct {
HSTS *HSTSDirectives
CSP *CSPDirectives
}
// ParseHeaders builds a ParsedHeaders from a probe's raw header map.
// Header lookups use the lowercase keys produced by the collector.
func ParseHeaders(p HTTPProbe) ParsedHeaders {
return ParsedHeaders{
HSTS: ParseHSTS(p.Headers["strict-transport-security"]),
CSP: ParseCSP(p.Headers["content-security-policy"]),
}
}