Compare commits

...

4 commits

Author SHA1 Message Date
513a73f17f checker: flag the deprecated Public-Key-Pins (HPKP) header
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2026-06-18 11:06:13 +09:00
a0fb42223b checker: flag information-disclosure response headers
Report Server, X-Powered-By, X-AspNet-Version and X-AspNetMvc-Version
headers, whose presence leaks the server/framework stack. Server only
warns on informative values (version/product detail), accepting a
non-informative value per OWASP.
2026-06-18 11:06:13 +09:00
329df14ec6 checker: flag the deprecated Expect-CT header 2026-06-18 11:05:07 +09:00
a652692ba4 checker: align X-XSS-Protection severities with OWASP
Absent is now OK (OWASP recommends leaving it unset or set to 0),
and filtering mode (bare 1 or 1; report=...) is Warn since selective
script rewriting can itself introduce XSS. 1; mode=block stays Info.
2026-06-18 10:52:31 +09:00
3 changed files with 257 additions and 6 deletions

View file

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

View file

@ -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.",
}}
}
}

View file

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