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,21 +5,30 @@
|
|||
package checker
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// HSTSDirectives is the parsed form of a Strict-Transport-Security header
|
||||
// (RFC 6797 §6.1).
|
||||
// (RFC 6797 §6.1). MaxAgeSet distinguishes an explicit max-age=0 from a
|
||||
// header that omitted the (mandatory) directive entirely. Errors lists
|
||||
// per-directive parse problems so callers can surface them instead of
|
||||
// silently treating malformed values as max-age=0.
|
||||
type HSTSDirectives struct {
|
||||
MaxAge int64
|
||||
MaxAgeSet bool
|
||||
IncludeSub bool
|
||||
Preload bool
|
||||
Errors []string
|
||||
}
|
||||
|
||||
// ParseHSTS pulls max-age, includeSubDomains and preload out of an HSTS
|
||||
// value. Returns nil for an empty value so callers can distinguish "header
|
||||
// absent" from "header present with max-age=0".
|
||||
// absent" from "header present with max-age=0". Per RFC 6797 §6.1.1
|
||||
// max-age is REQUIRED, MUST appear exactly once, and its value is a
|
||||
// non-negative integer (optionally quoted); violations are reported via
|
||||
// the Errors slice.
|
||||
func ParseHSTS(v string) *HSTSDirectives {
|
||||
v = strings.TrimSpace(v)
|
||||
if v == "" {
|
||||
|
|
@ -28,21 +37,57 @@ func ParseHSTS(v string) *HSTSDirectives {
|
|||
h := &HSTSDirectives{}
|
||||
for _, part := range strings.Split(v, ";") {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
lower := strings.ToLower(part)
|
||||
switch {
|
||||
case strings.HasPrefix(strings.ToLower(part), "max-age="):
|
||||
val := strings.Trim(part[len("max-age="):], "\"")
|
||||
if n, err := strconv.ParseInt(val, 10, 64); err == nil {
|
||||
case strings.HasPrefix(lower, "max-age="):
|
||||
raw := strings.TrimSpace(part[len("max-age="):])
|
||||
val, quoted := unquoteHSTS(raw)
|
||||
if h.MaxAgeSet {
|
||||
h.Errors = append(h.Errors, "max-age specified more than once")
|
||||
continue
|
||||
}
|
||||
h.MaxAgeSet = true
|
||||
n, err := strconv.ParseInt(val, 10, 64)
|
||||
switch {
|
||||
case err != nil:
|
||||
h.Errors = append(h.Errors, fmt.Sprintf("max-age value %q is not a valid integer", raw))
|
||||
case n < 0:
|
||||
h.Errors = append(h.Errors, fmt.Sprintf("max-age value %d is negative", n))
|
||||
case quoted && val == "":
|
||||
h.Errors = append(h.Errors, "max-age value is empty")
|
||||
default:
|
||||
h.MaxAge = n
|
||||
}
|
||||
case strings.EqualFold(part, "includeSubDomains"):
|
||||
case lower == "max-age":
|
||||
h.Errors = append(h.Errors, "max-age directive has no value")
|
||||
h.MaxAgeSet = true
|
||||
case lower == "includesubdomains":
|
||||
h.IncludeSub = true
|
||||
case strings.EqualFold(part, "preload"):
|
||||
case lower == "preload":
|
||||
h.Preload = true
|
||||
}
|
||||
// Unknown directives are ignored per RFC 6797 §6.1.
|
||||
}
|
||||
if !h.MaxAgeSet {
|
||||
h.Errors = append(h.Errors, "max-age directive is missing")
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
// unquoteHSTS strips a surrounding pair of double quotes from a directive
|
||||
// value (RFC 6797 allows the quoted-string form). Returns the inner value
|
||||
// and whether quotes were present, so callers can distinguish `max-age=""`
|
||||
// from `max-age=`.
|
||||
func unquoteHSTS(s string) (string, bool) {
|
||||
if len(s) >= 2 && s[0] == '"' && s[len(s)-1] == '"' {
|
||||
return s[1 : len(s)-1], true
|
||||
}
|
||||
return s, false
|
||||
}
|
||||
|
||||
// CSPDirectives is the parsed form of a Content-Security-Policy header
|
||||
// (W3C CSP3). Directive names are lowercased; source tokens keep their
|
||||
// original casing because keywords like 'unsafe-inline' must round-trip
|
||||
|
|
@ -95,23 +140,88 @@ func (c *CSPDirectives) HasSource(directive, source string) bool {
|
|||
return false
|
||||
}
|
||||
|
||||
// HasUnsafe reports whether any directive uses 'unsafe-inline' or
|
||||
// 'unsafe-eval' — the two keywords that nullify most of CSP's value.
|
||||
func (c *CSPDirectives) HasUnsafe() bool {
|
||||
// cspFetchFallback maps CSP fetch directives to default-src per CSP3
|
||||
// §6.1: when a directive is absent, the user agent falls back to
|
||||
// default-src. Non-fetch directives (frame-ancestors, form-action,
|
||||
// base-uri, …) have no fallback and are deliberately omitted.
|
||||
var cspFetchFallback = map[string]string{
|
||||
"child-src": "default-src",
|
||||
"connect-src": "default-src",
|
||||
"font-src": "default-src",
|
||||
"frame-src": "default-src",
|
||||
"img-src": "default-src",
|
||||
"manifest-src": "default-src",
|
||||
"media-src": "default-src",
|
||||
"object-src": "default-src",
|
||||
"prefetch-src": "default-src",
|
||||
"script-src": "default-src",
|
||||
"script-src-attr": "default-src",
|
||||
"script-src-elem": "default-src",
|
||||
"style-src": "default-src",
|
||||
"style-src-attr": "default-src",
|
||||
"style-src-elem": "default-src",
|
||||
"worker-src": "default-src",
|
||||
}
|
||||
|
||||
// EffectiveSources returns the source list that browsers will enforce
|
||||
// for directive: the directive's own list when declared, otherwise its
|
||||
// default-src fallback for fetch directives. The second return is true
|
||||
// iff the policy explicitly declares the directive (or its fallback).
|
||||
func (c *CSPDirectives) EffectiveSources(directive string) ([]string, bool) {
|
||||
if c == nil {
|
||||
return false
|
||||
return nil, false
|
||||
}
|
||||
for _, sources := range c.Directives {
|
||||
for _, s := range sources {
|
||||
ls := strings.ToLower(s)
|
||||
if ls == "'unsafe-inline'" || ls == "'unsafe-eval'" {
|
||||
return true
|
||||
}
|
||||
name := strings.ToLower(directive)
|
||||
if s, ok := c.Directives[name]; ok {
|
||||
return s, true
|
||||
}
|
||||
if fb, ok := cspFetchFallback[name]; ok {
|
||||
if s, ok := c.Directives[fb]; ok {
|
||||
return s, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func (c *CSPDirectives) effectiveHasSource(directive, source string) bool {
|
||||
srcs, _ := c.EffectiveSources(directive)
|
||||
for _, s := range srcs {
|
||||
if strings.EqualFold(s, source) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// HasUnsafeInline reports whether the effective script-src or style-src
|
||||
// allows 'unsafe-inline'.
|
||||
func (c *CSPDirectives) HasUnsafeInline() bool {
|
||||
return c.effectiveHasSource("script-src", "'unsafe-inline'") ||
|
||||
c.effectiveHasSource("style-src", "'unsafe-inline'")
|
||||
}
|
||||
|
||||
// HasUnsafeEval reports whether the effective script-src allows
|
||||
// 'unsafe-eval' (style-src does not enforce script execution, so we
|
||||
// look at scripts only).
|
||||
func (c *CSPDirectives) HasUnsafeEval() bool {
|
||||
return c.effectiveHasSource("script-src", "'unsafe-eval'")
|
||||
}
|
||||
|
||||
// WildcardSource returns a permissive token (the literal `*`, or one of
|
||||
// the schemes `http:`, `https:`, `data:`, `blob:`) found in the
|
||||
// effective sources of directive, or "" if none. These tokens
|
||||
// effectively neutralise the directive.
|
||||
func (c *CSPDirectives) WildcardSource(directive string) string {
|
||||
srcs, _ := c.EffectiveSources(directive)
|
||||
for _, s := range srcs {
|
||||
switch strings.ToLower(s) {
|
||||
case "*", "http:", "https:", "data:", "blob:":
|
||||
return s
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ParsedHeaders bundles the structured headers we parse repeatedly. Fields
|
||||
// are nil when the underlying header is absent on the probe; rules can
|
||||
// nil-check or rely on the typed accessors which already handle nil.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue