checker-http/checker/rules_security_headers.go

231 lines
7.7 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 (
"context"
"fmt"
"strings"
sdk "git.happydns.org/checker-sdk-go/checker"
)
func init() {
RegisterRule(&hstsRule{})
RegisterRule(&cspRule{})
RegisterRule(&xFrameOptionsRule{})
RegisterRule(&xXSSProtectionRule{})
}
// hstsRule checks the Strict-Transport-Security header on HTTPS responses.
type hstsRule struct{}
func (r *hstsRule) Name() string { return "http.hsts" }
func (r *hstsRule) Description() string {
return "Verifies the presence and quality of the Strict-Transport-Security header on HTTPS responses."
}
func (r *hstsRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState {
data, errSt := loadHTTPData(ctx, obs)
if errSt != nil {
return []sdk.CheckState{*errSt}
}
require := sdk.GetBoolOption(opts, OptionRequireHSTS, true)
minDays := sdk.GetIntOption(opts, OptionMinHSTSMaxAgeDays, DefaultMinHSTSMaxAge)
minSeconds := int64(minDays) * 86400
return EvalPerHTTPS(data, "http.hsts", func(p HTTPProbe) sdk.CheckState {
h := ParseHSTS(p.Headers["strict-transport-security"])
if h == nil {
status := sdk.StatusWarn
if !require {
status = sdk.StatusInfo
}
return sdk.CheckState{
Status: status,
Code: "http.hsts.missing",
Subject: p.Address,
Message: "Strict-Transport-Security header is missing.",
Meta: map[string]any{"fix": "Send `Strict-Transport-Security: max-age=15552000; includeSubDomains` from HTTPS responses."},
}
}
if h.MaxAge < minSeconds {
return sdk.CheckState{
Status: sdk.StatusWarn,
Code: "http.hsts.short_max_age",
Subject: p.Address,
Message: fmt.Sprintf("HSTS max-age=%d is below the recommended %d seconds (%d days).", h.MaxAge, minSeconds, minDays),
}
}
return sdk.CheckState{
Status: sdk.StatusOK,
Code: "http.hsts.ok",
Subject: p.Address,
Message: fmt.Sprintf("HSTS present (max-age=%d, includeSubDomains=%v, preload=%v).", h.MaxAge, h.IncludeSub, h.Preload),
}
})
}
// cspRule checks for the presence of a Content-Security-Policy header.
type cspRule struct{}
func (r *cspRule) Name() string { return "http.csp" }
func (r *cspRule) Description() string {
return "Verifies the presence of a Content-Security-Policy header on HTTPS responses."
}
func (r *cspRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState {
data, errSt := loadHTTPData(ctx, obs)
if errSt != nil {
return []sdk.CheckState{*errSt}
}
require := sdk.GetBoolOption(opts, OptionRequireCSP, false)
return EvalPerHTTPS(data, "http.csp", func(p HTTPProbe) sdk.CheckState {
csp := ParseCSP(p.Headers["content-security-policy"])
if csp == nil {
status := sdk.StatusInfo
if require {
status = sdk.StatusWarn
}
return sdk.CheckState{
Status: status,
Code: "http.csp.missing",
Subject: p.Address,
Message: "Content-Security-Policy header is missing.",
Meta: map[string]any{"fix": "Define a CSP appropriate for your application (e.g. default-src 'self')."},
}
}
if csp.HasUnsafe() {
return sdk.CheckState{
Status: sdk.StatusWarn,
Code: "http.csp.unsafe",
Subject: p.Address,
Message: "Content-Security-Policy uses 'unsafe-inline' or 'unsafe-eval'.",
}
}
return sdk.CheckState{
Status: sdk.StatusOK,
Code: "http.csp.ok",
Subject: p.Address,
Message: "Content-Security-Policy is set.",
}
})
}
// xFrameOptionsRule checks X-Frame-Options (or frame-ancestors in CSP as
// an acceptable substitute).
type xFrameOptionsRule struct{}
func (r *xFrameOptionsRule) Name() string { return "http.x_frame_options" }
func (r *xFrameOptionsRule) Description() string {
return "Verifies that responses set X-Frame-Options or a CSP frame-ancestors directive."
}
func (r *xFrameOptionsRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
data, errSt := loadHTTPData(ctx, obs)
if errSt != nil {
return []sdk.CheckState{*errSt}
}
return EvalPerHTTPS(data, "http.x_frame_options", func(p HTTPProbe) sdk.CheckState {
xfo := strings.ToUpper(strings.TrimSpace(p.Headers["x-frame-options"]))
hasFrameAncestors := ParseCSP(p.Headers["content-security-policy"]).HasDirective("frame-ancestors")
switch {
case xfo == "DENY" || xfo == "SAMEORIGIN" || hasFrameAncestors:
return sdk.CheckState{
Status: sdk.StatusOK,
Code: "http.x_frame_options.ok",
Subject: p.Address,
Message: "Clickjacking protection is in place.",
}
case xfo != "":
return sdk.CheckState{
Status: sdk.StatusWarn,
Code: "http.x_frame_options.invalid",
Subject: p.Address,
Message: "X-Frame-Options has an unrecognised value: " + xfo,
}
default:
return sdk.CheckState{
Status: sdk.StatusWarn,
Code: "http.x_frame_options.missing",
Subject: p.Address,
Message: "Neither X-Frame-Options nor CSP frame-ancestors is set.",
Meta: map[string]any{"fix": "Send `X-Frame-Options: DENY` (or SAMEORIGIN) or use CSP frame-ancestors."},
}
}
})
}
func init() {
// Showcase: a rule expressed entirely as a HeaderRuleSpec. Compare
// with the hand-rolled rules above — the boilerplate vanishes once
// the only logic is "is this header present and well-formed?".
RegisterRule(HeaderRule(HeaderRuleSpec{
Code: "http.x_content_type_options",
Description: "Verifies that responses set X-Content-Type-Options: nosniff.",
Header: "X-Content-Type-Options",
Required: true,
FixHint: "Add `X-Content-Type-Options: nosniff` to all responses.",
Validate: func(v string) (sdk.Status, string) {
if strings.EqualFold(v, "nosniff") {
return sdk.StatusOK, "X-Content-Type-Options: nosniff is set."
}
return sdk.StatusWarn, "X-Content-Type-Options has an unexpected value: " + strings.ToLower(v)
},
}))
}
// xXSSProtectionRule checks the legacy X-XSS-Protection header. Modern
// browsers ignore it, but if present we want it to be sane.
type xXSSProtectionRule struct{}
func (r *xXSSProtectionRule) Name() string { return "http.x_xss_protection" }
func (r *xXSSProtectionRule) Description() string {
return "Reports the value of the legacy X-XSS-Protection header (disabled is preferred on modern browsers; CSP is the proper replacement)."
}
func (r *xXSSProtectionRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
data, errSt := loadHTTPData(ctx, obs)
if errSt != nil {
return []sdk.CheckState{*errSt}
}
return EvalPerHTTPS(data, "http.x_xss_protection", func(p HTTPProbe) sdk.CheckState {
v := strings.TrimSpace(p.Headers["x-xss-protection"])
switch {
case v == "":
return sdk.CheckState{
Status: sdk.StatusInfo,
Code: "http.x_xss_protection.absent",
Subject: p.Address,
Message: "X-XSS-Protection is not set; CSP is the recommended replacement.",
}
case strings.HasPrefix(v, "0"):
return sdk.CheckState{
Status: sdk.StatusOK,
Code: "http.x_xss_protection.disabled",
Subject: p.Address,
Message: "X-XSS-Protection is explicitly disabled (recommended).",
}
case strings.Contains(strings.ToLower(v), "mode=block"):
return sdk.CheckState{
Status: sdk.StatusInfo,
Code: "http.x_xss_protection.enabled",
Subject: p.Address,
Message: "X-XSS-Protection is set to the historically recommended `1; mode=block`. Modern browsers ignore this header; CSP is the proper replacement.",
}
default:
return sdk.CheckState{
Status: sdk.StatusInfo,
Code: "http.x_xss_protection.enabled",
Subject: p.Address,
Message: "X-XSS-Protection is enabled. Modern browsers ignore this header; CSP is the proper replacement.",
}
}
})
}