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:
parent
086d3e151d
commit
ffa3fbe1f9
4 changed files with 281 additions and 22 deletions
|
|
@ -6,6 +6,7 @@ package checker
|
|||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
|
|
@ -25,8 +26,12 @@ func TestSecurityTxtRule_OK(t *testing.T) {
|
|||
Domain: "example.test",
|
||||
Probes: []HTTPProbe{httpsProbe("a:443")},
|
||||
Extensions: wellKnownData(t, map[string]WellKnownProbe{
|
||||
"/.well-known/security.txt": {StatusCode: 200, Bytes: 128},
|
||||
"/robots.txt": {StatusCode: 200, Bytes: 42},
|
||||
"/.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)
|
||||
|
|
@ -41,7 +46,7 @@ func TestSecurityTxtRule_Empty(t *testing.T) {
|
|||
Domain: "example.test",
|
||||
Probes: []HTTPProbe{httpsProbe("a:443")},
|
||||
Extensions: wellKnownData(t, map[string]WellKnownProbe{
|
||||
"/.well-known/security.txt": {StatusCode: 200, Bytes: 0},
|
||||
"/.well-known/security.txt": {PathProbe: PathProbe{StatusCode: 200, Bytes: 0}},
|
||||
}),
|
||||
}
|
||||
states := runRule(t, &securityTxtRule{}, data, nil)
|
||||
|
|
@ -51,12 +56,123 @@ func TestSecurityTxtRule_Empty(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
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": {StatusCode: 404},
|
||||
"/.well-known/security.txt": {PathProbe: PathProbe{StatusCode: 404}},
|
||||
}),
|
||||
}
|
||||
states := runRule(t, &securityTxtRule{}, data, nil)
|
||||
|
|
@ -69,6 +185,23 @@ func TestSecurityTxtRule_Missing(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue