// This file is part of the happyDomain (R) project. // Copyright (c) 2020-2026 happyDomain // Authors: Pierre-Olivier Mercier, et al. package checker import ( "strconv" "strings" ) // HSTSDirectives is the parsed form of a Strict-Transport-Security header // (RFC 6797 §6.1). type HSTSDirectives struct { MaxAge int64 IncludeSub bool Preload bool } // 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". 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) 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 { h.MaxAge = n } case strings.EqualFold(part, "includeSubDomains"): h.IncludeSub = true case strings.EqualFold(part, "preload"): h.Preload = true } } return h } // 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 } // 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 { if c == nil { return false } for _, sources := range c.Directives { for _, s := range sources { ls := strings.ToLower(s) if ls == "'unsafe-inline'" || ls == "'unsafe-eval'" { return true } } } return false } // 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"]), } }