// 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.StatusOK, Suffix: "absent", Message: "X-XSS-Protection is not set, which is acceptable (OWASP recommends leaving it unset or setting `0`); CSP is the proper protection.", }} }, })) RegisterRule(HeaderRule(HeaderRuleSpec{ Code: "http.expect_ct", Description: "Reports the presence of the deprecated Expect-CT header (Certificate Transparency enforcement is now mandatory in mainstream clients; Mozilla recommends removing it).", Header: "Expect-CT", Inspect: func(_ string, _ HTTPProbe, _ sdk.CheckerOptions) []HeaderResult { return []HeaderResult{{ Status: sdk.StatusWarn, Suffix: "deprecated", Message: "Expect-CT is deprecated. Certificate Transparency is now enforced by mainstream clients, so the header serves no purpose; Mozilla recommends removing it.", Meta: map[string]any{"fix": "Remove the `Expect-CT` header from your responses."}, }} }, OnMissing: func(_ HTTPProbe, _ sdk.CheckerOptions) []HeaderResult { return []HeaderResult{{ Status: sdk.StatusOK, Suffix: "absent", Message: "Expect-CT is not set, which is correct (the header is deprecated).", }} }, })) // Information-disclosure headers. Their mere presence leaks the // server/framework stack, helping an attacker map known vulnerabilities. // Absence is the desired state, so these invert the usual semantics: // present → Info ".disclosed", absent → OK ".absent". RegisterRule(HeaderRule(HeaderRuleSpec{ Code: "http.server_header", Description: "Reports whether the Server header discloses the origin server software and version.", Header: "Server", Inspect: inspectServerHeader, OnMissing: func(_ HTTPProbe, _ sdk.CheckerOptions) []HeaderResult { return []HeaderResult{{ Status: sdk.StatusOK, Suffix: "absent", Message: "Server header is not set, so no origin server software is disclosed.", }} }, })) RegisterRule(disclosureHeaderRule("http.x_powered_by", "X-Powered-By", "the technologies used by the web server")) RegisterRule(disclosureHeaderRule("http.x_aspnet_version", "X-AspNet-Version", "the ASP.NET framework version")) RegisterRule(disclosureHeaderRule("http.x_aspnetmvc_version", "X-AspNetMvc-Version", "the ASP.NET MVC version")) } // Information-disclosure headers -------------------------------------- // disclosureHeaderRule builds a rule for a header whose mere presence // leaks server/framework details (X-Powered-By, X-AspNet-Version, // X-AspNetMvc-Version). Any value is reported as Info ".disclosed"; // absence is OK ".absent". `what` names the leaked information for the // finding message. func disclosureHeaderRule(code, header, what string) sdk.CheckRule { return HeaderRule(HeaderRuleSpec{ Code: code, Description: "Reports the presence of the " + header + " header, which discloses " + what + " and should be removed.", Header: header, Inspect: func(value string, _ HTTPProbe, _ sdk.CheckerOptions) []HeaderResult { return []HeaderResult{{ Status: sdk.StatusInfo, Suffix: "disclosed", Message: fmt.Sprintf("%s exposes %s (%s); attackers can use it to target known vulnerabilities. Remove the header.", header, what, value), Meta: map[string]any{"fix": "Remove the `" + header + "` header from your responses."}, }} }, OnMissing: func(_ HTTPProbe, _ sdk.CheckerOptions) []HeaderResult { return []HeaderResult{{ Status: sdk.StatusOK, Suffix: "absent", Message: header + " is not set, which is correct (it would disclose " + what + ").", }} }, }) } // inspectServerHeader flags Server values that disclose product/version // detail (e.g. "nginx/1.18.0"). A bare, non-informative token // ("Server: webserver") is accepted: OWASP recommends either removing the // header or setting a non-informative value, so the latter is not a // finding. func inspectServerHeader(value string, _ HTTPProbe, _ sdk.CheckerOptions) []HeaderResult { if serverHeaderIsInformative(value) { return []HeaderResult{{ Status: sdk.StatusInfo, Suffix: "disclosed", Message: "Server header discloses the origin server software (" + value + "). Remove it or set a non-informative value such as `Server: webserver`.", Meta: map[string]any{"fix": "Remove the `Server` header or replace its value with a non-informative one (e.g. `Server: webserver`)."}, }} } return []HeaderResult{{ Status: sdk.StatusOK, Suffix: "ok", Message: "Server header is set to a non-informative value (" + value + ").", }} } // serverHeaderIsInformative reports whether a Server value reveals more // than a generic product name. A version number (digits) or a // product/version separator ("/") is the usual giveaway. func serverHeaderIsInformative(value string) bool { return strings.ContainsAny(value, "0123456789") || strings.Contains(value, "/") } // 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.StatusWarn, Suffix: "filtering", Message: "X-XSS-Protection is in filtering mode (e.g. `1` or `1; report=...`). Selective script rewriting can itself introduce XSS in otherwise-safe pages. Set `0` or use `1; mode=block`, and rely on CSP instead.", }} } }