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.
300 lines
11 KiB
Go
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
|
|
}
|