checker-http/checker/rules_wellknown_test.go
Pierre-Olivier Mercier ffa3fbe1f9 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.
2026-06-14 18:14:00 +09:00

229 lines
6.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 (
"encoding/json"
"strings"
"testing"
sdk "git.happydns.org/checker-sdk-go/checker"
)
func wellKnownData(t *testing.T, probes map[string]WellKnownProbe) map[string]json.RawMessage {
t.Helper()
raw, err := json.Marshal(WellKnownData{URIs: probes})
if err != nil {
t.Fatalf("marshal: %v", err)
}
return map[string]json.RawMessage{ObservationKeyWellKnown: raw}
}
func TestSecurityTxtRule_OK(t *testing.T) {
data := &HTTPData{
Domain: "example.test",
Probes: []HTTPProbe{httpsProbe("a:443")},
Extensions: wellKnownData(t, map[string]WellKnownProbe{
"/.well-known/security.txt": {
PathProbe: PathProbe{StatusCode: 200, Bytes: 128, ContentType: "text/plain; charset=utf-8"},
ContactCount: 1,
ExpiresCount: 1,
},
"/robots.txt": {PathProbe: PathProbe{StatusCode: 200, Bytes: 42}},
}),
}
states := runRule(t, &securityTxtRule{}, data, nil)
mustStatus(t, states, sdk.StatusOK)
if !hasCode(states, "http.security_txt.ok") {
t.Errorf("expected ok, got %+v", states)
}
}
func TestSecurityTxtRule_Empty(t *testing.T) {
data := &HTTPData{
Domain: "example.test",
Probes: []HTTPProbe{httpsProbe("a:443")},
Extensions: wellKnownData(t, map[string]WellKnownProbe{
"/.well-known/security.txt": {PathProbe: PathProbe{StatusCode: 200, Bytes: 0}},
}),
}
states := runRule(t, &securityTxtRule{}, data, nil)
mustStatus(t, states, sdk.StatusWarn)
if !hasCode(states, "http.security_txt.empty") {
t.Errorf("expected empty, got %+v", states)
}
}
func TestSecurityTxtRule_ReadError(t *testing.T) {
data := &HTTPData{
Domain: "example.test",
Probes: []HTTPProbe{httpsProbe("a:443")},
Extensions: wellKnownData(t, map[string]WellKnownProbe{
// 200 with a partial body and a read error: counts are unreliable.
"/.well-known/security.txt": {
PathProbe: PathProbe{StatusCode: 200, Bytes: 12, ContentType: "text/plain", Error: "unexpected EOF"},
ContactCount: 1,
},
}),
}
states := runRule(t, &securityTxtRule{}, data, nil)
mustStatus(t, states, sdk.StatusWarn)
if !hasCode(states, "http.security_txt.read_error") {
t.Errorf("expected read_error, got %+v", states)
}
}
func TestSecurityTxtRule_Invalid(t *testing.T) {
cases := []struct {
name string
probe WellKnownProbe
}{
{
name: "soft-404 html",
probe: WellKnownProbe{
PathProbe: PathProbe{StatusCode: 200, Bytes: 6320, ContentType: "text/html; charset=utf-8"},
},
},
{
name: "no contact",
probe: WellKnownProbe{
PathProbe: PathProbe{StatusCode: 200, Bytes: 64, ContentType: "text/plain"},
ContactCount: 0,
ExpiresCount: 1,
},
},
{
name: "no expires",
probe: WellKnownProbe{
PathProbe: PathProbe{StatusCode: 200, Bytes: 64, ContentType: "text/plain"},
ContactCount: 1,
ExpiresCount: 0,
},
},
{
name: "two expires",
probe: WellKnownProbe{
PathProbe: PathProbe{StatusCode: 200, Bytes: 64, ContentType: "text/plain"},
ContactCount: 1,
ExpiresCount: 2,
},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
data := &HTTPData{
Domain: "example.test",
Probes: []HTTPProbe{httpsProbe("a:443")},
Extensions: wellKnownData(t, map[string]WellKnownProbe{
"/.well-known/security.txt": tc.probe,
}),
}
states := runRule(t, &securityTxtRule{}, data, nil)
mustStatus(t, states, sdk.StatusWarn)
if !hasCode(states, "http.security_txt.invalid") {
t.Errorf("expected invalid, got %+v", states)
}
if states[0].Meta["fix"] == nil {
t.Errorf("expected fix hint in meta, got %+v", states[0].Meta)
}
})
}
}
func TestSecurityTxtRule_InvalidWording(t *testing.T) {
// A genuine file (Contact + Expires present) served with the wrong
// Content-Type is invalid, but must not be mislabelled a soft-404.
data := &HTTPData{
Domain: "example.test",
Probes: []HTTPProbe{httpsProbe("a:443")},
Extensions: wellKnownData(t, map[string]WellKnownProbe{
"/.well-known/security.txt": {
PathProbe: PathProbe{StatusCode: 200, Bytes: 64, ContentType: "application/octet-stream"},
ContactCount: 1,
ExpiresCount: 1,
},
}),
}
states := runRule(t, &securityTxtRule{}, data, nil)
mustStatus(t, states, sdk.StatusWarn)
if !hasCode(states, "http.security_txt.invalid") {
t.Fatalf("expected invalid, got %+v", states)
}
if strings.Contains(states[0].Message, "soft-404") {
t.Errorf("genuine file should not be labelled soft-404, got %q", states[0].Message)
}
// A bodyless/placeholder page with no fields keeps the soft-404 hint.
data.Extensions = wellKnownData(t, map[string]WellKnownProbe{
"/.well-known/security.txt": {
PathProbe: PathProbe{StatusCode: 200, Bytes: 6320, ContentType: "text/html; charset=utf-8"},
},
})
states = runRule(t, &securityTxtRule{}, data, nil)
if !strings.Contains(states[0].Message, "soft-404") {
t.Errorf("placeholder page should mention soft-404, got %q", states[0].Message)
}
}
func TestSecurityTxtRule_Missing(t *testing.T) {
data := &HTTPData{
Domain: "example.test",
Probes: []HTTPProbe{httpsProbe("a:443")},
Extensions: wellKnownData(t, map[string]WellKnownProbe{
"/.well-known/security.txt": {PathProbe: PathProbe{StatusCode: 404}},
}),
}
states := runRule(t, &securityTxtRule{}, data, nil)
mustStatus(t, states, sdk.StatusInfo)
if !hasCode(states, "http.security_txt.missing") {
t.Errorf("expected missing, got %+v", states)
}
if states[0].Meta["fix"] == nil {
t.Errorf("expected fix hint in meta, got %+v", states[0].Meta)
}
}
func TestCountSecurityTxtFields(t *testing.T) {
body := "# comment: not a Contact\n" +
"\n" +
"Contact: mailto:security@example.test\n" +
"contact: https://example.test/security\n" +
" CONTACT : tel:+1-201-555-0123\n" +
"Expires: 2026-12-31T23:59:59z\n" +
"Preferred-Languages: en\n"
contacts, expires := countSecurityTxtFields([]byte(body))
if contacts != 3 {
t.Errorf("contacts = %d, want 3", contacts)
}
if expires != 1 {
t.Errorf("expires = %d, want 1", expires)
}
}
func TestSecurityTxtRule_NoCollectorData(t *testing.T) {
data := &HTTPData{
Domain: "example.test",
Probes: []HTTPProbe{httpsProbe("a:443")},
}
states := runRule(t, &securityTxtRule{}, data, nil)
mustStatus(t, states, sdk.StatusUnknown)
if !hasCode(states, "http.security_txt.no_data") {
t.Errorf("expected no_data, got %+v", states)
}
}
func TestSecurityTxtRule_DecodeError(t *testing.T) {
data := &HTTPData{
Domain: "example.test",
Probes: []HTTPProbe{httpsProbe("a:443")},
Extensions: map[string]json.RawMessage{
ObservationKeyWellKnown: json.RawMessage(`"not an object"`),
},
}
states := runRule(t, &securityTxtRule{}, data, nil)
if states[0].Status != sdk.StatusError || states[0].Code != "http.security_txt.decode_error" {
t.Errorf("expected decode_error, got %+v", states)
}
}