303 lines
10 KiB
Go
303 lines
10 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"
|
|
"strconv"
|
|
"strings"
|
|
|
|
sdk "git.happydns.org/checker-sdk-go/checker"
|
|
)
|
|
|
|
// 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
|
|
|
|
probes := successfulHTTPSProbes(data.Probes)
|
|
if len(probes) == 0 {
|
|
return []sdk.CheckState{unknownState("http.hsts.no_https", "No successful HTTPS probe to evaluate.")}
|
|
}
|
|
|
|
var states []sdk.CheckState
|
|
for _, p := range probes {
|
|
v := strings.TrimSpace(p.Headers["strict-transport-security"])
|
|
if v == "" {
|
|
status := sdk.StatusWarn
|
|
if !require {
|
|
status = sdk.StatusInfo
|
|
}
|
|
states = append(states, 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."},
|
|
})
|
|
continue
|
|
}
|
|
maxAge, includeSub, preload := parseHSTS(v)
|
|
if maxAge < minSeconds {
|
|
states = append(states, 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).", maxAge, minSeconds, minDays),
|
|
})
|
|
continue
|
|
}
|
|
states = append(states, sdk.CheckState{
|
|
Status: sdk.StatusOK,
|
|
Code: "http.hsts.ok",
|
|
Subject: p.Address,
|
|
Message: fmt.Sprintf("HSTS present (max-age=%d, includeSubDomains=%v, preload=%v).", maxAge, includeSub, preload),
|
|
})
|
|
}
|
|
return states
|
|
}
|
|
|
|
// parseHSTS pulls max-age, includeSubDomains and preload out of a
|
|
// Strict-Transport-Security header value. Returns max-age=0 on parse failure.
|
|
func parseHSTS(v string) (maxAge int64, includeSub bool, preload bool) {
|
|
for _, part := range strings.Split(v, ";") {
|
|
part = strings.TrimSpace(part)
|
|
switch {
|
|
case strings.HasPrefix(strings.ToLower(part), "max-age="):
|
|
val := strings.Trim(part[len("max-age="):], "\"")
|
|
if n, err := strconv.ParseInt(val, 10, 64); err == nil {
|
|
maxAge = n
|
|
}
|
|
case strings.EqualFold(part, "includeSubDomains"):
|
|
includeSub = true
|
|
case strings.EqualFold(part, "preload"):
|
|
preload = true
|
|
}
|
|
}
|
|
return
|
|
}
|
|
|
|
// 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)
|
|
probes := successfulHTTPSProbes(data.Probes)
|
|
if len(probes) == 0 {
|
|
return []sdk.CheckState{unknownState("http.csp.no_https", "No successful HTTPS probe to evaluate.")}
|
|
}
|
|
|
|
var states []sdk.CheckState
|
|
for _, p := range probes {
|
|
csp := strings.TrimSpace(p.Headers["content-security-policy"])
|
|
if csp == "" {
|
|
status := sdk.StatusInfo
|
|
if require {
|
|
status = sdk.StatusWarn
|
|
}
|
|
states = append(states, 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')."},
|
|
})
|
|
continue
|
|
}
|
|
// Quick sanity hints; full CSP analysis is out of scope.
|
|
if strings.Contains(csp, "'unsafe-inline'") || strings.Contains(csp, "'unsafe-eval'") {
|
|
states = append(states, sdk.CheckState{
|
|
Status: sdk.StatusWarn,
|
|
Code: "http.csp.unsafe",
|
|
Subject: p.Address,
|
|
Message: "Content-Security-Policy uses 'unsafe-inline' or 'unsafe-eval'.",
|
|
})
|
|
continue
|
|
}
|
|
states = append(states, sdk.CheckState{
|
|
Status: sdk.StatusOK,
|
|
Code: "http.csp.ok",
|
|
Subject: p.Address,
|
|
Message: "Content-Security-Policy is set.",
|
|
})
|
|
}
|
|
return states
|
|
}
|
|
|
|
// 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}
|
|
}
|
|
probes := successfulHTTPSProbes(data.Probes)
|
|
if len(probes) == 0 {
|
|
return []sdk.CheckState{unknownState("http.x_frame_options.no_https", "No successful HTTPS probe to evaluate.")}
|
|
}
|
|
var states []sdk.CheckState
|
|
for _, p := range probes {
|
|
xfo := strings.ToUpper(strings.TrimSpace(p.Headers["x-frame-options"]))
|
|
csp := strings.ToLower(p.Headers["content-security-policy"])
|
|
hasFrameAncestors := strings.Contains(csp, "frame-ancestors")
|
|
|
|
switch {
|
|
case xfo == "DENY" || xfo == "SAMEORIGIN" || hasFrameAncestors:
|
|
states = append(states, sdk.CheckState{
|
|
Status: sdk.StatusOK,
|
|
Code: "http.x_frame_options.ok",
|
|
Subject: p.Address,
|
|
Message: "Clickjacking protection is in place.",
|
|
})
|
|
case xfo != "":
|
|
states = append(states, sdk.CheckState{
|
|
Status: sdk.StatusWarn,
|
|
Code: "http.x_frame_options.invalid",
|
|
Subject: p.Address,
|
|
Message: "X-Frame-Options has an unrecognised value: " + xfo,
|
|
})
|
|
default:
|
|
states = append(states, 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."},
|
|
})
|
|
}
|
|
}
|
|
return states
|
|
}
|
|
|
|
// xContentTypeOptionsRule checks for X-Content-Type-Options: nosniff.
|
|
type xContentTypeOptionsRule struct{}
|
|
|
|
func (r *xContentTypeOptionsRule) Name() string { return "http.x_content_type_options" }
|
|
func (r *xContentTypeOptionsRule) Description() string {
|
|
return "Verifies that responses set X-Content-Type-Options: nosniff."
|
|
}
|
|
|
|
func (r *xContentTypeOptionsRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
|
|
data, errSt := loadHTTPData(ctx, obs)
|
|
if errSt != nil {
|
|
return []sdk.CheckState{*errSt}
|
|
}
|
|
probes := successfulHTTPSProbes(data.Probes)
|
|
if len(probes) == 0 {
|
|
return []sdk.CheckState{unknownState("http.x_content_type_options.no_https", "No successful HTTPS probe to evaluate.")}
|
|
}
|
|
var states []sdk.CheckState
|
|
for _, p := range probes {
|
|
v := strings.ToLower(strings.TrimSpace(p.Headers["x-content-type-options"]))
|
|
if v == "nosniff" {
|
|
states = append(states, sdk.CheckState{
|
|
Status: sdk.StatusOK,
|
|
Code: "http.x_content_type_options.ok",
|
|
Subject: p.Address,
|
|
Message: "X-Content-Type-Options: nosniff is set.",
|
|
})
|
|
} else if v != "" {
|
|
states = append(states, sdk.CheckState{
|
|
Status: sdk.StatusWarn,
|
|
Code: "http.x_content_type_options.invalid",
|
|
Subject: p.Address,
|
|
Message: "X-Content-Type-Options has an unexpected value: " + v,
|
|
})
|
|
} else {
|
|
states = append(states, sdk.CheckState{
|
|
Status: sdk.StatusWarn,
|
|
Code: "http.x_content_type_options.missing",
|
|
Subject: p.Address,
|
|
Message: "X-Content-Type-Options: nosniff is not set.",
|
|
Meta: map[string]any{"fix": "Add `X-Content-Type-Options: nosniff` to all responses."},
|
|
})
|
|
}
|
|
}
|
|
return states
|
|
}
|
|
|
|
// 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}
|
|
}
|
|
probes := successfulHTTPSProbes(data.Probes)
|
|
if len(probes) == 0 {
|
|
return []sdk.CheckState{unknownState("http.x_xss_protection.no_https", "No successful HTTPS probe to evaluate.")}
|
|
}
|
|
var states []sdk.CheckState
|
|
for _, p := range probes {
|
|
v := strings.TrimSpace(p.Headers["x-xss-protection"])
|
|
switch {
|
|
case v == "":
|
|
states = append(states, 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"):
|
|
states = append(states, 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"):
|
|
states = append(states, 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:
|
|
states = append(states, 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.",
|
|
})
|
|
}
|
|
}
|
|
return states
|
|
}
|