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.
This commit is contained in:
nemunaire 2026-04-27 11:50:42 +07:00
commit 603e93355b
8 changed files with 738 additions and 305 deletions

View file

@ -5,6 +5,8 @@
package checker
import (
"fmt"
"sort"
"strings"
sdk "git.happydns.org/checker-sdk-go/checker"
@ -38,10 +40,11 @@ func init() {
RegisterRule(HeaderRule(HeaderRuleSpec{
Code: "http.permissions_policy",
Description: "Reports the presence of a Permissions-Policy header (W3C Permissions Policy, replaces Feature-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{
@ -146,6 +149,129 @@ func validateCORP(v string) (sdk.Status, string) {
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, …).