// This file is part of the happyDomain (R) project. // Copyright (c) 2020-2026 happyDomain // Authors: Pierre-Olivier Mercier, et al. package checker import ( "fmt" "strconv" "strings" ) // HSTSDirectives is the parsed form of a Strict-Transport-Security header // (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". 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 == "" { return nil } h := &HSTSDirectives{} for _, part := range strings.Split(v, ";") { part = strings.TrimSpace(part) if part == "" { continue } lower := strings.ToLower(part) switch { 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 lower == "max-age": h.Errors = append(h.Errors, "max-age directive has no value") h.MaxAgeSet = true case lower == "includesubdomains": h.IncludeSub = true 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 // verbatim when reported back to the user. type CSPDirectives struct { Raw string Directives map[string][]string } // ParseCSP splits a CSP header into its directive → sources map. func ParseCSP(v string) *CSPDirectives { v = strings.TrimSpace(v) if v == "" { return nil } c := &CSPDirectives{Raw: v, Directives: map[string][]string{}} for _, d := range strings.Split(v, ";") { d = strings.TrimSpace(d) if d == "" { continue } fields := strings.Fields(d) name := strings.ToLower(fields[0]) c.Directives[name] = fields[1:] } return c } // HasDirective reports whether the named directive is declared at all. func (c *CSPDirectives) HasDirective(name string) bool { if c == nil { return false } _, ok := c.Directives[strings.ToLower(name)] return ok } // HasSource reports whether the named directive lists the given source // token (case-insensitive comparison; pass keywords with their quotes, // e.g. "'unsafe-inline'"). func (c *CSPDirectives) HasSource(directive, source string) bool { if c == nil { return false } for _, s := range c.Directives[strings.ToLower(directive)] { if strings.EqualFold(s, source) { return true } } return false } // 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 nil, false } 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. type ParsedHeaders struct { HSTS *HSTSDirectives CSP *CSPDirectives } // ParseHeaders builds a ParsedHeaders from a probe's raw header map. // Header lookups use the lowercase keys produced by the collector. func ParseHeaders(p HTTPProbe) ParsedHeaders { return ParsedHeaders{ HSTS: ParseHSTS(p.Headers["strict-transport-security"]), CSP: ParseCSP(p.Headers["content-security-policy"]), } }