Add modern security header rules
This commit is contained in:
parent
542ebdea34
commit
01bdadd2ab
3 changed files with 352 additions and 0 deletions
|
|
@ -21,8 +21,14 @@ relies on TLS for transport.
|
||||||
| `http.x_frame_options` | `X-Frame-Options` or CSP `frame-ancestors` provides clickjacking protection. |
|
| `http.x_frame_options` | `X-Frame-Options` or CSP `frame-ancestors` provides clickjacking protection. |
|
||||||
| `http.x_content_type_options` | `X-Content-Type-Options: nosniff` is set. |
|
| `http.x_content_type_options` | `X-Content-Type-Options: nosniff` is set. |
|
||||||
| `http.x_xss_protection` | Reports the legacy `X-XSS-Protection` header (recommendation: disable). |
|
| `http.x_xss_protection` | Reports the legacy `X-XSS-Protection` header (recommendation: disable). |
|
||||||
|
| `http.referrer_policy` | `Referrer-Policy` is set to a privacy-preserving value (W3C Referrer Policy). |
|
||||||
|
| `http.permissions_policy` | `Permissions-Policy` is set (W3C Permissions Policy, replaces Feature-Policy). |
|
||||||
|
| `http.coop` | `Cross-Origin-Opener-Policy` isolates the document from cross-origin windows. |
|
||||||
|
| `http.coep` | `Cross-Origin-Embedder-Policy` requires CORP/CORS opt-in for embedded resources. |
|
||||||
|
| `http.corp` | `Cross-Origin-Resource-Policy` restricts cross-origin embedding of responses. |
|
||||||
| `http.cookie_flags` | Every Set-Cookie has `Secure`, `HttpOnly`, and a `SameSite` attribute. |
|
| `http.cookie_flags` | Every Set-Cookie has `Secure`, `HttpOnly`, and a `SameSite` attribute. |
|
||||||
| `http.sri` | Cross-origin `<script>`/`<link>` tags carry `integrity=` (Subresource Integrity). |
|
| `http.sri` | Cross-origin `<script>`/`<link>` tags carry `integrity=` (Subresource Integrity). |
|
||||||
|
| `http.security_txt` | `/.well-known/security.txt` is published (RFC 9116). |
|
||||||
|
|
||||||
## Options
|
## Options
|
||||||
|
|
||||||
|
|
|
||||||
174
checker/rules_modern_headers.go
Normal file
174
checker/rules_modern_headers.go
Normal file
|
|
@ -0,0 +1,174 @@
|
||||||
|
// This file is part of the happyDomain (R) project.
|
||||||
|
// Copyright (c) 2020-2026 happyDomain
|
||||||
|
// Authors: Pierre-Olivier Mercier, et al.
|
||||||
|
|
||||||
|
package checker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||||
|
)
|
||||||
|
|
||||||
|
// This file wires modern privacy/isolation headers entirely through the
|
||||||
|
// HeaderRule DSL. Each rule is a single declarative spec — no per-rule
|
||||||
|
// type, no Evaluate plumbing, no test scaffolding beyond the value
|
||||||
|
// validator.
|
||||||
|
//
|
||||||
|
// Coverage:
|
||||||
|
// - Referrer-Policy (W3C Referrer Policy)
|
||||||
|
// - Permissions-Policy (W3C Permissions Policy, replaces Feature-Policy)
|
||||||
|
// - Cross-Origin-Opener-Policy (HTML spec, COOP)
|
||||||
|
// - Cross-Origin-Embedder-Policy (HTML spec, COEP)
|
||||||
|
// - Cross-Origin-Resource-Policy (Fetch spec, CORP)
|
||||||
|
//
|
||||||
|
// These are all "presence + value sanity" checks. Anything richer (e.g.
|
||||||
|
// directive-by-directive Permissions-Policy parsing) belongs in its own
|
||||||
|
// hand-rolled rule.
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
RegisterRule(HeaderRule(HeaderRuleSpec{
|
||||||
|
Code: "http.referrer_policy",
|
||||||
|
Description: "Verifies that responses set a Referrer-Policy header with a privacy-preserving value.",
|
||||||
|
Header: "Referrer-Policy",
|
||||||
|
Required: false,
|
||||||
|
FixHint: "Send `Referrer-Policy: strict-origin-when-cross-origin` (the modern browser default) or stricter.",
|
||||||
|
Validate: validateReferrerPolicy,
|
||||||
|
}))
|
||||||
|
|
||||||
|
RegisterRule(HeaderRule(HeaderRuleSpec{
|
||||||
|
Code: "http.permissions_policy",
|
||||||
|
Description: "Reports the presence of a Permissions-Policy header (W3C Permissions Policy, replaces Feature-Policy).",
|
||||||
|
Header: "Permissions-Policy",
|
||||||
|
Required: false,
|
||||||
|
FixHint: "Define a Permissions-Policy that disables APIs the site does not use, e.g. `Permissions-Policy: camera=(), microphone=(), geolocation=()`.",
|
||||||
|
}))
|
||||||
|
|
||||||
|
RegisterRule(HeaderRule(HeaderRuleSpec{
|
||||||
|
Code: "http.coop",
|
||||||
|
Description: "Verifies the Cross-Origin-Opener-Policy (COOP) header for cross-origin process isolation.",
|
||||||
|
Header: "Cross-Origin-Opener-Policy",
|
||||||
|
Required: false,
|
||||||
|
FixHint: "Send `Cross-Origin-Opener-Policy: same-origin` to isolate this document from cross-origin windows.",
|
||||||
|
Validate: validateCOOP,
|
||||||
|
}))
|
||||||
|
|
||||||
|
RegisterRule(HeaderRule(HeaderRuleSpec{
|
||||||
|
Code: "http.coep",
|
||||||
|
Description: "Verifies the Cross-Origin-Embedder-Policy (COEP) header. Required (with COOP) to enable cross-origin isolation and APIs such as SharedArrayBuffer.",
|
||||||
|
Header: "Cross-Origin-Embedder-Policy",
|
||||||
|
Required: false,
|
||||||
|
FixHint: "Send `Cross-Origin-Embedder-Policy: require-corp` (or `credentialless`) once embedded resources opt in via CORP/CORS.",
|
||||||
|
Validate: validateCOEP,
|
||||||
|
}))
|
||||||
|
|
||||||
|
RegisterRule(HeaderRule(HeaderRuleSpec{
|
||||||
|
Code: "http.corp",
|
||||||
|
Description: "Verifies the Cross-Origin-Resource-Policy (CORP) header, which lets a server forbid cross-origin/cross-site embedding of its responses.",
|
||||||
|
Header: "Cross-Origin-Resource-Policy",
|
||||||
|
Required: false,
|
||||||
|
FixHint: "Send `Cross-Origin-Resource-Policy: same-origin` (or `same-site`) on responses that should not be embedded cross-origin.",
|
||||||
|
Validate: validateCORP,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateReferrerPolicy accepts any token (or comma-separated list of
|
||||||
|
// tokens) defined by the W3C Referrer Policy spec, but downgrades the
|
||||||
|
// status when the only effective value is the historically lax
|
||||||
|
// `unsafe-url` or `no-referrer-when-downgrade`. Per the spec, browsers
|
||||||
|
// pick the last *recognised* token of a comma list, so we evaluate that
|
||||||
|
// one.
|
||||||
|
func validateReferrerPolicy(v string) (sdk.Status, string) {
|
||||||
|
tokens := splitCSV(v)
|
||||||
|
if len(tokens) == 0 {
|
||||||
|
return sdk.StatusWarn, "Referrer-Policy is empty."
|
||||||
|
}
|
||||||
|
// Per spec, the user-agent picks the last token it recognises.
|
||||||
|
var effective string
|
||||||
|
for _, t := range tokens {
|
||||||
|
if isReferrerPolicyToken(t) {
|
||||||
|
effective = t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if effective == "" {
|
||||||
|
return sdk.StatusWarn, "Referrer-Policy has no recognised token: " + v
|
||||||
|
}
|
||||||
|
switch effective {
|
||||||
|
case "unsafe-url":
|
||||||
|
return sdk.StatusWarn, "Referrer-Policy: unsafe-url leaks the full URL (including query) cross-origin; prefer strict-origin-when-cross-origin."
|
||||||
|
case "no-referrer-when-downgrade":
|
||||||
|
return sdk.StatusInfo, "Referrer-Policy: no-referrer-when-downgrade is the legacy default; prefer strict-origin-when-cross-origin."
|
||||||
|
}
|
||||||
|
return sdk.StatusOK, "Referrer-Policy is set to " + effective + "."
|
||||||
|
}
|
||||||
|
|
||||||
|
func isReferrerPolicyToken(t string) bool {
|
||||||
|
switch t {
|
||||||
|
case "no-referrer",
|
||||||
|
"no-referrer-when-downgrade",
|
||||||
|
"origin",
|
||||||
|
"origin-when-cross-origin",
|
||||||
|
"same-origin",
|
||||||
|
"strict-origin",
|
||||||
|
"strict-origin-when-cross-origin",
|
||||||
|
"unsafe-url",
|
||||||
|
"":
|
||||||
|
return t != ""
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateCOOP(v string) (sdk.Status, string) {
|
||||||
|
switch strings.ToLower(directiveToken(v)) {
|
||||||
|
case "same-origin", "same-origin-allow-popups", "noopener-allow-popups":
|
||||||
|
return sdk.StatusOK, "Cross-Origin-Opener-Policy is set to " + v + "."
|
||||||
|
case "unsafe-none":
|
||||||
|
return sdk.StatusWarn, "Cross-Origin-Opener-Policy: unsafe-none disables the protection (this is the browser default; the header is redundant)."
|
||||||
|
}
|
||||||
|
return sdk.StatusWarn, "Cross-Origin-Opener-Policy has an unrecognised value: " + v
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateCOEP(v string) (sdk.Status, string) {
|
||||||
|
switch strings.ToLower(directiveToken(v)) {
|
||||||
|
case "require-corp", "credentialless":
|
||||||
|
return sdk.StatusOK, "Cross-Origin-Embedder-Policy is set to " + v + "."
|
||||||
|
case "unsafe-none":
|
||||||
|
return sdk.StatusWarn, "Cross-Origin-Embedder-Policy: unsafe-none disables the protection (this is the browser default; the header is redundant)."
|
||||||
|
}
|
||||||
|
return sdk.StatusWarn, "Cross-Origin-Embedder-Policy has an unrecognised value: " + v
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateCORP(v string) (sdk.Status, string) {
|
||||||
|
switch strings.ToLower(directiveToken(v)) {
|
||||||
|
case "same-origin", "same-site", "cross-origin":
|
||||||
|
return sdk.StatusOK, "Cross-Origin-Resource-Policy is set to " + v + "."
|
||||||
|
}
|
||||||
|
return sdk.StatusWarn, "Cross-Origin-Resource-Policy has an unrecognised value: " + v
|
||||||
|
}
|
||||||
|
|
||||||
|
// splitCSV splits on commas, trims whitespace, lowercases, and drops
|
||||||
|
// empty fragments. Used for header values that are comma-separated lists
|
||||||
|
// of tokens (Referrer-Policy, Accept-Encoding, …).
|
||||||
|
func splitCSV(v string) []string {
|
||||||
|
parts := strings.Split(v, ",")
|
||||||
|
out := make([]string, 0, len(parts))
|
||||||
|
for _, p := range parts {
|
||||||
|
p = strings.TrimSpace(strings.ToLower(p))
|
||||||
|
if p != "" {
|
||||||
|
out = append(out, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// directiveToken extracts the first whitespace-delimited token of a
|
||||||
|
// header value, stripping any trailing parameters (e.g. `same-origin
|
||||||
|
// "..."` -> `same-origin`). Suitable for single-token directive headers
|
||||||
|
// like COOP/COEP/CORP.
|
||||||
|
func directiveToken(v string) string {
|
||||||
|
v = strings.TrimSpace(v)
|
||||||
|
if i := strings.IndexAny(v, " \t;,"); i >= 0 {
|
||||||
|
return v[:i]
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
172
checker/rules_modern_headers_test.go
Normal file
172
checker/rules_modern_headers_test.go
Normal file
|
|
@ -0,0 +1,172 @@
|
||||||
|
// This file is part of the happyDomain (R) project.
|
||||||
|
// Copyright (c) 2020-2026 happyDomain
|
||||||
|
// Authors: Pierre-Olivier Mercier, et al.
|
||||||
|
|
||||||
|
package checker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||||
|
)
|
||||||
|
|
||||||
|
// runHeaderRule looks up a registered rule by name and evaluates it
|
||||||
|
// against an HTTPS probe whose only set header is the one under test.
|
||||||
|
// The collector publishes headers as a lowercase-keyed map (see
|
||||||
|
// collect.go), so we mirror that here regardless of the casing the
|
||||||
|
// caller passed in.
|
||||||
|
func runHeaderRule(t *testing.T, ruleName, header, value string) []sdk.CheckState {
|
||||||
|
t.Helper()
|
||||||
|
p := httpsProbe("a:443")
|
||||||
|
if strings.TrimSpace(value) != "" {
|
||||||
|
p.Headers[strings.ToLower(header)] = value
|
||||||
|
}
|
||||||
|
return runRule(t, ruleByName(t, ruleName), &HTTPData{Probes: []HTTPProbe{p}}, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReferrerPolicyRule(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
value string
|
||||||
|
want sdk.Status
|
||||||
|
code string
|
||||||
|
}{
|
||||||
|
{"missing", "", sdk.StatusInfo, "http.referrer_policy.missing"},
|
||||||
|
{"strict-origin-when-cross-origin", "strict-origin-when-cross-origin", sdk.StatusOK, "http.referrer_policy.ok"},
|
||||||
|
{"no-referrer", "no-referrer", sdk.StatusOK, "http.referrer_policy.ok"},
|
||||||
|
{"unsafe-url", "unsafe-url", sdk.StatusWarn, "http.referrer_policy.invalid"},
|
||||||
|
{"no-referrer-when-downgrade", "no-referrer-when-downgrade", sdk.StatusInfo, "http.referrer_policy.invalid"},
|
||||||
|
{"unrecognised token", "totally-made-up", sdk.StatusWarn, "http.referrer_policy.invalid"},
|
||||||
|
// Per spec the UA picks the last *recognised* token, so the
|
||||||
|
// `bogus` is ignored and `same-origin` wins.
|
||||||
|
{"list with fallback", "bogus, same-origin", sdk.StatusOK, "http.referrer_policy.ok"},
|
||||||
|
// Unknown token after a known one: UA falls back to the last
|
||||||
|
// recognised one (`strict-origin`).
|
||||||
|
{"list with unknown trailing", "strict-origin, bogus", sdk.StatusOK, "http.referrer_policy.ok"},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
states := runHeaderRule(t, "http.referrer_policy", "Referrer-Policy", c.value)
|
||||||
|
mustStatus(t, states, c.want)
|
||||||
|
if !hasCode(states, c.code) {
|
||||||
|
t.Errorf("value=%q: missing code %q in %+v", c.value, c.code, states)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPermissionsPolicyRule(t *testing.T) {
|
||||||
|
// Permissions-Policy has no Validate function: presence alone is OK,
|
||||||
|
// absence is Info (Required=false).
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
value string
|
||||||
|
want sdk.Status
|
||||||
|
code string
|
||||||
|
}{
|
||||||
|
{"missing", "", sdk.StatusInfo, "http.permissions_policy.missing"},
|
||||||
|
{"present", "camera=(), microphone=()", sdk.StatusOK, "http.permissions_policy.ok"},
|
||||||
|
{"empty value treated as missing", " ", sdk.StatusInfo, "http.permissions_policy.missing"},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
states := runHeaderRule(t, "http.permissions_policy", "Permissions-Policy", c.value)
|
||||||
|
mustStatus(t, states, c.want)
|
||||||
|
if !hasCode(states, c.code) {
|
||||||
|
t.Errorf("value=%q: missing code %q in %+v", c.value, c.code, states)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCOOPRule(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
value string
|
||||||
|
want sdk.Status
|
||||||
|
code string
|
||||||
|
}{
|
||||||
|
{"missing", "", sdk.StatusInfo, "http.coop.missing"},
|
||||||
|
{"same-origin", "same-origin", sdk.StatusOK, "http.coop.ok"},
|
||||||
|
{"same-origin-allow-popups", "same-origin-allow-popups", sdk.StatusOK, "http.coop.ok"},
|
||||||
|
{"unsafe-none", "unsafe-none", sdk.StatusWarn, "http.coop.invalid"},
|
||||||
|
{"unrecognised", "bogus", sdk.StatusWarn, "http.coop.invalid"},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
states := runHeaderRule(t, "http.coop", "Cross-Origin-Opener-Policy", c.value)
|
||||||
|
mustStatus(t, states, c.want)
|
||||||
|
if !hasCode(states, c.code) {
|
||||||
|
t.Errorf("value=%q: missing code %q in %+v", c.value, c.code, states)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCOEPRule(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
value string
|
||||||
|
want sdk.Status
|
||||||
|
code string
|
||||||
|
}{
|
||||||
|
{"missing", "", sdk.StatusInfo, "http.coep.missing"},
|
||||||
|
{"require-corp", "require-corp", sdk.StatusOK, "http.coep.ok"},
|
||||||
|
{"credentialless", "credentialless", sdk.StatusOK, "http.coep.ok"},
|
||||||
|
{"unsafe-none", "unsafe-none", sdk.StatusWarn, "http.coep.invalid"},
|
||||||
|
{"unrecognised", "bogus", sdk.StatusWarn, "http.coep.invalid"},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
states := runHeaderRule(t, "http.coep", "Cross-Origin-Embedder-Policy", c.value)
|
||||||
|
mustStatus(t, states, c.want)
|
||||||
|
if !hasCode(states, c.code) {
|
||||||
|
t.Errorf("value=%q: missing code %q in %+v", c.value, c.code, states)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCORPRule(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
value string
|
||||||
|
want sdk.Status
|
||||||
|
code string
|
||||||
|
}{
|
||||||
|
{"missing", "", sdk.StatusInfo, "http.corp.missing"},
|
||||||
|
{"same-origin", "same-origin", sdk.StatusOK, "http.corp.ok"},
|
||||||
|
{"same-site", "same-site", sdk.StatusOK, "http.corp.ok"},
|
||||||
|
{"cross-origin", "cross-origin", sdk.StatusOK, "http.corp.ok"},
|
||||||
|
{"unrecognised", "bogus", sdk.StatusWarn, "http.corp.invalid"},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
states := runHeaderRule(t, "http.corp", "Cross-Origin-Resource-Policy", c.value)
|
||||||
|
mustStatus(t, states, c.want)
|
||||||
|
if !hasCode(states, c.code) {
|
||||||
|
t.Errorf("value=%q: missing code %q in %+v", c.value, c.code, states)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestModernHeaders_NoHTTPS(t *testing.T) {
|
||||||
|
// Each modern header rule must emit Unknown when there are no
|
||||||
|
// successful HTTPS probes — the no_https path comes from EvalPerHTTPS.
|
||||||
|
rules := []string{
|
||||||
|
"http.referrer_policy",
|
||||||
|
"http.permissions_policy",
|
||||||
|
"http.coop",
|
||||||
|
"http.coep",
|
||||||
|
"http.corp",
|
||||||
|
}
|
||||||
|
data := &HTTPData{Probes: []HTTPProbe{httpProbe("a:80")}}
|
||||||
|
for _, name := range rules {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
states := runRule(t, ruleByName(t, name), data, nil)
|
||||||
|
mustStatus(t, states, sdk.StatusUnknown)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue