checker: validate security.txt is a real RFC 9116 file

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.
This commit is contained in:
nemunaire 2026-06-14 12:56:43 +09:00
commit ffa3fbe1f9
4 changed files with 281 additions and 22 deletions

View file

@ -7,6 +7,7 @@ package checker
import (
"context"
"fmt"
"mime"
sdk "git.happydns.org/checker-sdk-go/checker"
)
@ -37,8 +38,25 @@ func (r *securityTxtRule) Evaluate(ctx context.Context, obs sdk.ObservationGette
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.Bytes > 0:
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",
@ -46,11 +64,26 @@ func (r *securityTxtRule) Evaluate(ctx context.Context, obs sdk.ObservationGette
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.empty",
Code: "http.security_txt.invalid",
Subject: data.Domain,
Message: "/.well-known/security.txt responded 200 but is empty.",
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{{
@ -62,3 +95,33 @@ func (r *securityTxtRule) Evaluate(ctx context.Context, obs sdk.ObservationGette
}}
}
}
// 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"
}