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