Deepen CSP, Permissions-Policy and cookie audits
Detect CSP weaknesses individually (unsafe-inline, unsafe-eval, missing default-src/script-src, permissive sources on script-src or its default-src fallback) instead of a single catch-all "unsafe" code, and honour CSP3 fetch-directive fallback via EffectiveSources/WildcardSource helpers. Validate Permissions-Policy values: warn when a powerful feature (camera, microphone, geolocation, payment, sensors, …) is granted to all origins. Add a SameSite aggregate state on cookie audits so callers get the global ratio alongside per-cookie diagnostics.
This commit is contained in:
parent
27a30638f4
commit
603e93355b
8 changed files with 738 additions and 305 deletions
|
|
@ -17,16 +17,21 @@ func TestParseHSTS(t *testing.T) {
|
|||
maxAge int64
|
||||
includeSub bool
|
||||
preload bool
|
||||
wantErr bool
|
||||
}{
|
||||
{"empty", "", 0, false, false},
|
||||
{"max-age only", "max-age=31536000", 31536000, false, false},
|
||||
{"includeSubDomains", "max-age=15552000; includeSubDomains", 15552000, true, false},
|
||||
{"all flags", "max-age=63072000; includeSubDomains; preload", 63072000, true, true},
|
||||
{"quoted max-age", `max-age="3600"`, 3600, false, false},
|
||||
{"case-insensitive directive", "MAX-AGE=42; INCLUDESUBDOMAINS; PRELOAD", 42, true, true},
|
||||
{"messy spaces", " max-age=10 ; includeSubDomains ", 10, true, false},
|
||||
{"unparseable max-age", "max-age=not-a-number", 0, false, false},
|
||||
{"no max-age, only flags", "includeSubDomains; preload", 0, true, true},
|
||||
{"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) {
|
||||
|
|
@ -44,13 +49,17 @@ func TestParseHSTS(t *testing.T) {
|
|||
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, &hstsRule{}, data, nil)
|
||||
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)
|
||||
|
|
@ -59,7 +68,7 @@ func TestHSTSRule_NoHTTPSProbes(t *testing.T) {
|
|||
|
||||
func TestHSTSRule_MissingRequired(t *testing.T) {
|
||||
data := &HTTPData{Probes: []HTTPProbe{httpsProbe("a:443")}}
|
||||
states := runRule(t, &hstsRule{}, data, sdk.CheckerOptions{OptionRequireHSTS: true})
|
||||
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)
|
||||
|
|
@ -68,7 +77,7 @@ func TestHSTSRule_MissingRequired(t *testing.T) {
|
|||
|
||||
func TestHSTSRule_MissingNotRequired(t *testing.T) {
|
||||
data := &HTTPData{Probes: []HTTPProbe{httpsProbe("a:443")}}
|
||||
states := runRule(t, &hstsRule{}, data, sdk.CheckerOptions{OptionRequireHSTS: false})
|
||||
states := runRule(t, ruleByName(t, "http.hsts"), data, sdk.CheckerOptions{OptionRequireHSTS: false})
|
||||
mustStatus(t, states, sdk.StatusInfo)
|
||||
}
|
||||
|
||||
|
|
@ -76,7 +85,7 @@ 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, &hstsRule{}, data, nil)
|
||||
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)
|
||||
|
|
@ -87,7 +96,7 @@ 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, &hstsRule{}, data, nil)
|
||||
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)
|
||||
|
|
@ -95,7 +104,7 @@ func TestHSTSRule_OK(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestHSTSRule_LoadFailure(t *testing.T) {
|
||||
states := (&hstsRule{}).Evaluate(t.Context(), &fakeObs{failGet: true}, nil)
|
||||
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)
|
||||
}
|
||||
|
|
@ -104,22 +113,78 @@ func TestHSTSRule_LoadFailure(t *testing.T) {
|
|||
func TestCSPRule_Missing(t *testing.T) {
|
||||
data := &HTTPData{Probes: []HTTPProbe{httpsProbe("a:443")}}
|
||||
// Default: not required → Info.
|
||||
states := runRule(t, &cspRule{}, data, nil)
|
||||
states := runRule(t, ruleByName(t, "http.csp"), data, nil)
|
||||
mustStatus(t, states, sdk.StatusInfo)
|
||||
// Required → Warn.
|
||||
states = runRule(t, &cspRule{}, data, sdk.CheckerOptions{OptionRequireCSP: true})
|
||||
states = runRule(t, ruleByName(t, "http.csp"), data, sdk.CheckerOptions{OptionRequireCSP: true})
|
||||
mustStatus(t, states, sdk.StatusWarn)
|
||||
}
|
||||
|
||||
func TestCSPRule_Unsafe(t *testing.T) {
|
||||
for _, csp := range []string{"default-src 'self'; script-src 'unsafe-inline'", "default-src 'unsafe-eval'"} {
|
||||
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"] = csp
|
||||
p.Headers["content-security-policy"] = c.csp
|
||||
data := &HTTPData{Probes: []HTTPProbe{p}}
|
||||
states := runRule(t, &cspRule{}, data, nil)
|
||||
states := runRule(t, ruleByName(t, "http.csp"), data, nil)
|
||||
mustStatus(t, states, sdk.StatusWarn)
|
||||
if !hasCode(states, "http.csp.unsafe") {
|
||||
t.Errorf("csp=%q: missing unsafe code: %+v", csp, states)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -128,7 +193,7 @@ 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, &cspRule{}, data, nil)
|
||||
states := runRule(t, ruleByName(t, "http.csp"), data, nil)
|
||||
mustStatus(t, states, sdk.StatusOK)
|
||||
}
|
||||
|
||||
|
|
@ -156,7 +221,7 @@ func TestXFrameOptionsRule(t *testing.T) {
|
|||
p.Headers["content-security-policy"] = c.csp
|
||||
}
|
||||
data := &HTTPData{Probes: []HTTPProbe{p}}
|
||||
states := runRule(t, &xFrameOptionsRule{}, data, nil)
|
||||
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)
|
||||
|
|
@ -204,7 +269,7 @@ func TestXXSSProtectionRule(t *testing.T) {
|
|||
if c.val != "" {
|
||||
p.Headers["x-xss-protection"] = c.val
|
||||
}
|
||||
states := runRule(t, &xXSSProtectionRule{}, &HTTPData{Probes: []HTTPProbe{p}}, nil)
|
||||
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)
|
||||
|
|
@ -214,7 +279,13 @@ func TestXXSSProtectionRule(t *testing.T) {
|
|||
|
||||
func TestSecurityHeaders_NoHTTPS(t *testing.T) {
|
||||
// Each header rule must emit Unknown when there are no successful HTTPS probes.
|
||||
rules := []sdk.CheckRule{&hstsRule{}, &cspRule{}, &xFrameOptionsRule{}, ruleByName(t, "http.x_content_type_options"), &xXSSProtectionRule{}}
|
||||
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"),
|
||||
}
|
||||
data := &HTTPData{Probes: []HTTPProbe{httpProbe("a:80")}}
|
||||
for _, r := range rules {
|
||||
states := runRule(t, r, data, nil)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue