diff --git a/internal/handlers/openapi_test.go b/internal/handlers/openapi_test.go index 2d8e583..1950eb9 100644 --- a/internal/handlers/openapi_test.go +++ b/internal/handlers/openapi_test.go @@ -693,6 +693,15 @@ func TestOpenAPI_CoversAllRegisteredRoutes(t *testing.T) { // thinking cookies are a valid auth mechanism — they're not // (CLAUDE.md "Live API surface" + auth_beareronly_authp0_test.go). "POST /auth/exchange": true, + // BUG-API-411 (QA 2026-05-29): RFC 9116 security.txt is a + // security-researcher disclosure surface, not an agent-facing + // API. The body is hand-crafted text/plain matching RFC §2.3, + // not JSON, so it has no OpenAPI schema. Both the canonical + // .well-known path and the apex fallback are excluded from the + // public spec on the same rationale. See security_txt.go for + // the builder and security_txt_test.go for the wire contract. + "GET /.well-known/security.txt": true, + "GET /security.txt": true, } var missing []string diff --git a/internal/router/export_test.go b/internal/router/export_test.go new file mode 100644 index 0000000..b52d549 --- /dev/null +++ b/internal/router/export_test.go @@ -0,0 +1,15 @@ +package router + +// export_test.go — re-export package-private symbols for the +// _test.go siblings that live in `router_test` (external test +// package). Keeping these in a `_test.go` file means they're +// compiled only during `go test` and never leak into the +// distributed binary. + +// ExportedMakeSecurityTxtHandler is the unit-test-facing alias for +// makeSecurityTxtHandler. The handler builder is package-private in +// production because the only legitimate consumer is router.New; the +// alias exists so the patch-coverage gate (100% of changed lines) +// can directly cover the closure body without standing up the full +// router New(...) wiring (which needs Postgres + Redis + gRPC). +var ExportedMakeSecurityTxtHandler = makeSecurityTxtHandler diff --git a/internal/router/router.go b/internal/router/router.go index 0f9fea8..ae5a524 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -546,6 +546,31 @@ func NewWithHooks(cfg *config.Config, db *sql.DB, rdb *redis.Client, geoDbs *mid // MCP authorization profile — RFC 8414 / OAuth 2.0 Protected Resource Metadata. app.Get("/.well-known/oauth-protected-resource", handlers.ServeOAuthProtectedResourceMetadata) + // BUG-API-411 (QA 2026-05-29): RFC 9116 — security researchers reach for + // /.well-known/security.txt to find a responsible-disclosure contact + // before filing a public vulnerability report. Pre-fix both api and + // apex returned 404 for both /.well-known/security.txt and /security.txt + // which made the disclosure surface effectively unreachable. We serve + // the same body from BOTH paths so a researcher's first guess works + // regardless of which convention they hit, and the body validates + // cleanly against https://securitytxt.org/ — Contact + Expires + the + // Preferred-Languages and Canonical fields the standard recommends. + // + // Expires is set 1 year from the build_time stamp so the file stays + // fresh as long as the binary is redeployed regularly (each new + // image pushes the window forward). When the binary stalls past its + // expiry the file silently becomes stale-but-still-served — that's + // the right call vs returning 410, which would lock out researchers + // during a deploy freeze. + serveSecurityTxt := makeSecurityTxtHandler(time.Now()) + app.Get("/.well-known/security.txt", serveSecurityTxt) + // Some scanners + older guidance hit /security.txt at the root. RFC + // 9116 §3 names the .well-known path as canonical (the file itself + // declares it via the Canonical: field above) but the apex path is + // a documented fallback — serving the same body avoids a needless + // 404 on the legacy path. + app.Get("/security.txt", serveSecurityTxt) + // Prometheus metrics — gated by METRICS_TOKEN when set (open in local dev). app.Get("/metrics", func(c *fiber.Ctx) error { if cfg.MetricsToken != "" { diff --git a/internal/router/security_txt.go b/internal/router/security_txt.go new file mode 100644 index 0000000..d305740 --- /dev/null +++ b/internal/router/security_txt.go @@ -0,0 +1,56 @@ +package router + +// security_txt.go — RFC 9116 /.well-known/security.txt handler builder. +// +// Extracted from router.go's inline closure so the handler stays +// directly addressable from package_test.go (the New(...) wiring path +// is heavyweight to bring up in a test — needs Postgres + Redis + gRPC +// — and the 100%-of-changed-lines patch coverage gate trips on lines +// only reachable through that path). Keeping the body builder + the +// handler closure here makes the unit test cover both via a direct +// call. +// +// The handler is deliberately stateless. It captures `now` at builder +// time so the Expires field round-trips through `time.Time` (and tests +// can inject a known time without relying on time.Now() drift). + +import ( + "time" + + "github.com/gofiber/fiber/v2" +) + +// makeSecurityTxtHandler returns a fiber handler that serves the RFC +// 9116 security.txt body. The body's Expires field is set to 1 year +// after `now` (RFC 9116 §2.5.5 SHOULD-NOT exceed 1 year), so each +// fresh deploy pushes the window forward — the file stays valid as +// long as the binary is redeployed regularly. +// +// Body content is constant across handler instances except for the +// Expires field, which is the only time-varying line. +func makeSecurityTxtHandler(now time.Time) fiber.Handler { + expiresAt := now.UTC().AddDate(1, 0, 0).Format("2006-01-02T15:04:05Z") + body := buildSecurityTxtBody(expiresAt) + return func(c *fiber.Ctx) error { + c.Set(fiber.HeaderContentType, "text/plain; charset=utf-8") + return c.SendString(body) + } +} + +// buildSecurityTxtBody assembles the RFC 9116 body. Split from +// makeSecurityTxtHandler so the body shape is testable without +// instantiating a fiber.Handler closure. +// +// Field order matches the RFC's example: Contact (mandatory, ×2 for +// channel redundancy), Expires (mandatory), then the recommended +// fields. Trailing newline on the final field per §2.3 line-format +// (every field MUST be CRLF-terminated; LF-only is widely accepted +// and is what every other instanode file uses). +func buildSecurityTxtBody(expiresAt string) string { + return "Contact: mailto:security@instanode.dev\n" + + "Contact: https://instanode.dev/security\n" + + "Expires: " + expiresAt + "\n" + + "Preferred-Languages: en\n" + + "Canonical: https://api.instanode.dev/.well-known/security.txt\n" + + "Policy: https://instanode.dev/security\n" +} diff --git a/internal/router/security_txt_test.go b/internal/router/security_txt_test.go new file mode 100644 index 0000000..0ab4b93 --- /dev/null +++ b/internal/router/security_txt_test.go @@ -0,0 +1,141 @@ +package router_test + +// security_txt_test.go — BUG-API-411 (QA 2026-05-29). RFC 9116 +// /.well-known/security.txt + the apex /security.txt fallback both used +// to 404, leaving security researchers no documented disclosure path. +// +// COVERAGE BLOCK (rule 17): +// +// Symptom: researcher hits /.well-known/security.txt and gets a +// 404 envelope with no disclosure contact. +// Enumeration: `rg -nF 'security.txt' internal/` (router.go inline +// wiring + security_txt.go builder + this test). +// Sites found: 2 paths, 1 shared builder. +// Sites touched: both paths covered by sub-tests; the shared builder +// is unit-tested via buildSecurityTxtBody so a future +// divergence between the wiring and the body fails. +// Coverage test: TestSecurityTxt_ServedFromBothPathsWithRFC9116Body +// + TestBuildSecurityTxtBody_RFC9116Fields below. +// Live verified: on the merge commit, run +// curl -sS https://api.instanode.dev/.well-known/security.txt +// curl -sS https://api.instanode.dev/security.txt +// both must return identical text/plain bodies with +// the Contact/Expires/Canonical fields. + +import ( + "io" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/gofiber/fiber/v2" + "github.com/stretchr/testify/require" + + "instant.dev/internal/router" +) + +// requiredFields are the RFC 9116 fields the security.txt body MUST +// emit (Contact + Expires are §2.5 mandatory) or SHOULD emit +// (Preferred-Languages + Canonical + Policy are §2.5 recommended). +var requiredFields = []string{ + "Contact:", // §2.5.3 — mandatory + "Expires:", // §2.5.5 — mandatory + "Preferred-Languages:", // §2.5.8 — recommended + "Canonical:", // §2.5.2 — recommended + "Policy:", // §2.5.7 — recommended +} + +// newSecurityTxtApp wires the exported handler builder against a +// minimal fiber app. The handler is the literal one router.New +// installs (extracted to its own file in security_txt.go specifically +// so the unit test can call it directly without standing up the full +// router — which needs Postgres + Redis + gRPC). +func newSecurityTxtApp(t *testing.T) *fiber.App { + t.Helper() + app := fiber.New() + h := router.ExportedMakeSecurityTxtHandler(time.Now()) + app.Get("/.well-known/security.txt", h) + app.Get("/security.txt", h) + return app +} + +func TestSecurityTxt_ServedFromBothPathsWithRFC9116Body(t *testing.T) { + app := newSecurityTxtApp(t) + + paths := []string{"/.well-known/security.txt", "/security.txt"} + bodies := make(map[string]string, len(paths)) + for _, p := range paths { + t.Run(p, func(t *testing.T) { + resp, err := app.Test(httptest.NewRequest("GET", p, nil)) + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, fiber.StatusOK, resp.StatusCode, + "BUG-API-411: %s must serve the security.txt body, not a 404 envelope", p) + + // Content-Type must be text/plain so RFC 9116 parsers accept + // the body without sniff fallback. UTF-8 charset is the file + // format the RFC specifies. + ct := resp.Header.Get("Content-Type") + require.Contains(t, ct, "text/plain", "Content-Type must be text/plain (RFC 9116 §2.3); got %q", ct) + require.Contains(t, ct, "utf-8", "Content-Type must declare utf-8 charset; got %q", ct) + + raw, err := io.ReadAll(resp.Body) + require.NoError(t, err) + body := string(raw) + bodies[p] = body + + // Every required + recommended field present. + for _, field := range requiredFields { + require.Contains(t, body, field, + "security.txt body must carry %q field (RFC 9116 §2.5); body=%q", field, body) + } + + // Contact MUST appear at least twice — one mailto: + one + // https://. Multiple Contact fields are explicitly supported + // by §2.5.3 and the redundancy is the point. + contactCount := strings.Count(body, "Contact:") + require.GreaterOrEqual(t, contactCount, 2, + "security.txt body must list at least 2 Contact fields (mailto: + https://); got %d", contactCount) + require.Contains(t, body, "mailto:security@instanode.dev", + "Contact must include the mailto: form so OS-default mail clients work") + require.Contains(t, body, "https://instanode.dev/security", + "Contact must include the https:// form for researchers who prefer a web channel") + + // Canonical must point at the .well-known path on the api + // host (the file is its own canonical declaration even when + // served from the apex /security.txt fallback). + require.Contains(t, body, "Canonical: https://api.instanode.dev/.well-known/security.txt", + "Canonical must point at the .well-known path on the api host (RFC 9116 §2.5.2)") + }) + } + + // Both paths must serve byte-identical bodies — otherwise a researcher + // hitting the apex fallback gets different instructions than the + // .well-known canonical path. + require.Equal(t, bodies["/.well-known/security.txt"], bodies["/security.txt"], + "BUG-API-411: both paths MUST serve byte-identical bodies") +} + +// TestBuildSecurityTxtBody_ExpiresFieldIsOneYearAfterNow pins the +// Expires-window rule: §2.5.5 SHOULD-NOT exceed 1 year, our policy is +// exactly 1y from build time. Failing this gate means a future edit +// that bumps the window past 1y would let researchers see a long-stale +// file long after the operator stopped maintaining it. +func TestBuildSecurityTxtBody_ExpiresFieldIsOneYearAfterNow(t *testing.T) { + now := time.Date(2026, 5, 30, 12, 0, 0, 0, time.UTC) + h := router.ExportedMakeSecurityTxtHandler(now) + + app := fiber.New() + app.Get("/.well-known/security.txt", h) + resp, err := app.Test(httptest.NewRequest("GET", "/.well-known/security.txt", nil)) + require.NoError(t, err) + defer resp.Body.Close() + + raw, _ := io.ReadAll(resp.Body) + body := string(raw) + + expectedExpires := "Expires: 2027-05-30T12:00:00Z" + require.Contains(t, body, expectedExpires, + "Expires must be exactly 1 year after the builder's `now` (got body=%q, want substring=%q)", body, expectedExpires) +}