375 lines
15 KiB
Go
375 lines
15 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.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"))
|
|
|
|
RegisterRule(deprecatedHPKPRule("http.hpkp", "Public-Key-Pins"))
|
|
RegisterRule(deprecatedHPKPRule("http.hpkp_report_only", "Public-Key-Pins-Report-Only"))
|
|
}
|
|
|
|
// deprecatedHPKPRule builds a rule for the HTTP Public-Key-Pins (HPKP)
|
|
// headers. Key pinning was removed from Chromium in 2018 and is
|
|
// unsupported by all modern browsers; its operational brittleness made it
|
|
// a frequent cause of self-inflicted outages. Certificate Transparency
|
|
// and CAA records provide superior compromise detection, so any presence
|
|
// is reported as Warn ".deprecated" and absence is OK ".absent".
|
|
func deprecatedHPKPRule(code, header string) sdk.CheckRule {
|
|
return HeaderRule(HeaderRuleSpec{
|
|
Code: code,
|
|
Description: "Reports the presence of the deprecated " + header + " (HPKP) header, which is unsupported by modern browsers and should be removed.",
|
|
Header: header,
|
|
Inspect: func(_ string, _ HTTPProbe, _ sdk.CheckerOptions) []HeaderResult {
|
|
return []HeaderResult{{
|
|
Status: sdk.StatusWarn,
|
|
Suffix: "deprecated",
|
|
Message: header + " (HPKP) is deprecated. Key pinning was removed from Chromium in 2018 and is unsupported by modern browsers; rely on Certificate Transparency and CAA DNS records instead.",
|
|
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 (HPKP is deprecated).",
|
|
}}
|
|
},
|
|
})
|
|
}
|
|
|
|
// 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.",
|
|
}}
|
|
}
|
|
}
|