checker-http/checker/rules_modern_headers.go

174 lines
6.6 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 (
"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: "Reports the presence of a Permissions-Policy header (W3C Permissions Policy, replaces Feature-Policy).",
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=()`.",
}))
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
}
// 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
}