// 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.StatusInfo, "http.x_xss_protection.absent"}, {"0", sdk.StatusOK, "http.x_xss_protection.disabled"}, {"1; mode=block", sdk.StatusInfo, "http.x_xss_protection.enabled"}, } 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 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"), } data := &HTTPData{Probes: []HTTPProbe{httpProbe("a:80")}} for _, r := range rules { states := runRule(t, r, data, nil) mustStatus(t, states, sdk.StatusUnknown) } }