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:
nemunaire 2026-04-27 11:50:42 +07:00
commit 603e93355b
8 changed files with 738 additions and 305 deletions

View file

@ -11,50 +11,71 @@ import (
sdk "git.happydns.org/checker-sdk-go/checker"
)
// HeaderRuleSpec declares a "presence + value validation" rule for one
// HTTP response header. It covers the most common shape of security
// header rule (one of Referrer-Policy, Permissions-Policy, COOP, COEP,
// CORP, X-Content-Type-Options, …) without forcing the author to write
// the load/iterate/build-state scaffolding.
// HeaderResult is one observation produced by a HeaderRuleSpec callback.
// Suffix is appended to spec.Code (with a dot separator) to form the
// final CheckState code, e.g. "http.hsts" + "short_max_age" →
// "http.hsts.short_max_age". An empty Suffix uses spec.Code verbatim.
type HeaderResult struct {
Status sdk.Status
Suffix string
Message string
Meta map[string]any
}
// HeaderRuleSpec declares a per-HTTPS-probe rule built around a single
// response header. It supersedes the per-rule Evaluate boilerplate that
// every "load HTTPData → iterate successful HTTPS probes → inspect one
// header → emit one CheckState" rule used to repeat.
//
// The DSL emits three CheckState codes derived from Code:
// - Code+".missing" when the header is absent
// - Code+".invalid" when Validate returns a non-OK status
// - Code+".ok" when Validate accepts the value
// Three callbacks cover the spectrum, from simplest to most expressive:
//
// Rules with richer semantics (HSTS quality thresholds, CSP directive
// inspection, cookie flag aggregation, legacy headers with reversed
// "absent is fine" semantics) keep implementing sdk.CheckRule directly.
// - Validate: the header is present and a single boolean verdict is
// enough. Returns (Status, message); the rule emits ".ok" on
// StatusOK or ".invalid" otherwise. Used by the modern privacy
// headers (Referrer-Policy, COOP/COEP/CORP, Permissions-Policy).
//
// - Inspect: the header is present and may produce any number of
// findings with arbitrary suffixes. Used by HSTS (".short_max_age"),
// CSP (".unsafe_inline" / ".wildcard_script_src" / …) and the
// legacy X-XSS-Protection rule which reports custom suffixes
// (".disabled", ".enabled").
//
// - OnMissing: the header is absent and the default ".missing"
// emitter is wrong — either an alternative satisfies the
// requirement (CSP frame-ancestors standing in for X-Frame-Options),
// or absence has non-default severity (X-XSS-Protection emits
// Info ".absent", not Warn ".missing"), or the severity depends
// on a CheckerOption (HSTS/CSP gate "missing" on a configurable
// "required" flag).
//
// Validate and Inspect are mutually exclusive. OnMissing can be combined
// with either. Specs that omit all three behave as a pure presence check
// (".ok" when set, default ".missing" when not).
type HeaderRuleSpec struct {
// Code is the rule's Name() and the prefix for every CheckState
// code it emits.
Code string
// Description is returned by Description().
Code string
Description string
Header string
// Header is the response header to inspect. Lookups go through the
// lowercased map populated by the collector, so casing is flexible.
Header string
// Required toggles the severity of an absent header: Warn when true,
// Info when false.
// Required toggles the severity of the default ".missing" emitter
// (Warn when true, Info when false). Ignored when OnMissing is set.
Required bool
// Validate, when set, inspects the trimmed header value. Return
// (StatusOK, msg) to accept the value (emits ".ok" with msg) or any
// other status to flag it (emits ".invalid" with msg). When nil,
// presence alone is treated as OK with a generic message.
Validate func(value string) (sdk.Status, string)
// FixHint, when set, is attached as Meta.fix on the ".missing"
// state.
// FixHint, when set, populates Meta.fix on the default ".missing"
// emitter. Ignored when OnMissing is set (callbacks must build
// their own Meta).
FixHint string
Validate func(value string) (sdk.Status, string)
Inspect func(value string, p HTTPProbe, opts sdk.CheckerOptions) []HeaderResult
OnMissing func(p HTTPProbe, opts sdk.CheckerOptions) []HeaderResult
}
// HeaderRule constructs a self-contained sdk.CheckRule from a spec.
// Intended to be wired in init() via RegisterRule.
func HeaderRule(spec HeaderRuleSpec) sdk.CheckRule {
if spec.Validate != nil && spec.Inspect != nil {
panic("checker: HeaderRuleSpec " + spec.Code + " sets both Validate and Inspect")
}
return &headerRule{spec: spec}
}
@ -63,49 +84,90 @@ type headerRule struct{ spec HeaderRuleSpec }
func (r *headerRule) Name() string { return r.spec.Code }
func (r *headerRule) Description() string { return r.spec.Description }
func (r *headerRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
func (r *headerRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState {
data, errSt := loadHTTPData(ctx, obs)
if errSt != nil {
return []sdk.CheckState{*errSt}
}
probes := successfulHTTPSProbes(data.Probes)
if len(probes) == 0 {
return []sdk.CheckState{unknownState(r.spec.Code+".no_https", "No successful HTTPS probe to evaluate.")}
}
headerKey := strings.ToLower(r.spec.Header)
return EvalPerHTTPS(data, r.spec.Code, func(p HTTPProbe) sdk.CheckState {
v := strings.TrimSpace(p.Headers[headerKey])
if v == "" {
status := sdk.StatusWarn
if !r.spec.Required {
status = sdk.StatusInfo
}
st := sdk.CheckState{
Status: status,
Code: r.spec.Code + ".missing",
Subject: p.Address,
Message: r.spec.Header + " is not set.",
}
if r.spec.FixHint != "" {
st.Meta = map[string]any{"fix": r.spec.FixHint}
}
return st
out := make([]sdk.CheckState, 0, len(probes))
for _, p := range probes {
for _, res := range r.evaluateProbe(p, opts, headerKey) {
out = append(out, r.toCheckState(p, res))
}
if r.spec.Validate == nil {
return sdk.CheckState{
Status: sdk.StatusOK,
Code: r.spec.Code + ".ok",
Subject: p.Address,
Message: r.spec.Header + " is set.",
}
}
status, msg := r.spec.Validate(v)
suffix := ".invalid"
if status == sdk.StatusOK {
suffix = ".ok"
}
return sdk.CheckState{
Status: status,
Code: r.spec.Code + suffix,
Subject: p.Address,
Message: msg,
}
})
}
return out
}
func (r *headerRule) evaluateProbe(p HTTPProbe, opts sdk.CheckerOptions, headerKey string) []HeaderResult {
v := strings.TrimSpace(p.Headers[headerKey])
if v == "" {
if r.spec.OnMissing != nil {
return ensureNonEmpty(r.spec.OnMissing(p, opts), r.defaultPresent())
}
return []HeaderResult{r.defaultMissing()}
}
switch {
case r.spec.Inspect != nil:
return ensureNonEmpty(r.spec.Inspect(v, p, opts), r.defaultPresent())
case r.spec.Validate != nil:
status, msg := r.spec.Validate(v)
suffix := "invalid"
if status == sdk.StatusOK {
suffix = "ok"
}
return []HeaderResult{{Status: status, Suffix: suffix, Message: msg}}
default:
return []HeaderResult{r.defaultPresent()}
}
}
func (r *headerRule) defaultMissing() HeaderResult {
status := sdk.StatusInfo
if r.spec.Required {
status = sdk.StatusWarn
}
res := HeaderResult{
Status: status,
Suffix: "missing",
Message: r.spec.Header + " is not set.",
}
if r.spec.FixHint != "" {
res.Meta = map[string]any{"fix": r.spec.FixHint}
}
return res
}
func (r *headerRule) defaultPresent() HeaderResult {
return HeaderResult{
Status: sdk.StatusOK,
Suffix: "ok",
Message: r.spec.Header + " is set.",
}
}
func (r *headerRule) toCheckState(p HTTPProbe, res HeaderResult) sdk.CheckState {
code := r.spec.Code
if res.Suffix != "" {
code = code + "." + res.Suffix
}
return sdk.CheckState{
Status: res.Status,
Code: code,
Subject: p.Address,
Message: res.Message,
Meta: res.Meta,
}
}
func ensureNonEmpty(results []HeaderResult, fallback HeaderResult) []HeaderResult {
if len(results) == 0 {
return []HeaderResult{fallback}
}
return results
}

View file

@ -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.

View file

@ -35,6 +35,7 @@ func (r *cookieFlagsRule) Evaluate(ctx context.Context, obs sdk.ObservationGette
var states []sdk.CheckState
totalCookies := 0
samesiteMissing := 0
for _, p := range probes {
for _, c := range p.Cookies {
totalCookies++
@ -47,6 +48,7 @@ func (r *cookieFlagsRule) Evaluate(ctx context.Context, obs sdk.ObservationGette
}
if c.SameSite == "" {
issues = append(issues, "missing SameSite")
samesiteMissing++
} else if strings.EqualFold(c.SameSite, "None") && !c.Secure {
issues = append(issues, "SameSite=None requires Secure")
}
@ -63,6 +65,16 @@ func (r *cookieFlagsRule) Evaluate(ctx context.Context, obs sdk.ObservationGette
if totalCookies == 0 {
return []sdk.CheckState{passState("http.cookie_flags.none", "No cookies were set on the inspected responses.")}
}
if samesiteMissing > 0 {
// Aggregate alongside per-cookie diagnostics so callers see the
// global ratio at a glance — mirrors what Mozilla Observatory
// reports as a single cookies test outcome.
states = append(states, sdk.CheckState{
Status: sdk.StatusWarn,
Code: "http.cookie_flags.samesite_missing",
Message: fmt.Sprintf("%d of %d cookies do not set SameSite.", samesiteMissing, totalCookies),
})
}
if len(states) == 0 {
return []sdk.CheckState{passState("http.cookie_flags.ok", fmt.Sprintf("All %d cookies have proper Secure/HttpOnly/SameSite flags.", totalCookies))}
}

View file

@ -48,12 +48,26 @@ func TestCookieFlagsRule_Issues(t *testing.T) {
{Name: "none-without-secure", Secure: false, HttpOnly: true, SameSite: "None"},
}
states := runRule(t, &cookieFlagsRule{}, &HTTPData{Probes: []HTTPProbe{p}}, nil)
if len(states) != len(p.Cookies) {
t.Fatalf("got %d states, want %d", len(states), len(p.Cookies))
// Per-cookie diagnostics + a single SameSite aggregate (1 cookie out
// of 4 is missing SameSite).
if len(states) != len(p.Cookies)+1 {
t.Fatalf("got %d states, want %d", len(states), len(p.Cookies)+1)
}
mustStatus(t, states, sdk.StatusWarn)
// Check each diagnostic mentions the cookie name and a relevant phrase.
if !hasCode(states, "http.cookie_flags.samesite_missing") {
t.Errorf("missing samesite_missing aggregate: %+v", states)
}
for _, st := range states {
if st.Code == "http.cookie_flags.samesite_missing" {
if !strings.Contains(st.Message, "1 of 4") {
t.Errorf("aggregate message %q should mention 1 of 4", st.Message)
}
}
}
// Check each per-cookie diagnostic mentions the cookie name and a
// relevant phrase.
wantSubstr := map[string]string{
"no-secure": "missing Secure",
"no-httponly": "missing HttpOnly",
@ -61,6 +75,9 @@ func TestCookieFlagsRule_Issues(t *testing.T) {
"none-without-secure": "SameSite=None requires Secure",
}
for _, st := range states {
if st.Code != "http.cookie_flags.weak" {
continue
}
matched := false
for name, phrase := range wantSubstr {
if strings.Contains(st.Message, name) && strings.Contains(st.Message, phrase) {
@ -74,6 +91,26 @@ func TestCookieFlagsRule_Issues(t *testing.T) {
}
}
func TestCookieFlagsRule_SameSiteAggregateOnly(t *testing.T) {
// Two cookies, both otherwise compliant but missing SameSite. We
// expect 2 per-cookie warnings + 1 aggregate.
p := httpsProbe("a:443")
p.Cookies = []CookieInfo{
{Name: "a", Secure: true, HttpOnly: true, SameSite: ""},
{Name: "b", Secure: true, HttpOnly: true, SameSite: ""},
}
states := runRule(t, &cookieFlagsRule{}, &HTTPData{Probes: []HTTPProbe{p}}, nil)
mustStatus(t, states, sdk.StatusWarn)
if !hasCode(states, "http.cookie_flags.samesite_missing") {
t.Fatalf("missing aggregate state: %+v", states)
}
for _, st := range states {
if st.Code == "http.cookie_flags.samesite_missing" && !strings.Contains(st.Message, "2 of 2") {
t.Errorf("aggregate should report 2 of 2, got %q", st.Message)
}
}
}
func TestCookieFlagsRule_SameSiteNoneCaseInsensitive(t *testing.T) {
p := httpsProbe("a:443")
p.Cookies = []CookieInfo{{Name: "x", Secure: false, HttpOnly: true, SameSite: "none"}}

View file

@ -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, …).

View file

@ -57,8 +57,6 @@ func TestReferrerPolicyRule(t *testing.T) {
}
func TestPermissionsPolicyRule(t *testing.T) {
// Permissions-Policy has no Validate function: presence alone is OK,
// absence is Info (Required=false).
cases := []struct {
name string
value string
@ -66,8 +64,14 @@ func TestPermissionsPolicyRule(t *testing.T) {
code string
}{
{"missing", "", sdk.StatusInfo, "http.permissions_policy.missing"},
{"present", "camera=(), microphone=()", sdk.StatusOK, "http.permissions_policy.ok"},
{"restrictive", "camera=(), microphone=()", sdk.StatusOK, "http.permissions_policy.ok"},
{"self only", "geolocation=(self)", sdk.StatusOK, "http.permissions_policy.ok"},
{"empty value treated as missing", " ", sdk.StatusInfo, "http.permissions_policy.missing"},
{"camera wildcard", "camera=*", sdk.StatusWarn, "http.permissions_policy.invalid"},
{"microphone parenthesised wildcard", "microphone=(*)", sdk.StatusWarn, "http.permissions_policy.invalid"},
{"non-dangerous wildcard ignored", "fullscreen=(self), accelerometer=*", sdk.StatusWarn, "http.permissions_policy.invalid"},
{"unknown feature wildcard ignored", "totally-made-up=*", sdk.StatusOK, "http.permissions_policy.ok"},
{"malformed entry", "camera", sdk.StatusWarn, "http.permissions_policy.invalid"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {

View file

@ -5,166 +5,43 @@
package checker
import (
"context"
"fmt"
"strings"
sdk "git.happydns.org/checker-sdk-go/checker"
)
func init() {
RegisterRule(&hstsRule{})
RegisterRule(&cspRule{})
RegisterRule(&xFrameOptionsRule{})
RegisterRule(&xXSSProtectionRule{})
}
// hstsRule checks the Strict-Transport-Security header on HTTPS responses.
type hstsRule struct{}
func (r *hstsRule) Name() string { return "http.hsts" }
func (r *hstsRule) Description() string {
return "Verifies the presence and quality of the Strict-Transport-Security header on HTTPS responses."
}
func (r *hstsRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState {
data, errSt := loadHTTPData(ctx, obs)
if errSt != nil {
return []sdk.CheckState{*errSt}
}
require := sdk.GetBoolOption(opts, OptionRequireHSTS, true)
minDays := sdk.GetIntOption(opts, OptionMinHSTSMaxAgeDays, DefaultMinHSTSMaxAge)
minSeconds := int64(minDays) * 86400
return EvalPerHTTPS(data, "http.hsts", func(p HTTPProbe) sdk.CheckState {
h := ParseHSTS(p.Headers["strict-transport-security"])
if h == nil {
status := sdk.StatusWarn
if !require {
status = sdk.StatusInfo
}
return sdk.CheckState{
Status: status,
Code: "http.hsts.missing",
Subject: p.Address,
Message: "Strict-Transport-Security header is missing.",
Meta: map[string]any{"fix": "Send `Strict-Transport-Security: max-age=15552000; includeSubDomains` from HTTPS responses."},
}
}
if h.MaxAge < minSeconds {
return sdk.CheckState{
Status: sdk.StatusWarn,
Code: "http.hsts.short_max_age",
Subject: p.Address,
Message: fmt.Sprintf("HSTS max-age=%d is below the recommended %d seconds (%d days).", h.MaxAge, minSeconds, minDays),
}
}
return sdk.CheckState{
Status: sdk.StatusOK,
Code: "http.hsts.ok",
Subject: p.Address,
Message: fmt.Sprintf("HSTS present (max-age=%d, includeSubDomains=%v, preload=%v).", h.MaxAge, h.IncludeSub, h.Preload),
}
})
}
// cspRule checks for the presence of a Content-Security-Policy header.
type cspRule struct{}
func (r *cspRule) Name() string { return "http.csp" }
func (r *cspRule) Description() string {
return "Verifies the presence of a Content-Security-Policy header on HTTPS responses."
}
func (r *cspRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState {
data, errSt := loadHTTPData(ctx, obs)
if errSt != nil {
return []sdk.CheckState{*errSt}
}
require := sdk.GetBoolOption(opts, OptionRequireCSP, false)
return EvalPerHTTPS(data, "http.csp", func(p HTTPProbe) sdk.CheckState {
csp := ParseCSP(p.Headers["content-security-policy"])
if csp == nil {
status := sdk.StatusInfo
if require {
status = sdk.StatusWarn
}
return sdk.CheckState{
Status: status,
Code: "http.csp.missing",
Subject: p.Address,
Message: "Content-Security-Policy header is missing.",
Meta: map[string]any{"fix": "Define a CSP appropriate for your application (e.g. default-src 'self')."},
}
}
if csp.HasUnsafe() {
return sdk.CheckState{
Status: sdk.StatusWarn,
Code: "http.csp.unsafe",
Subject: p.Address,
Message: "Content-Security-Policy uses 'unsafe-inline' or 'unsafe-eval'.",
}
}
return sdk.CheckState{
Status: sdk.StatusOK,
Code: "http.csp.ok",
Subject: p.Address,
Message: "Content-Security-Policy is set.",
}
})
}
// xFrameOptionsRule checks X-Frame-Options (or frame-ancestors in CSP as
// an acceptable substitute).
type xFrameOptionsRule struct{}
func (r *xFrameOptionsRule) Name() string { return "http.x_frame_options" }
func (r *xFrameOptionsRule) Description() string {
return "Verifies that responses set X-Frame-Options or a CSP frame-ancestors directive."
}
func (r *xFrameOptionsRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
data, errSt := loadHTTPData(ctx, obs)
if errSt != nil {
return []sdk.CheckState{*errSt}
}
return EvalPerHTTPS(data, "http.x_frame_options", func(p HTTPProbe) sdk.CheckState {
xfo := strings.ToUpper(strings.TrimSpace(p.Headers["x-frame-options"]))
hasFrameAncestors := ParseCSP(p.Headers["content-security-policy"]).HasDirective("frame-ancestors")
switch {
case xfo == "DENY" || xfo == "SAMEORIGIN" || hasFrameAncestors:
return sdk.CheckState{
Status: sdk.StatusOK,
Code: "http.x_frame_options.ok",
Subject: p.Address,
Message: "Clickjacking protection is in place.",
}
case xfo != "":
return sdk.CheckState{
Status: sdk.StatusWarn,
Code: "http.x_frame_options.invalid",
Subject: p.Address,
Message: "X-Frame-Options has an unrecognised value: " + xfo,
}
default:
return sdk.CheckState{
Status: sdk.StatusWarn,
Code: "http.x_frame_options.missing",
Subject: p.Address,
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."},
}
}
})
}
// 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() {
// Showcase: a rule expressed entirely as a HeaderRuleSpec. Compare
// with the hand-rolled rules above — the boilerplate vanishes once
// the only logic is "is this header present and well-formed?".
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.",
@ -178,54 +55,188 @@ func init() {
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.",
}}
},
}))
}
// xXSSProtectionRule checks the legacy X-XSS-Protection header. Modern
// browsers ignore it, but if present we want it to be sane.
type xXSSProtectionRule struct{}
// HSTS ----------------------------------------------------------------
func (r *xXSSProtectionRule) Name() string { return "http.x_xss_protection" }
func (r *xXSSProtectionRule) Description() string {
return "Reports the value of the legacy X-XSS-Protection header (disabled is preferred on modern browsers; CSP is the proper replacement)."
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 (r *xXSSProtectionRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
data, errSt := loadHTTPData(ctx, obs)
if errSt != nil {
return []sdk.CheckState{*errSt}
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})
}
return EvalPerHTTPS(data, "http.x_xss_protection", func(p HTTPProbe) sdk.CheckState {
v := strings.TrimSpace(p.Headers["x-xss-protection"])
switch {
case v == "":
return sdk.CheckState{
Status: sdk.StatusInfo,
Code: "http.x_xss_protection.absent",
Subject: p.Address,
Message: "X-XSS-Protection is not set; CSP is the recommended replacement.",
}
case strings.HasPrefix(v, "0"):
return sdk.CheckState{
Status: sdk.StatusOK,
Code: "http.x_xss_protection.disabled",
Subject: p.Address,
Message: "X-XSS-Protection is explicitly disabled (recommended).",
}
case strings.Contains(strings.ToLower(v), "mode=block"):
return sdk.CheckState{
Status: sdk.StatusInfo,
Code: "http.x_xss_protection.enabled",
Subject: p.Address,
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 sdk.CheckState{
Status: sdk.StatusInfo,
Code: "http.x_xss_protection.enabled",
Subject: p.Address,
Message: "X-XSS-Protection is enabled. Modern browsers ignore this header; CSP is the proper replacement.",
}
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.",
}}
}
}

View file

@ -17,16 +17,21 @@ func TestParseHSTS(t *testing.T) {
maxAge int64
includeSub bool
preload bool
wantErr bool
}{
{"empty", "", 0, false, false},
{"max-age only", "max-age=31536000", 31536000, false, false},
{"includeSubDomains", "max-age=15552000; includeSubDomains", 15552000, true, false},
{"all flags", "max-age=63072000; includeSubDomains; preload", 63072000, true, true},
{"quoted max-age", `max-age="3600"`, 3600, false, false},
{"case-insensitive directive", "MAX-AGE=42; INCLUDESUBDOMAINS; PRELOAD", 42, true, true},
{"messy spaces", " max-age=10 ; includeSubDomains ", 10, true, false},
{"unparseable max-age", "max-age=not-a-number", 0, false, false},
{"no max-age, only flags", "includeSubDomains; preload", 0, true, true},
{"empty", "", 0, false, false, false},
{"max-age only", "max-age=31536000", 31536000, false, false, false},
{"includeSubDomains", "max-age=15552000; includeSubDomains", 15552000, true, false, false},
{"all flags", "max-age=63072000; includeSubDomains; preload", 63072000, true, true, false},
{"quoted max-age", `max-age="3600"`, 3600, false, false, false},
{"case-insensitive directive", "MAX-AGE=42; INCLUDESUBDOMAINS; PRELOAD", 42, true, true, false},
{"messy spaces", " max-age=10 ; includeSubDomains ", 10, true, false, false},
{"unparseable max-age", "max-age=not-a-number", 0, false, false, true},
{"no max-age, only flags", "includeSubDomains; preload", 0, true, true, true},
{"negative max-age", "max-age=-1", 0, false, false, true},
{"empty quoted max-age", `max-age=""`, 0, false, false, true},
{"max-age without value", "max-age; includeSubDomains", 0, true, false, true},
{"duplicate max-age", "max-age=10; max-age=20", 10, false, false, true},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
@ -44,13 +49,17 @@ func TestParseHSTS(t *testing.T) {
t.Errorf("ParseHSTS(%q) = (%d, %v, %v), want (%d, %v, %v)",
c.in, h.MaxAge, h.IncludeSub, h.Preload, c.maxAge, c.includeSub, c.preload)
}
if got := len(h.Errors) > 0; got != c.wantErr {
t.Errorf("ParseHSTS(%q) errors = %v (%v), want wantErr=%v",
c.in, h.Errors, got, c.wantErr)
}
})
}
}
func TestHSTSRule_NoHTTPSProbes(t *testing.T) {
data := &HTTPData{Probes: []HTTPProbe{httpProbe("a:80")}}
states := runRule(t, &hstsRule{}, data, nil)
states := runRule(t, ruleByName(t, "http.hsts"), data, nil)
mustStatus(t, states, sdk.StatusUnknown)
if !hasCode(states, "http.hsts.no_https") {
t.Errorf("missing no_https code: %+v", states)
@ -59,7 +68,7 @@ func TestHSTSRule_NoHTTPSProbes(t *testing.T) {
func TestHSTSRule_MissingRequired(t *testing.T) {
data := &HTTPData{Probes: []HTTPProbe{httpsProbe("a:443")}}
states := runRule(t, &hstsRule{}, data, sdk.CheckerOptions{OptionRequireHSTS: true})
states := runRule(t, ruleByName(t, "http.hsts"), data, sdk.CheckerOptions{OptionRequireHSTS: true})
mustStatus(t, states, sdk.StatusWarn)
if !hasCode(states, "http.hsts.missing") {
t.Errorf("missing 'http.hsts.missing': %+v", states)
@ -68,7 +77,7 @@ func TestHSTSRule_MissingRequired(t *testing.T) {
func TestHSTSRule_MissingNotRequired(t *testing.T) {
data := &HTTPData{Probes: []HTTPProbe{httpsProbe("a:443")}}
states := runRule(t, &hstsRule{}, data, sdk.CheckerOptions{OptionRequireHSTS: false})
states := runRule(t, ruleByName(t, "http.hsts"), data, sdk.CheckerOptions{OptionRequireHSTS: false})
mustStatus(t, states, sdk.StatusInfo)
}
@ -76,7 +85,7 @@ func TestHSTSRule_ShortMaxAge(t *testing.T) {
p := httpsProbe("a:443")
p.Headers["strict-transport-security"] = "max-age=60"
data := &HTTPData{Probes: []HTTPProbe{p}}
states := runRule(t, &hstsRule{}, data, nil)
states := runRule(t, ruleByName(t, "http.hsts"), data, nil)
mustStatus(t, states, sdk.StatusWarn)
if !hasCode(states, "http.hsts.short_max_age") {
t.Errorf("missing short_max_age code: %+v", states)
@ -87,7 +96,7 @@ func TestHSTSRule_OK(t *testing.T) {
p := httpsProbe("a:443")
p.Headers["strict-transport-security"] = "max-age=63072000; includeSubDomains; preload"
data := &HTTPData{Probes: []HTTPProbe{p}}
states := runRule(t, &hstsRule{}, data, nil)
states := runRule(t, ruleByName(t, "http.hsts"), data, nil)
mustStatus(t, states, sdk.StatusOK)
if !hasCode(states, "http.hsts.ok") {
t.Errorf("missing ok code: %+v", states)
@ -95,7 +104,7 @@ func TestHSTSRule_OK(t *testing.T) {
}
func TestHSTSRule_LoadFailure(t *testing.T) {
states := (&hstsRule{}).Evaluate(t.Context(), &fakeObs{failGet: true}, nil)
states := ruleByName(t, "http.hsts").Evaluate(t.Context(), &fakeObs{failGet: true}, nil)
if len(states) != 1 || states[0].Status != sdk.StatusError {
t.Fatalf("expected single error state, got %+v", states)
}
@ -104,22 +113,78 @@ func TestHSTSRule_LoadFailure(t *testing.T) {
func TestCSPRule_Missing(t *testing.T) {
data := &HTTPData{Probes: []HTTPProbe{httpsProbe("a:443")}}
// Default: not required → Info.
states := runRule(t, &cspRule{}, data, nil)
states := runRule(t, ruleByName(t, "http.csp"), data, nil)
mustStatus(t, states, sdk.StatusInfo)
// Required → Warn.
states = runRule(t, &cspRule{}, data, sdk.CheckerOptions{OptionRequireCSP: true})
states = runRule(t, ruleByName(t, "http.csp"), data, sdk.CheckerOptions{OptionRequireCSP: true})
mustStatus(t, states, sdk.StatusWarn)
}
func TestCSPRule_Unsafe(t *testing.T) {
for _, csp := range []string{"default-src 'self'; script-src 'unsafe-inline'", "default-src 'unsafe-eval'"} {
cases := []struct {
csp string
code string
}{
{"default-src 'self'; script-src 'self' 'unsafe-inline'", "http.csp.unsafe_inline"},
{"default-src 'self'; script-src 'self' 'unsafe-eval'", "http.csp.unsafe_eval"},
// unsafe-eval on default-src falls back to script-src.
{"default-src 'self' 'unsafe-eval'", "http.csp.unsafe_eval"},
}
for _, c := range cases {
p := httpsProbe("a:443")
p.Headers["content-security-policy"] = csp
p.Headers["content-security-policy"] = c.csp
data := &HTTPData{Probes: []HTTPProbe{p}}
states := runRule(t, &cspRule{}, data, nil)
states := runRule(t, ruleByName(t, "http.csp"), data, nil)
mustStatus(t, states, sdk.StatusWarn)
if !hasCode(states, "http.csp.unsafe") {
t.Errorf("csp=%q: missing unsafe code: %+v", csp, states)
if !hasCode(states, c.code) {
t.Errorf("csp=%q: missing code %q in %+v", c.csp, c.code, states)
}
}
}
func TestCSPRule_MissingDefault(t *testing.T) {
p := httpsProbe("a:443")
p.Headers["content-security-policy"] = "frame-ancestors 'none'"
data := &HTTPData{Probes: []HTTPProbe{p}}
states := runRule(t, ruleByName(t, "http.csp"), data, nil)
mustStatus(t, states, sdk.StatusWarn)
if !hasCode(states, "http.csp.missing_default") {
t.Errorf("missing_default not emitted: %+v", states)
}
}
func TestCSPRule_WildcardScriptSrc(t *testing.T) {
cases := []struct {
csp string
code string
}{
{"default-src 'self'; script-src *", "http.csp.wildcard_script_src"},
{"default-src 'self'; script-src https:", "http.csp.wildcard_script_src"},
// No script-src declared → wildcard on default-src is reported.
{"default-src *", "http.csp.wildcard_default_src"},
}
for _, c := range cases {
p := httpsProbe("a:443")
p.Headers["content-security-policy"] = c.csp
data := &HTTPData{Probes: []HTTPProbe{p}}
states := runRule(t, ruleByName(t, "http.csp"), data, nil)
mustStatus(t, states, sdk.StatusWarn)
if !hasCode(states, c.code) {
t.Errorf("csp=%q: missing code %q in %+v", c.csp, c.code, states)
}
}
}
func TestCSPRule_TightScriptSrcMasksDefaultWildcard(t *testing.T) {
// default-src is permissive but script-src locks scripts down — we
// should not emit the default-src wildcard warning.
p := httpsProbe("a:443")
p.Headers["content-security-policy"] = "default-src *; script-src 'self'"
data := &HTTPData{Probes: []HTTPProbe{p}}
states := runRule(t, ruleByName(t, "http.csp"), data, nil)
for _, s := range states {
if s.Code == "http.csp.wildcard_default_src" {
t.Errorf("unexpected wildcard_default_src when script-src tightens scripts: %+v", states)
}
}
}
@ -128,7 +193,7 @@ func TestCSPRule_OK(t *testing.T) {
p := httpsProbe("a:443")
p.Headers["content-security-policy"] = "default-src 'self'"
data := &HTTPData{Probes: []HTTPProbe{p}}
states := runRule(t, &cspRule{}, data, nil)
states := runRule(t, ruleByName(t, "http.csp"), data, nil)
mustStatus(t, states, sdk.StatusOK)
}
@ -156,7 +221,7 @@ func TestXFrameOptionsRule(t *testing.T) {
p.Headers["content-security-policy"] = c.csp
}
data := &HTTPData{Probes: []HTTPProbe{p}}
states := runRule(t, &xFrameOptionsRule{}, data, nil)
states := runRule(t, ruleByName(t, "http.x_frame_options"), data, nil)
mustStatus(t, states, c.want)
if !hasCode(states, c.wantSub) {
t.Errorf("missing code %q in %+v", c.wantSub, states)
@ -204,7 +269,7 @@ func TestXXSSProtectionRule(t *testing.T) {
if c.val != "" {
p.Headers["x-xss-protection"] = c.val
}
states := runRule(t, &xXSSProtectionRule{}, &HTTPData{Probes: []HTTPProbe{p}}, nil)
states := runRule(t, ruleByName(t, "http.x_xss_protection"), &HTTPData{Probes: []HTTPProbe{p}}, nil)
mustStatus(t, states, c.want)
if !hasCode(states, c.code) {
t.Errorf("val=%q: want code %q, got %+v", c.val, c.code, states)
@ -214,7 +279,13 @@ func TestXXSSProtectionRule(t *testing.T) {
func TestSecurityHeaders_NoHTTPS(t *testing.T) {
// Each header rule must emit Unknown when there are no successful HTTPS probes.
rules := []sdk.CheckRule{&hstsRule{}, &cspRule{}, &xFrameOptionsRule{}, ruleByName(t, "http.x_content_type_options"), &xXSSProtectionRule{}}
rules := []sdk.CheckRule{
ruleByName(t, "http.hsts"),
ruleByName(t, "http.csp"),
ruleByName(t, "http.x_frame_options"),
ruleByName(t, "http.x_content_type_options"),
ruleByName(t, "http.x_xss_protection"),
}
data := &HTTPData{Probes: []HTTPProbe{httpProbe("a:80")}}
for _, r := range rules {
states := runRule(t, r, data, nil)