checker-http/checker/rules_security_headers_test.go
Pierre-Olivier Mercier 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

375 lines
13 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 (
"testing"
sdk "git.happydns.org/checker-sdk-go/checker"
)
func TestParseHSTS(t *testing.T) {
cases := []struct {
name string
in string
maxAge int64
includeSub bool
preload bool
wantErr bool
}{
{"empty", "", 0, false, false, false},
{"max-age only", "max-age=31536000", 31536000, false, false, false},
{"includeSubDomains", "max-age=15552000; includeSubDomains", 15552000, true, false, false},
{"all flags", "max-age=63072000; includeSubDomains; preload", 63072000, true, true, false},
{"quoted max-age", `max-age="3600"`, 3600, false, false, false},
{"case-insensitive directive", "MAX-AGE=42; INCLUDESUBDOMAINS; PRELOAD", 42, true, true, false},
{"messy spaces", " max-age=10 ; includeSubDomains ", 10, true, false, false},
{"unparseable max-age", "max-age=not-a-number", 0, false, false, true},
{"no max-age, only flags", "includeSubDomains; preload", 0, true, true, true},
{"negative max-age", "max-age=-1", 0, false, false, true},
{"empty quoted max-age", `max-age=""`, 0, false, false, true},
{"max-age without value", "max-age; includeSubDomains", 0, true, false, true},
{"duplicate max-age", "max-age=10; max-age=20", 10, false, false, true},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
h := ParseHSTS(c.in)
if c.in == "" {
if h != nil {
t.Errorf("ParseHSTS(%q) = %+v, want nil", c.in, h)
}
return
}
if h == nil {
t.Fatalf("ParseHSTS(%q) returned nil", c.in)
}
if h.MaxAge != c.maxAge || h.IncludeSub != c.includeSub || h.Preload != c.preload {
t.Errorf("ParseHSTS(%q) = (%d, %v, %v), want (%d, %v, %v)",
c.in, h.MaxAge, h.IncludeSub, h.Preload, c.maxAge, c.includeSub, c.preload)
}
if got := len(h.Errors) > 0; got != c.wantErr {
t.Errorf("ParseHSTS(%q) errors = %v (%v), want wantErr=%v",
c.in, h.Errors, got, c.wantErr)
}
})
}
}
func TestHSTSRule_NoHTTPSProbes(t *testing.T) {
data := &HTTPData{Probes: []HTTPProbe{httpProbe("a:80")}}
states := runRule(t, ruleByName(t, "http.hsts"), data, nil)
mustStatus(t, states, sdk.StatusUnknown)
if !hasCode(states, "http.hsts.no_https") {
t.Errorf("missing no_https code: %+v", states)
}
}
func TestHSTSRule_MissingRequired(t *testing.T) {
data := &HTTPData{Probes: []HTTPProbe{httpsProbe("a:443")}}
states := runRule(t, ruleByName(t, "http.hsts"), data, sdk.CheckerOptions{OptionRequireHSTS: true})
mustStatus(t, states, sdk.StatusWarn)
if !hasCode(states, "http.hsts.missing") {
t.Errorf("missing 'http.hsts.missing': %+v", states)
}
}
func TestHSTSRule_MissingNotRequired(t *testing.T) {
data := &HTTPData{Probes: []HTTPProbe{httpsProbe("a:443")}}
states := runRule(t, ruleByName(t, "http.hsts"), data, sdk.CheckerOptions{OptionRequireHSTS: false})
mustStatus(t, states, sdk.StatusInfo)
}
func TestHSTSRule_ShortMaxAge(t *testing.T) {
p := httpsProbe("a:443")
p.Headers["strict-transport-security"] = "max-age=60"
data := &HTTPData{Probes: []HTTPProbe{p}}
states := runRule(t, ruleByName(t, "http.hsts"), data, nil)
mustStatus(t, states, sdk.StatusWarn)
if !hasCode(states, "http.hsts.short_max_age") {
t.Errorf("missing short_max_age code: %+v", states)
}
}
func TestHSTSRule_OK(t *testing.T) {
p := httpsProbe("a:443")
p.Headers["strict-transport-security"] = "max-age=63072000; includeSubDomains; preload"
data := &HTTPData{Probes: []HTTPProbe{p}}
states := runRule(t, ruleByName(t, "http.hsts"), data, nil)
mustStatus(t, states, sdk.StatusOK)
if !hasCode(states, "http.hsts.ok") {
t.Errorf("missing ok code: %+v", states)
}
}
func TestHSTSRule_LoadFailure(t *testing.T) {
states := ruleByName(t, "http.hsts").Evaluate(t.Context(), &fakeObs{failGet: true}, nil)
if len(states) != 1 || states[0].Status != sdk.StatusError {
t.Fatalf("expected single error state, got %+v", states)
}
}
func TestCSPRule_Missing(t *testing.T) {
data := &HTTPData{Probes: []HTTPProbe{httpsProbe("a:443")}}
// Default: not required → Info.
states := runRule(t, ruleByName(t, "http.csp"), data, nil)
mustStatus(t, states, sdk.StatusInfo)
// Required → Warn.
states = runRule(t, ruleByName(t, "http.csp"), data, sdk.CheckerOptions{OptionRequireCSP: true})
mustStatus(t, states, sdk.StatusWarn)
}
func TestCSPRule_Unsafe(t *testing.T) {
cases := []struct {
csp string
code string
}{
{"default-src 'self'; script-src 'self' 'unsafe-inline'", "http.csp.unsafe_inline"},
{"default-src 'self'; script-src 'self' 'unsafe-eval'", "http.csp.unsafe_eval"},
// unsafe-eval on default-src falls back to script-src.
{"default-src 'self' 'unsafe-eval'", "http.csp.unsafe_eval"},
}
for _, c := range cases {
p := httpsProbe("a:443")
p.Headers["content-security-policy"] = c.csp
data := &HTTPData{Probes: []HTTPProbe{p}}
states := runRule(t, ruleByName(t, "http.csp"), data, nil)
mustStatus(t, states, sdk.StatusWarn)
if !hasCode(states, c.code) {
t.Errorf("csp=%q: missing code %q in %+v", c.csp, c.code, states)
}
}
}
func TestCSPRule_MissingDefault(t *testing.T) {
p := httpsProbe("a:443")
p.Headers["content-security-policy"] = "frame-ancestors 'none'"
data := &HTTPData{Probes: []HTTPProbe{p}}
states := runRule(t, ruleByName(t, "http.csp"), data, nil)
mustStatus(t, states, sdk.StatusWarn)
if !hasCode(states, "http.csp.missing_default") {
t.Errorf("missing_default not emitted: %+v", states)
}
}
func TestCSPRule_WildcardScriptSrc(t *testing.T) {
cases := []struct {
csp string
code string
}{
{"default-src 'self'; script-src *", "http.csp.wildcard_script_src"},
{"default-src 'self'; script-src https:", "http.csp.wildcard_script_src"},
// No script-src declared → wildcard on default-src is reported.
{"default-src *", "http.csp.wildcard_default_src"},
}
for _, c := range cases {
p := httpsProbe("a:443")
p.Headers["content-security-policy"] = c.csp
data := &HTTPData{Probes: []HTTPProbe{p}}
states := runRule(t, ruleByName(t, "http.csp"), data, nil)
mustStatus(t, states, sdk.StatusWarn)
if !hasCode(states, c.code) {
t.Errorf("csp=%q: missing code %q in %+v", c.csp, c.code, states)
}
}
}
func TestCSPRule_TightScriptSrcMasksDefaultWildcard(t *testing.T) {
// default-src is permissive but script-src locks scripts down — we
// should not emit the default-src wildcard warning.
p := httpsProbe("a:443")
p.Headers["content-security-policy"] = "default-src *; script-src 'self'"
data := &HTTPData{Probes: []HTTPProbe{p}}
states := runRule(t, ruleByName(t, "http.csp"), data, nil)
for _, s := range states {
if s.Code == "http.csp.wildcard_default_src" {
t.Errorf("unexpected wildcard_default_src when script-src tightens scripts: %+v", states)
}
}
}
func TestCSPRule_OK(t *testing.T) {
p := httpsProbe("a:443")
p.Headers["content-security-policy"] = "default-src 'self'"
data := &HTTPData{Probes: []HTTPProbe{p}}
states := runRule(t, ruleByName(t, "http.csp"), data, nil)
mustStatus(t, states, sdk.StatusOK)
}
func TestXFrameOptionsRule(t *testing.T) {
cases := []struct {
name string
xfo string
csp string
want sdk.Status
wantSub string
}{
{"DENY", "DENY", "", sdk.StatusOK, "http.x_frame_options.ok"},
{"SAMEORIGIN lower", "sameorigin", "", sdk.StatusOK, "http.x_frame_options.ok"},
{"frame-ancestors via CSP", "", "default-src 'self'; frame-ancestors 'none'", sdk.StatusOK, "http.x_frame_options.ok"},
{"invalid value", "ALLOWALL", "", sdk.StatusWarn, "http.x_frame_options.invalid"},
{"missing", "", "", sdk.StatusWarn, "http.x_frame_options.missing"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
p := httpsProbe("a:443")
if c.xfo != "" {
p.Headers["x-frame-options"] = c.xfo
}
if c.csp != "" {
p.Headers["content-security-policy"] = c.csp
}
data := &HTTPData{Probes: []HTTPProbe{p}}
states := runRule(t, ruleByName(t, "http.x_frame_options"), data, nil)
mustStatus(t, states, c.want)
if !hasCode(states, c.wantSub) {
t.Errorf("missing code %q in %+v", c.wantSub, states)
}
})
}
}
func TestXContentTypeOptionsRule(t *testing.T) {
cases := []struct {
val string
want sdk.Status
code string
}{
{"nosniff", sdk.StatusOK, "http.x_content_type_options.ok"},
{"NoSniff", sdk.StatusOK, "http.x_content_type_options.ok"},
{"sniff", sdk.StatusWarn, "http.x_content_type_options.invalid"},
{"", sdk.StatusWarn, "http.x_content_type_options.missing"},
}
for _, c := range cases {
p := httpsProbe("a:443")
if c.val != "" {
p.Headers["x-content-type-options"] = c.val
}
states := runRule(t, ruleByName(t, "http.x_content_type_options"), &HTTPData{Probes: []HTTPProbe{p}}, nil)
mustStatus(t, states, c.want)
if !hasCode(states, c.code) {
t.Errorf("val=%q: missing code %q in %+v", c.val, c.code, states)
}
}
}
func TestXXSSProtectionRule(t *testing.T) {
cases := []struct {
val string
want sdk.Status
code string
}{
{"", 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")
if c.val != "" {
p.Headers["x-xss-protection"] = c.val
}
states := runRule(t, ruleByName(t, "http.x_xss_protection"), &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 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 TestSecurityHeaders_NoHTTPS(t *testing.T) {
// Each header rule must emit Unknown when there are no successful HTTPS probes.
rules := []sdk.CheckRule{
ruleByName(t, "http.hsts"),
ruleByName(t, "http.csp"),
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"),
}
data := &HTTPData{Probes: []HTTPProbe{httpProbe("a:80")}}
for _, r := range rules {
states := runRule(t, r, data, nil)
mustStatus(t, states, sdk.StatusUnknown)
}
}