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:
parent
27a30638f4
commit
603e93355b
8 changed files with 738 additions and 305 deletions
|
|
@ -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, …).
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue