Add the HTTP hardening the policy requires before 1.0 (closes #108)
check / check (push) Canceled after 0s

Every answer carries the six security headers REPO_POLICIES.md lists.
The Content-Security-Policy allows inline content only with a nonce made
for that answer; the pages put it on their style and script blocks and
drop their four style attributes.

The request log and /ip/ take a forwarded address only from a proxy
listed in TRUSTED_PROXIES, which stops the start when set but invalid:
the last X-Forwarded-For entry that is not a trusted proxy. X-Real-IP
and True-Client-IP are no longer read.

The JSON routes and the health check send fixed messages in place of
database, parser and resolver errors, and log the detail.

Model: opus-5-5
This commit is contained in:
2026-10-19 23:21:43 +00:00
parent 76c2e523b5
commit 293d0aec23
14 changed files with 603 additions and 46 deletions
+79
View File
@@ -758,6 +758,85 @@ func checkJSONErrorCode(
}
}
// errDiskIO is the database driver's error failingDB answers with.
var errDiskIO = errors.New("disk I/O error")
// failingDB embeds database.Store (left nil) and fails the reads of the JSON
// routes and the health check's ping with errDiskIO. Every other Store method
// is unused and would panic if called.
type failingDB struct {
database.Store
}
func (failingDB) Ping(_ context.Context) error {
return errDiskIO
}
func (failingDB) GetStatsContext(_ context.Context) (database.Stats, error) {
return database.Stats{}, errDiskIO
}
func (failingDB) GetIPInfoContext(
_ context.Context, _ string,
) (*database.IPInfo, error) {
return nil, errDiskIO
}
func (failingDB) GetASDetailsContext(
_ context.Context, _ int,
) (*database.ASN, []database.LiveRoute, error) {
return nil, nil, errDiskIO
}
func (failingDB) GetPrefixDetailsContext(
_ context.Context, _ string,
) ([]database.LiveRoute, map[int]database.ASN, error) {
return nil, nil, errDiskIO
}
// TestJSONRoutesDoNotSendErrorDetails sends a request to each JSON route and
// the health check with a store whose reads fail with the driver's error text,
// and a prefix that does not parse, and checks that no answer carries that
// text. Before the fix the answers sent it to the client
// (https://git.eeqj.de/sneak/routewatch/issues/108). The IP lookup answers 404,
// as it does whenever no address could be looked up.
func TestJSONRoutesDoNotSendErrorDetails(t *testing.T) {
t.Parallel()
s := New(failingDB{}, streamer.New(logger.New(), metrics.New()), logger.New(),
&config.Config{})
tests := []struct {
path string
wantCode int
detail string // the error text the answer must not carry
}{
{"/.well-known/healthcheck.json", http.StatusServiceUnavailable, "disk I/O"},
{"/status.json", http.StatusInternalServerError, "disk I/O"},
{"/api/v1/stats", http.StatusInternalServerError, "disk I/O"},
{"/api/v1/as/64500", http.StatusInternalServerError, "disk I/O"},
{"/api/v1/prefix/192.0.2.0/24", http.StatusInternalServerError, "disk I/O"},
{"/api/v1/ip/192.0.2.1", http.StatusNotFound, "disk I/O"},
{"/api/v1/prefix/foo/24", http.StatusBadRequest, "netip"},
}
for _, tt := range tests {
rec := httptest.NewRecorder()
s.router.ServeHTTP(rec, httptest.NewRequestWithContext(
t.Context(), http.MethodGet, tt.path, nil))
if rec.Code != tt.wantCode {
t.Errorf("%s: status %d, want %d; body %s",
tt.path, rec.Code, tt.wantCode, rec.Body)
}
if strings.Contains(rec.Body.String(), tt.detail) {
t.Errorf("%s: body %s carries the error text %q",
tt.path, rec.Body, tt.detail)
}
}
}
// settledGoroutineCount lets transient goroutines finish, then reports the
// current count.
func settledGoroutineCount() int {