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.
This commit is contained in:
parent
329df14ec6
commit
a0fb42223b
3 changed files with 145 additions and 0 deletions
|
|
@ -24,6 +24,10 @@ relies on TLS for transport.
|
|||
| `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.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 |
|
||||
|
|
|
|||
|
|
@ -90,6 +90,86 @@ func init() {
|
|||
}}
|
||||
},
|
||||
}))
|
||||
|
||||
// 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 ----------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -296,6 +296,63 @@ func TestExpectCTRule(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
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 TestSecurityHeaders_NoHTTPS(t *testing.T) {
|
||||
// Each header rule must emit Unknown when there are no successful HTTPS probes.
|
||||
rules := []sdk.CheckRule{
|
||||
|
|
@ -305,6 +362,10 @@ func TestSecurityHeaders_NoHTTPS(t *testing.T) {
|
|||
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"),
|
||||
}
|
||||
data := &HTTPData{Probes: []HTTPProbe{httpProbe("a:80")}}
|
||||
for _, r := range rules {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue