checker-http/checker/rules_modern_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

300 lines
11 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"
"sort"
"strings"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// This file wires modern privacy/isolation headers entirely through the
// HeaderRule DSL. Each rule is a single declarative spec — no per-rule
// type, no Evaluate plumbing, no test scaffolding beyond the value
// validator.
//
// Coverage:
// - Referrer-Policy (W3C Referrer Policy)
// - Permissions-Policy (W3C Permissions Policy, replaces Feature-Policy)
// - Cross-Origin-Opener-Policy (HTML spec, COOP)
// - Cross-Origin-Embedder-Policy (HTML spec, COEP)
// - Cross-Origin-Resource-Policy (Fetch spec, CORP)
//
// These are all "presence + value sanity" checks. Anything richer (e.g.
// directive-by-directive Permissions-Policy parsing) belongs in its own
// hand-rolled rule.
func init() {
RegisterRule(HeaderRule(HeaderRuleSpec{
Code: "http.referrer_policy",
Description: "Verifies that responses set a Referrer-Policy header with a privacy-preserving value.",
Header: "Referrer-Policy",
Required: false,
FixHint: "Send `Referrer-Policy: strict-origin-when-cross-origin` (the modern browser default) or stricter.",
Validate: validateReferrerPolicy,
}))
RegisterRule(HeaderRule(HeaderRuleSpec{
Code: "http.permissions_policy",
Description: "Verifies that the Permissions-Policy header restricts powerful APIs (camera, microphone, geolocation, …).",
Header: "Permissions-Policy",
Required: false,
FixHint: "Define a Permissions-Policy that disables APIs the site does not use, e.g. `Permissions-Policy: camera=(), microphone=(), geolocation=()`.",
Validate: validatePermissionsPolicy,
}))
RegisterRule(HeaderRule(HeaderRuleSpec{
Code: "http.coop",
Description: "Verifies the Cross-Origin-Opener-Policy (COOP) header for cross-origin process isolation.",
Header: "Cross-Origin-Opener-Policy",
Required: false,
FixHint: "Send `Cross-Origin-Opener-Policy: same-origin` to isolate this document from cross-origin windows.",
Validate: validateCOOP,
}))
RegisterRule(HeaderRule(HeaderRuleSpec{
Code: "http.coep",
Description: "Verifies the Cross-Origin-Embedder-Policy (COEP) header. Required (with COOP) to enable cross-origin isolation and APIs such as SharedArrayBuffer.",
Header: "Cross-Origin-Embedder-Policy",
Required: false,
FixHint: "Send `Cross-Origin-Embedder-Policy: require-corp` (or `credentialless`) once embedded resources opt in via CORP/CORS.",
Validate: validateCOEP,
}))
RegisterRule(HeaderRule(HeaderRuleSpec{
Code: "http.corp",
Description: "Verifies the Cross-Origin-Resource-Policy (CORP) header, which lets a server forbid cross-origin/cross-site embedding of its responses.",
Header: "Cross-Origin-Resource-Policy",
Required: false,
FixHint: "Send `Cross-Origin-Resource-Policy: same-origin` (or `same-site`) on responses that should not be embedded cross-origin.",
Validate: validateCORP,
}))
}
// validateReferrerPolicy accepts any token (or comma-separated list of
// tokens) defined by the W3C Referrer Policy spec, but downgrades the
// status when the only effective value is the historically lax
// `unsafe-url` or `no-referrer-when-downgrade`. Per the spec, browsers
// pick the last *recognised* token of a comma list, so we evaluate that
// one.
func validateReferrerPolicy(v string) (sdk.Status, string) {
tokens := splitCSV(v)
if len(tokens) == 0 {
return sdk.StatusWarn, "Referrer-Policy is empty."
}
// Per spec, the user-agent picks the last token it recognises.
var effective string
for _, t := range tokens {
if isReferrerPolicyToken(t) {
effective = t
}
}
if effective == "" {
return sdk.StatusWarn, "Referrer-Policy has no recognised token: " + v
}
switch effective {
case "unsafe-url":
return sdk.StatusWarn, "Referrer-Policy: unsafe-url leaks the full URL (including query) cross-origin; prefer strict-origin-when-cross-origin."
case "no-referrer-when-downgrade":
return sdk.StatusInfo, "Referrer-Policy: no-referrer-when-downgrade is the legacy default; prefer strict-origin-when-cross-origin."
}
return sdk.StatusOK, "Referrer-Policy is set to " + effective + "."
}
func isReferrerPolicyToken(t string) bool {
switch t {
case "no-referrer",
"no-referrer-when-downgrade",
"origin",
"origin-when-cross-origin",
"same-origin",
"strict-origin",
"strict-origin-when-cross-origin",
"unsafe-url",
"":
return t != ""
}
return false
}
func validateCOOP(v string) (sdk.Status, string) {
switch strings.ToLower(directiveToken(v)) {
case "same-origin", "same-origin-allow-popups", "noopener-allow-popups":
return sdk.StatusOK, "Cross-Origin-Opener-Policy is set to " + v + "."
case "unsafe-none":
return sdk.StatusWarn, "Cross-Origin-Opener-Policy: unsafe-none disables the protection (this is the browser default; the header is redundant)."
}
return sdk.StatusWarn, "Cross-Origin-Opener-Policy has an unrecognised value: " + v
}
func validateCOEP(v string) (sdk.Status, string) {
switch strings.ToLower(directiveToken(v)) {
case "require-corp", "credentialless":
return sdk.StatusOK, "Cross-Origin-Embedder-Policy is set to " + v + "."
case "unsafe-none":
return sdk.StatusWarn, "Cross-Origin-Embedder-Policy: unsafe-none disables the protection (this is the browser default; the header is redundant)."
}
return sdk.StatusWarn, "Cross-Origin-Embedder-Policy has an unrecognised value: " + v
}
func validateCORP(v string) (sdk.Status, string) {
switch strings.ToLower(directiveToken(v)) {
case "same-origin", "same-site", "cross-origin":
return sdk.StatusOK, "Cross-Origin-Resource-Policy is set to " + v + "."
}
return sdk.StatusWarn, "Cross-Origin-Resource-Policy has an unrecognised value: " + v
}
// dangerousPermissionsPolicyFeatures lists features whose default
// (browser-level) allowlist is permissive enough to warrant an explicit
// restriction. Sources: W3C Permissions Policy registry + the
// "powerful features" list (camera, microphone, geolocation, payment,
// usb, midi, sensors, screen-wake-lock, fullscreen, autoplay, …).
// Tracking-related features (interest-cohort, browsing-topics) are
// included for privacy.
var dangerousPermissionsPolicyFeatures = map[string]struct{}{
"accelerometer": {},
"ambient-light-sensor": {},
"autoplay": {},
"battery": {},
"browsing-topics": {},
"camera": {},
"display-capture": {},
"document-domain": {},
"encrypted-media": {},
"fullscreen": {},
"geolocation": {},
"gyroscope": {},
"hid": {},
"identity-credentials-get": {},
"idle-detection": {},
"interest-cohort": {},
"magnetometer": {},
"microphone": {},
"midi": {},
"otp-credentials": {},
"payment": {},
"picture-in-picture": {},
"publickey-credentials-create": {},
"publickey-credentials-get": {},
"screen-wake-lock": {},
"serial": {},
"storage-access": {},
"usb": {},
"window-management": {},
"xr-spatial-tracking": {},
}
// validatePermissionsPolicy parses a Permissions-Policy header
// (RFC 8941 structured fields, dictionary form) and warns when any
// dangerous feature is granted to all origins (`*`) or when the value
// is syntactically broken. A header that only restricts features (e.g.
// `camera=()`) is accepted even if it does not enumerate every
// dangerous one — listing every feature would be noisy and
// most browsers default-deny powerful features in cross-origin frames
// already.
func validatePermissionsPolicy(v string) (sdk.Status, string) {
entries, err := parsePermissionsPolicy(v)
if err != nil {
return sdk.StatusWarn, "Permissions-Policy is malformed: " + err.Error()
}
if len(entries) == 0 {
return sdk.StatusWarn, "Permissions-Policy is empty."
}
var permissive []string
for feature, allowlist := range entries {
if _, dangerous := dangerousPermissionsPolicyFeatures[feature]; !dangerous {
continue
}
if isPermissionsAllowlistWildcard(allowlist) {
permissive = append(permissive, feature)
}
}
if len(permissive) > 0 {
sort.Strings(permissive)
return sdk.StatusWarn,
"Permissions-Policy grants " + strings.Join(permissive, ", ") +
" to all origins (`*`); restrict these to (), self or specific origins."
}
return sdk.StatusOK, "Permissions-Policy restricts powerful features."
}
// parsePermissionsPolicy splits the header into a feature → allowlist
// map. It tolerates the two forms in the wild: the spec'd
// structured-field form (`camera=()`, `geolocation=(self "https://x")`)
// and the legacy comma form (`camera=()`). Allowlist tokens are kept
// verbatim minus surrounding parentheses so the caller can detect `*`.
func parsePermissionsPolicy(v string) (map[string]string, error) {
v = strings.TrimSpace(v)
if v == "" {
return nil, nil
}
out := map[string]string{}
for _, raw := range strings.Split(v, ",") {
entry := strings.TrimSpace(raw)
if entry == "" {
continue
}
eq := strings.IndexByte(entry, '=')
if eq < 0 {
return nil, fmt.Errorf("entry %q is missing `=`", entry)
}
feature := strings.ToLower(strings.TrimSpace(entry[:eq]))
allowlist := strings.TrimSpace(entry[eq+1:])
if feature == "" {
return nil, fmt.Errorf("entry %q has an empty feature name", entry)
}
out[feature] = allowlist
}
return out, nil
}
// isPermissionsAllowlistWildcard reports whether an allowlist grants
// the feature to every origin. The two equivalent forms are the bare
// `*` and the parenthesised list `(*)`.
func isPermissionsAllowlistWildcard(allowlist string) bool {
a := strings.TrimSpace(allowlist)
if a == "*" {
return true
}
if strings.HasPrefix(a, "(") && strings.HasSuffix(a, ")") {
inner := strings.TrimSpace(a[1 : len(a)-1])
for _, tok := range strings.Fields(inner) {
if tok == "*" {
return true
}
}
}
return false
}
// splitCSV splits on commas, trims whitespace, lowercases, and drops
// empty fragments. Used for header values that are comma-separated lists
// of tokens (Referrer-Policy, Accept-Encoding, …).
func splitCSV(v string) []string {
parts := strings.Split(v, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(strings.ToLower(p))
if p != "" {
out = append(out, p)
}
}
return out
}
// directiveToken extracts the first whitespace-delimited token of a
// header value, stripping any trailing parameters (e.g. `same-origin
// "..."` -> `same-origin`). Suitable for single-token directive headers
// like COOP/COEP/CORP.
func directiveToken(v string) string {
v = strings.TrimSpace(v)
if i := strings.IndexAny(v, " \t;,"); i >= 0 {
return v[:i]
}
return v
}