diff --git a/README.md b/README.md index ca92a97..aea45f8 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,14 @@ relies on TLS for transport. | `http.csp` | Verifies the presence and quality of the Content-Security-Policy header on HTTPS responses. | WARNING | | `http.x_frame_options` | Verifies that responses set X-Frame-Options or a CSP frame-ancestors directive. | WARNING | | `http.x_content_type_options` | Verifies that responses set X-Content-Type-Options: nosniff. | WARNING | -| `http.x_xss_protection` | Reports the value of the legacy X-XSS-Protection header (disabled is preferred; CSP is the proper replacement). | INFO | +| `http.x_xss_protection` | Reports the legacy X-XSS-Protection header; warns on filtering mode (can introduce XSS), absent/`0` are fine, CSP is the real defense. | WARNING | +| `http.expect_ct` | Flags the deprecated Expect-CT header (Certificate Transparency is now enforced by mainstream clients; Mozilla recommends removing it). | WARNING | +| `http.hpkp` | Flags the deprecated Public-Key-Pins (HPKP) header, which is unsupported by modern browsers; rely on Certificate Transparency and CAA records instead. | WARNING | +| `http.hpkp_report_only` | Flags the deprecated Public-Key-Pins-Report-Only (HPKP) header, which is unsupported by modern browsers; rely on Certificate Transparency and CAA records instead. | WARNING | +| `http.server_header` | Reports whether the Server header discloses the origin server software/version; a non-informative value is accepted. | INFO | +| `http.x_powered_by` | Reports the X-Powered-By header, which discloses the web server technology stack and should be removed. | INFO | +| `http.x_aspnet_version` | Reports the X-AspNet-Version header, which discloses the ASP.NET framework version and should be removed. | INFO | +| `http.x_aspnetmvc_version` | Reports the X-AspNetMvc-Version header, which discloses the ASP.NET MVC version and should be removed. | INFO | | `http.referrer_policy` | Verifies that responses set a Referrer-Policy header with a privacy-preserving value. | WARNING | | `http.permissions_policy` | Verifies that the Permissions-Policy header restricts powerful APIs (camera, microphone, geolocation, …). | WARNING | | `http.coop` | Verifies the Cross-Origin-Opener-Policy (COOP) header for cross-origin process isolation. | WARNING | diff --git a/checker/rules_security_headers.go b/checker/rules_security_headers.go index 0d1a877..1952654 100644 --- a/checker/rules_security_headers.go +++ b/checker/rules_security_headers.go @@ -63,12 +63,145 @@ func init() { Inspect: inspectXXSSProtection, OnMissing: func(_ HTTPProbe, _ sdk.CheckerOptions) []HeaderResult { return []HeaderResult{{ - Status: sdk.StatusInfo, + Status: sdk.StatusOK, Suffix: "absent", - Message: "X-XSS-Protection is not set; CSP is the recommended replacement.", + 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 ---------------------------------------------------------------- @@ -235,8 +368,8 @@ func inspectXXSSProtection(value string, _ HTTPProbe, _ sdk.CheckerOptions) []He }} default: return []HeaderResult{{ - Status: sdk.StatusInfo, Suffix: "enabled", - Message: "X-XSS-Protection is enabled. Modern browsers ignore this header; CSP is the proper replacement.", + 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.", }} } } diff --git a/checker/rules_security_headers_test.go b/checker/rules_security_headers_test.go index 00c6c27..9875dca 100644 --- a/checker/rules_security_headers_test.go +++ b/checker/rules_security_headers_test.go @@ -260,9 +260,11 @@ func TestXXSSProtectionRule(t *testing.T) { want sdk.Status code string }{ - {"", sdk.StatusInfo, "http.x_xss_protection.absent"}, + {"", sdk.StatusOK, "http.x_xss_protection.absent"}, {"0", sdk.StatusOK, "http.x_xss_protection.disabled"}, {"1; mode=block", sdk.StatusInfo, "http.x_xss_protection.enabled"}, + {"1", sdk.StatusWarn, "http.x_xss_protection.filtering"}, + {"1; report=https://example.com/r", sdk.StatusWarn, "http.x_xss_protection.filtering"}, } for _, c := range cases { p := httpsProbe("a:443") @@ -277,6 +279,108 @@ func TestXXSSProtectionRule(t *testing.T) { } } +func TestExpectCTRule(t *testing.T) { + // Absent → OK, since Expect-CT is deprecated. + states := runRule(t, ruleByName(t, "http.expect_ct"), &HTTPData{Probes: []HTTPProbe{httpsProbe("a:443")}}, nil) + mustStatus(t, states, sdk.StatusOK) + if !hasCode(states, "http.expect_ct.absent") { + t.Errorf("missing absent code: %+v", states) + } + // Present → Warn deprecated. + p := httpsProbe("a:443") + p.Headers["expect-ct"] = "max-age=86400, enforce" + states = runRule(t, ruleByName(t, "http.expect_ct"), &HTTPData{Probes: []HTTPProbe{p}}, nil) + mustStatus(t, states, sdk.StatusWarn) + if !hasCode(states, "http.expect_ct.deprecated") { + t.Errorf("missing deprecated code: %+v", states) + } +} + +func TestServerHeaderRule(t *testing.T) { + cases := []struct { + val string + want sdk.Status + code string + }{ + {"", sdk.StatusOK, "http.server_header.absent"}, + {"nginx/1.18.0", sdk.StatusInfo, "http.server_header.disclosed"}, + {"Apache/2.4.41 (Ubuntu)", sdk.StatusInfo, "http.server_header.disclosed"}, + // Bare product token with a separator is still informative. + {"nginx/", sdk.StatusInfo, "http.server_header.disclosed"}, + // Non-informative value is acceptable per OWASP. + {"webserver", sdk.StatusOK, "http.server_header.ok"}, + {"nginx", sdk.StatusOK, "http.server_header.ok"}, + } + for _, c := range cases { + p := httpsProbe("a:443") + if c.val != "" { + p.Headers["server"] = c.val + } + states := runRule(t, ruleByName(t, "http.server_header"), &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) + } + } +} + +func TestDisclosureHeaderRules(t *testing.T) { + cases := []struct { + rule string + header string + }{ + {"http.x_powered_by", "x-powered-by"}, + {"http.x_aspnet_version", "x-aspnet-version"}, + {"http.x_aspnetmvc_version", "x-aspnetmvc-version"}, + } + for _, c := range cases { + t.Run(c.rule, func(t *testing.T) { + // Absent → OK. + states := runRule(t, ruleByName(t, c.rule), &HTTPData{Probes: []HTTPProbe{httpsProbe("a:443")}}, nil) + mustStatus(t, states, sdk.StatusOK) + if !hasCode(states, c.rule+".absent") { + t.Errorf("%s: missing absent code: %+v", c.rule, states) + } + // Present → Info disclosure. + p := httpsProbe("a:443") + p.Headers[c.header] = "4.0.30319" + states = runRule(t, ruleByName(t, c.rule), &HTTPData{Probes: []HTTPProbe{p}}, nil) + mustStatus(t, states, sdk.StatusInfo) + if !hasCode(states, c.rule+".disclosed") { + t.Errorf("%s: missing disclosed code: %+v", c.rule, states) + } + }) + } +} + +func TestHPKPRules(t *testing.T) { + cases := []struct { + rule string + header string + }{ + {"http.hpkp", "public-key-pins"}, + {"http.hpkp_report_only", "public-key-pins-report-only"}, + } + for _, c := range cases { + t.Run(c.rule, func(t *testing.T) { + // Absent → OK, since HPKP is deprecated. + states := runRule(t, ruleByName(t, c.rule), &HTTPData{Probes: []HTTPProbe{httpsProbe("a:443")}}, nil) + mustStatus(t, states, sdk.StatusOK) + if !hasCode(states, c.rule+".absent") { + t.Errorf("%s: missing absent code: %+v", c.rule, states) + } + // Present → Warn deprecated. + p := httpsProbe("a:443") + p.Headers[c.header] = `pin-sha256="d6qzRu9zOECb90Uez27xWltNsj0e1Md7GkYYkVoZWmM="; max-age=5184000` + states = runRule(t, ruleByName(t, c.rule), &HTTPData{Probes: []HTTPProbe{p}}, nil) + mustStatus(t, states, sdk.StatusWarn) + if !hasCode(states, c.rule+".deprecated") { + t.Errorf("%s: missing deprecated code: %+v", c.rule, states) + } + }) + } +} + func TestSecurityHeaders_NoHTTPS(t *testing.T) { // Each header rule must emit Unknown when there are no successful HTTPS probes. rules := []sdk.CheckRule{ @@ -285,6 +389,13 @@ func TestSecurityHeaders_NoHTTPS(t *testing.T) { ruleByName(t, "http.x_frame_options"), ruleByName(t, "http.x_content_type_options"), ruleByName(t, "http.x_xss_protection"), + ruleByName(t, "http.expect_ct"), + ruleByName(t, "http.server_header"), + ruleByName(t, "http.x_powered_by"), + ruleByName(t, "http.x_aspnet_version"), + ruleByName(t, "http.x_aspnetmvc_version"), + ruleByName(t, "http.hpkp"), + ruleByName(t, "http.hpkp_report_only"), } data := &HTTPData{Probes: []HTTPProbe{httpProbe("a:80")}} for _, r := range rules {