checker-http/checker/rules_security_headers.go
Pierre-Olivier Mercier 603e93355b 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.
2026-04-28 18:43:07 +07:00

242 lines
8.5 KiB
Go

// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
package checker
import (
"fmt"
"strings"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// All five "core" security-header rules are wired through the HeaderRule
// DSL. The richer ones (HSTS, CSP, X-Frame-Options, X-XSS-Protection)
// use Inspect / OnMissing to express thresholds, multi-finding outputs,
// alternative-source fallbacks and reversed "absent is fine" semantics
// without re-implementing the load/iterate/build-state scaffolding.
func init() {
RegisterRule(HeaderRule(HeaderRuleSpec{
Code: "http.hsts",
Description: "Verifies the presence and quality of the Strict-Transport-Security header on HTTPS responses.",
Header: "Strict-Transport-Security",
Inspect: inspectHSTS,
OnMissing: missingHSTS,
}))
RegisterRule(HeaderRule(HeaderRuleSpec{
Code: "http.csp",
Description: "Verifies the presence and quality of the Content-Security-Policy header on HTTPS responses.",
Header: "Content-Security-Policy",
Inspect: inspectCSP,
OnMissing: missingCSP,
}))
RegisterRule(HeaderRule(HeaderRuleSpec{
Code: "http.x_frame_options",
Description: "Verifies that responses set X-Frame-Options or a CSP frame-ancestors directive.",
Header: "X-Frame-Options",
Inspect: inspectXFrameOptions,
OnMissing: missingXFrameOptions,
}))
RegisterRule(HeaderRule(HeaderRuleSpec{
Code: "http.x_content_type_options",
Description: "Verifies that responses set X-Content-Type-Options: nosniff.",
Header: "X-Content-Type-Options",
Required: true,
FixHint: "Add `X-Content-Type-Options: nosniff` to all responses.",
Validate: func(v string) (sdk.Status, string) {
if strings.EqualFold(v, "nosniff") {
return sdk.StatusOK, "X-Content-Type-Options: nosniff is set."
}
return sdk.StatusWarn, "X-Content-Type-Options has an unexpected value: " + strings.ToLower(v)
},
}))
RegisterRule(HeaderRule(HeaderRuleSpec{
Code: "http.x_xss_protection",
Description: "Reports the value of the legacy X-XSS-Protection header (disabled is preferred on modern browsers; CSP is the proper replacement).",
Header: "X-XSS-Protection",
Inspect: inspectXXSSProtection,
OnMissing: func(_ HTTPProbe, _ sdk.CheckerOptions) []HeaderResult {
return []HeaderResult{{
Status: sdk.StatusInfo,
Suffix: "absent",
Message: "X-XSS-Protection is not set; CSP is the recommended replacement.",
}}
},
}))
}
// HSTS ----------------------------------------------------------------
func missingHSTS(_ HTTPProbe, opts sdk.CheckerOptions) []HeaderResult {
status := sdk.StatusWarn
if !sdk.GetBoolOption(opts, OptionRequireHSTS, true) {
status = sdk.StatusInfo
}
return []HeaderResult{{
Status: status,
Suffix: "missing",
Message: "Strict-Transport-Security header is missing.",
Meta: map[string]any{"fix": "Send `Strict-Transport-Security: max-age=15552000; includeSubDomains` from HTTPS responses."},
}}
}
func inspectHSTS(value string, _ HTTPProbe, opts sdk.CheckerOptions) []HeaderResult {
h := ParseHSTS(value)
if h == nil {
// Defensive: ParseHSTS only returns nil on empty input, which the
// DSL has already routed to OnMissing.
return []HeaderResult{{
Status: sdk.StatusWarn, Suffix: "invalid",
Message: "Strict-Transport-Security header is malformed.",
}}
}
if len(h.Errors) > 0 {
return []HeaderResult{{
Status: sdk.StatusWarn,
Suffix: "invalid",
Message: fmt.Sprintf("Strict-Transport-Security header is malformed: %s.", strings.Join(h.Errors, "; ")),
Meta: map[string]any{"fix": "Send `Strict-Transport-Security: max-age=15552000; includeSubDomains` with a non-negative integer max-age."},
}}
}
minDays := sdk.GetIntOption(opts, OptionMinHSTSMaxAgeDays, DefaultMinHSTSMaxAge)
minSeconds := int64(minDays) * 86400
if h.MaxAge < minSeconds {
return []HeaderResult{{
Status: sdk.StatusWarn,
Suffix: "short_max_age",
Message: fmt.Sprintf("HSTS max-age=%d is below the recommended %d seconds (%d days).", h.MaxAge, minSeconds, minDays),
}}
}
return []HeaderResult{{
Status: sdk.StatusOK,
Suffix: "ok",
Message: fmt.Sprintf("HSTS present (max-age=%d, includeSubDomains=%v, preload=%v).", h.MaxAge, h.IncludeSub, h.Preload),
}}
}
// CSP -----------------------------------------------------------------
func missingCSP(_ HTTPProbe, opts sdk.CheckerOptions) []HeaderResult {
status := sdk.StatusInfo
if sdk.GetBoolOption(opts, OptionRequireCSP, false) {
status = sdk.StatusWarn
}
return []HeaderResult{{
Status: status,
Suffix: "missing",
Message: "Content-Security-Policy header is missing.",
Meta: map[string]any{"fix": "Define a CSP appropriate for your application (e.g. default-src 'self')."},
}}
}
// inspectCSP surfaces multiple weakness suffixes per probe — see the
// historical docstring on evaluateCSP for the rationale (unsafe-inline /
// unsafe-eval split, missing default-src, permissive script-src).
func inspectCSP(value string, _ HTTPProbe, _ sdk.CheckerOptions) []HeaderResult {
csp := ParseCSP(value)
if csp == nil {
return []HeaderResult{{
Status: sdk.StatusWarn, Suffix: "invalid",
Message: "Content-Security-Policy header is empty.",
}}
}
var out []HeaderResult
add := func(suffix, msg string) {
out = append(out, HeaderResult{Status: sdk.StatusWarn, Suffix: suffix, Message: msg})
}
hasDefault := csp.HasDirective("default-src")
hasScript := csp.HasDirective("script-src")
if !hasDefault && !hasScript {
add("missing_default",
"Content-Security-Policy declares neither default-src nor script-src; script execution is not constrained.")
}
if csp.HasUnsafeInline() {
add("unsafe_inline",
"Content-Security-Policy allows 'unsafe-inline' for scripts or styles, which negates most XSS protection.")
}
if csp.HasUnsafeEval() {
add("unsafe_eval",
"Content-Security-Policy allows 'unsafe-eval' in script-src, enabling eval()/new Function().")
}
switch {
case hasScript:
if w := csp.WildcardSource("script-src"); w != "" {
add("wildcard_script_src",
"Content-Security-Policy script-src includes the permissive source "+w+", allowing scripts from arbitrary origins.")
}
case hasDefault:
if w := csp.WildcardSource("default-src"); w != "" {
add("wildcard_default_src",
"Content-Security-Policy default-src includes the permissive source "+w+" and no script-src overrides it.")
}
}
if len(out) == 0 {
return []HeaderResult{{
Status: sdk.StatusOK,
Suffix: "ok",
Message: "Content-Security-Policy is set with no detected weaknesses.",
}}
}
return out
}
// X-Frame-Options -----------------------------------------------------
func inspectXFrameOptions(value string, _ HTTPProbe, _ sdk.CheckerOptions) []HeaderResult {
xfo := strings.ToUpper(value)
if xfo == "DENY" || xfo == "SAMEORIGIN" {
return []HeaderResult{{
Status: sdk.StatusOK, Suffix: "ok",
Message: "Clickjacking protection is in place.",
}}
}
return []HeaderResult{{
Status: sdk.StatusWarn, Suffix: "invalid",
Message: "X-Frame-Options has an unrecognised value: " + xfo,
}}
}
func missingXFrameOptions(p HTTPProbe, _ sdk.CheckerOptions) []HeaderResult {
if ParseCSP(p.Headers["content-security-policy"]).HasDirective("frame-ancestors") {
return []HeaderResult{{
Status: sdk.StatusOK, Suffix: "ok",
Message: "Clickjacking protection is in place.",
}}
}
return []HeaderResult{{
Status: sdk.StatusWarn,
Suffix: "missing",
Message: "Neither X-Frame-Options nor CSP frame-ancestors is set.",
Meta: map[string]any{"fix": "Send `X-Frame-Options: DENY` (or SAMEORIGIN) or use CSP frame-ancestors."},
}}
}
// X-XSS-Protection ----------------------------------------------------
func inspectXXSSProtection(value string, _ HTTPProbe, _ sdk.CheckerOptions) []HeaderResult {
switch {
case strings.HasPrefix(value, "0"):
return []HeaderResult{{
Status: sdk.StatusOK, Suffix: "disabled",
Message: "X-XSS-Protection is explicitly disabled (recommended).",
}}
case strings.Contains(strings.ToLower(value), "mode=block"):
return []HeaderResult{{
Status: sdk.StatusInfo, Suffix: "enabled",
Message: "X-XSS-Protection is set to the historically recommended `1; mode=block`. Modern browsers ignore this header; CSP is the proper replacement.",
}}
default:
return []HeaderResult{{
Status: sdk.StatusInfo, Suffix: "enabled",
Message: "X-XSS-Protection is enabled. Modern browsers ignore this header; CSP is the proper replacement.",
}}
}
}