diff --git a/README.md b/README.md index aea45f8..ca92a97 100644 --- a/README.md +++ b/README.md @@ -22,14 +22,7 @@ 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 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.x_xss_protection` | Reports the value of the legacy X-XSS-Protection header (disabled is preferred; CSP is the proper replacement). | 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 1952654..0d1a877 100644 --- a/checker/rules_security_headers.go +++ b/checker/rules_security_headers.go @@ -62,146 +62,13 @@ func init() { 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 + ").", + Message: "X-XSS-Protection is not set; CSP is the recommended replacement.", }} }, - }) -} - -// 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 ---------------------------------------------------------------- @@ -368,8 +235,8 @@ func inspectXXSSProtection(value string, _ HTTPProbe, _ sdk.CheckerOptions) []He }} 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.", + Status: sdk.StatusInfo, Suffix: "enabled", + Message: "X-XSS-Protection is enabled. Modern browsers ignore this header; CSP is the proper replacement.", }} } } diff --git a/checker/rules_security_headers_test.go b/checker/rules_security_headers_test.go index 9875dca..00c6c27 100644 --- a/checker/rules_security_headers_test.go +++ b/checker/rules_security_headers_test.go @@ -260,11 +260,9 @@ func TestXXSSProtectionRule(t *testing.T) { want sdk.Status code string }{ - {"", sdk.StatusOK, "http.x_xss_protection.absent"}, + {"", sdk.StatusInfo, "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") @@ -279,108 +277,6 @@ 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{ @@ -389,13 +285,6 @@ 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 {