// 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 }