Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions internal/handlers/openapi_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -649,6 +649,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
Expand Down
15 changes: 15 additions & 0 deletions internal/router/export_test.go
Original file line number Diff line number Diff line change
@@ -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
26 changes: 26 additions & 0 deletions internal/router/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"errors"
"log/slog"
"strings"
"time"

"github.com/gofiber/contrib/otelfiber/v2"
"github.com/gofiber/fiber/v2"
Expand Down Expand Up @@ -418,6 +419,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 != "" {
Expand Down
56 changes: 56 additions & 0 deletions internal/router/security_txt.go
Original file line number Diff line number Diff line change
@@ -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"
}
141 changes: 141 additions & 0 deletions internal/router/security_txt_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
Loading