The http.security_txt rule reported OK for any 200 response with a non-empty body, so a soft-404 (status 200 + HTML body) served for /.well-known/security.txt was misread as "published". Capture the response Content-Type and count the RFC 9116 required fields (Contact, Expires) in the body. OK now requires text/plain with at least one Contact and exactly one Expires; a non-conforming 200 yields a new Warn http.security_txt.invalid explaining the defect. Redirects are still followed and the final response is validated, per RFC 9116 §3.
127 lines
4.8 KiB
Go
127 lines
4.8 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"
|
|
"mime"
|
|
|
|
sdk "git.happydns.org/checker-sdk-go/checker"
|
|
)
|
|
|
|
func init() { RegisterRule(&securityTxtRule{}) }
|
|
|
|
// securityTxtRule reports whether /.well-known/security.txt is published
|
|
// (RFC 9116). Absence is an Info, not a Warn: many sites legitimately
|
|
// have no security disclosure pipeline, but it is now the expected place
|
|
// for researchers to look first.
|
|
type securityTxtRule struct{}
|
|
|
|
func (r *securityTxtRule) Name() string { return "http.security_txt" }
|
|
func (r *securityTxtRule) Description() string {
|
|
return "Reports whether /.well-known/security.txt (RFC 9116) is published."
|
|
}
|
|
|
|
func (r *securityTxtRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
|
|
data, errSt := loadHTTPData(ctx, obs)
|
|
if errSt != nil {
|
|
return []sdk.CheckState{*errSt}
|
|
}
|
|
wk, ok, err := LoadExtension[WellKnownData](data, ObservationKeyWellKnown)
|
|
if err != nil {
|
|
return []sdk.CheckState{{Status: sdk.StatusError, Code: "http.security_txt.decode_error", Message: err.Error()}}
|
|
}
|
|
if !ok {
|
|
return []sdk.CheckState{unknownState("http.security_txt.no_data", "Well-known collector did not run.")}
|
|
}
|
|
probe := wk.URIs["/.well-known/security.txt"]
|
|
valid, defect := checkSecurityTxt(probe)
|
|
switch {
|
|
case probe.StatusCode == 200 && probe.Error != "":
|
|
// The server answered 200 but the body could not be fully read, so
|
|
// the field counts are unreliable; don't pass a verdict on it.
|
|
return []sdk.CheckState{{
|
|
Status: sdk.StatusWarn,
|
|
Code: "http.security_txt.read_error",
|
|
Subject: data.Domain,
|
|
Message: fmt.Sprintf("/.well-known/security.txt responded 200 but could not be read fully (%s).", probe.Error),
|
|
}}
|
|
case probe.StatusCode == 200 && probe.Bytes == 0:
|
|
return []sdk.CheckState{{
|
|
Status: sdk.StatusWarn,
|
|
Code: "http.security_txt.empty",
|
|
Subject: data.Domain,
|
|
Message: "/.well-known/security.txt responded 200 but is empty.",
|
|
}}
|
|
case probe.StatusCode == 200 && valid:
|
|
return []sdk.CheckState{{
|
|
Status: sdk.StatusOK,
|
|
Code: "http.security_txt.ok",
|
|
Subject: data.Domain,
|
|
Message: fmt.Sprintf("/.well-known/security.txt is published (%d bytes).", probe.Bytes),
|
|
}}
|
|
case probe.StatusCode == 200:
|
|
// 200 but the body is not a conforming RFC 9116 file. With no Contact
|
|
// or Expires fields at all it is typically a soft-404 page (e.g. an
|
|
// HTML 404 served with status 200); when the fields are present it is
|
|
// a genuine file that is merely non-conforming (wrong Content-Type,
|
|
// duplicate Expires, …), so don't mislabel it a soft-404.
|
|
msg := fmt.Sprintf("/.well-known/security.txt responded 200 but is not a valid RFC 9116 file (%s).", defect)
|
|
if probe.ContactCount == 0 && probe.ExpiresCount == 0 {
|
|
msg += " It looks like a soft-404 or placeholder rather than a published security.txt."
|
|
}
|
|
return []sdk.CheckState{{
|
|
Status: sdk.StatusWarn,
|
|
Code: "http.security_txt.invalid",
|
|
Subject: data.Domain,
|
|
Message: msg,
|
|
Meta: map[string]any{
|
|
"content_type": probe.ContentType,
|
|
"contact_count": probe.ContactCount,
|
|
"expires_count": probe.ExpiresCount,
|
|
"fix": "Serve /.well-known/security.txt as text/plain with at least one Contact: field and exactly one Expires: field (RFC 9116). If you do not publish one, return 404 for this path.",
|
|
},
|
|
}}
|
|
default:
|
|
return []sdk.CheckState{{
|
|
Status: sdk.StatusInfo,
|
|
Code: "http.security_txt.missing",
|
|
Subject: data.Domain,
|
|
Message: fmt.Sprintf("/.well-known/security.txt is not published (status %d).", probe.StatusCode),
|
|
Meta: map[string]any{"fix": "Publish /.well-known/security.txt per RFC 9116 (Contact:, Expires:, …)."},
|
|
}}
|
|
}
|
|
}
|
|
|
|
// checkSecurityTxt reports whether a 200 response is a conforming RFC 9116
|
|
// file (served as text/plain, with at least one Contact field and exactly one
|
|
// Expires field) and, when it is not, a short human-readable reason why.
|
|
func checkSecurityTxt(p WellKnownProbe) (valid bool, defect string) {
|
|
switch {
|
|
case !isTextPlain(p.ContentType):
|
|
ct := p.ContentType
|
|
if ct == "" {
|
|
ct = "no Content-Type"
|
|
}
|
|
return false, fmt.Sprintf("Content-Type is %q, not text/plain", ct)
|
|
case p.ContactCount == 0:
|
|
return false, "missing required Contact field"
|
|
case p.ExpiresCount == 0:
|
|
return false, "missing required Expires field"
|
|
case p.ExpiresCount > 1:
|
|
return false, fmt.Sprintf("has %d Expires fields, exactly one is required", p.ExpiresCount)
|
|
default:
|
|
return true, ""
|
|
}
|
|
}
|
|
|
|
// isTextPlain reports whether a Content-Type header value denotes text/plain,
|
|
// tolerating an optional charset (or other) parameter such as
|
|
// "text/plain; charset=utf-8".
|
|
func isTextPlain(contentType string) bool {
|
|
mediaType, _, _ := mime.ParseMediaType(contentType)
|
|
return mediaType == "text/plain"
|
|
}
|