223 lines
7.2 KiB
Go
223 lines
7.2 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
|
|
}{
|
|
{"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},
|
|
}
|
|
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)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestHSTSRule_NoHTTPSProbes(t *testing.T) {
|
|
data := &HTTPData{Probes: []HTTPProbe{httpProbe("a:80")}}
|
|
states := runRule(t, &hstsRule{}, 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, &hstsRule{}, 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, &hstsRule{}, 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, &hstsRule{}, 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, &hstsRule{}, 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 := (&hstsRule{}).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, &cspRule{}, data, nil)
|
|
mustStatus(t, states, sdk.StatusInfo)
|
|
// Required → Warn.
|
|
states = runRule(t, &cspRule{}, 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'"} {
|
|
p := httpsProbe("a:443")
|
|
p.Headers["content-security-policy"] = csp
|
|
data := &HTTPData{Probes: []HTTPProbe{p}}
|
|
states := runRule(t, &cspRule{}, data, nil)
|
|
mustStatus(t, states, sdk.StatusWarn)
|
|
if !hasCode(states, "http.csp.unsafe") {
|
|
t.Errorf("csp=%q: missing unsafe code: %+v", csp, 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, &cspRule{}, 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, &xFrameOptionsRule{}, 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, &xXSSProtectionRule{}, &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{&hstsRule{}, &cspRule{}, &xFrameOptionsRule{}, ruleByName(t, "http.x_content_type_options"), &xXSSProtectionRule{}}
|
|
data := &HTTPData{Probes: []HTTPProbe{httpProbe("a:80")}}
|
|
for _, r := range rules {
|
|
states := runRule(t, r, data, nil)
|
|
mustStatus(t, states, sdk.StatusUnknown)
|
|
}
|
|
}
|