From e7f965356c5dff2b60374b71c9c50cf915e5dfa9 Mon Sep 17 00:00:00 2001 From: Bryan Fawcett Date: Wed, 12 Aug 2026 06:53:20 +0000 Subject: [PATCH] feat(security): serve /.well-known/security.txt (RFC 9116) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cloudflare's security-insights scan flags nhimbe.com under "Security.txt not configured" — there was no vulnerability-disclosure contact at a machine-discoverable location, so a researcher's only route was guessing an address. Served as a route handler rather than a static file in public/ because RFC 9116 makes Expires mandatory and caps it at a year ahead: a checked-in file becomes non-compliant purely by ageing. Deriving it per request (180 days out) means it cannot expire. Lists both production origins under Canonical, since either host serves this app and a mismatch is grounds for a scanner to distrust the file. Points at the existing SECURITY.md for policy and at security@nyuchi.com, matching what SECURITY.md already tells reporters. Tests assert the required fields, both canonicals, that Expires is in the future and inside the one-year maximum, and that there is exactly one Expires line. --- src/__tests__/security-txt.test.ts | 37 +++++++++++++++++++ src/app/.well-known/security.txt/route.ts | 43 +++++++++++++++++++++++ 2 files changed, 80 insertions(+) create mode 100644 src/__tests__/security-txt.test.ts create mode 100644 src/app/.well-known/security.txt/route.ts diff --git a/src/__tests__/security-txt.test.ts b/src/__tests__/security-txt.test.ts new file mode 100644 index 00000000..0ee95c91 --- /dev/null +++ b/src/__tests__/security-txt.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; +import { buildSecurityTxt } from "@/app/.well-known/security.txt/route"; + +// Cloudflare's security-insights scan flags nhimbe.com for "Security.txt not +// configured". RFC 9116 makes `Expires` mandatory and caps it at a year out, +// which is why this is generated per request rather than checked in — these +// assertions are what stop it regressing to a file that silently expires. + +describe("security.txt", () => { + const txt = buildSecurityTxt(); + + it("carries the fields RFC 9116 requires", () => { + expect(txt).toContain("Contact: mailto:security@nyuchi.com"); + expect(txt).toMatch(/^Expires: /m); + expect(txt).toContain("Policy: https://github.com/nyuchi/nhimbe/blob/main/SECURITY.md"); + expect(txt).toContain("Preferred-Languages: en"); + }); + + it("lists both origins this app is served from", () => { + // Either host can serve the file, and RFC 9116 wants every URI it is + // reachable at listed — a scanner may distrust a mismatch. + expect(txt).toContain("Canonical: https://events.mukoko.com/.well-known/security.txt"); + expect(txt).toContain("Canonical: https://nhimbe.com/.well-known/security.txt"); + }); + + it("expires in the future and within RFC 9116's one-year maximum", () => { + const now = new Date("2030-06-15T12:00:00Z"); + const expires = new Date(buildSecurityTxt(now).match(/^Expires: (.+)$/m)![1]); + expect(expires.getTime()).toBeGreaterThan(now.getTime()); + const oneYear = new Date(now.getTime() + 365 * 24 * 60 * 60 * 1000); + expect(expires.getTime()).toBeLessThan(oneYear.getTime()); + }); + + it("is a single Expires field — duplicates make the file invalid", () => { + expect(txt.match(/^Expires: /gm)).toHaveLength(1); + }); +}); diff --git a/src/app/.well-known/security.txt/route.ts b/src/app/.well-known/security.txt/route.ts new file mode 100644 index 00000000..7175e339 --- /dev/null +++ b/src/app/.well-known/security.txt/route.ts @@ -0,0 +1,43 @@ +// /.well-known/security.txt — RFC 9116 vulnerability-disclosure contact. +// +// A route handler rather than a static file in `public/` on purpose: RFC 9116 +// makes `Expires` mandatory and requires it to be less than a year out, so a +// checked-in file quietly becomes non-compliant the moment it ages past that. +// Deriving it per request means it can never expire. + +import { SITE_URL } from "@/lib/site-url"; + +export const dynamic = "force-dynamic"; + +/** Days ahead to set `Expires`. Well inside RFC 9116's one-year maximum. */ +const EXPIRY_DAYS = 180; + +export function buildSecurityTxt(now: Date = new Date()): string { + const expires = new Date(now.getTime() + EXPIRY_DAYS * 24 * 60 * 60 * 1000); + // Both production origins serve this app, and RFC 9116 §2.5.5 wants every + // URI the file is reachable at listed — a mismatch is grounds for a scanner + // to distrust it. + const canonicals = [SITE_URL, "https://nhimbe.com"] + .filter((origin, i, all) => all.indexOf(origin) === i) + .map((origin) => `Canonical: ${origin}/.well-known/security.txt`) + .join("\n"); + + return `# Nhimbe — security contact (RFC 9116) +# Please report vulnerabilities privately; do not open a public GitHub issue. + +Contact: mailto:security@nyuchi.com +Expires: ${expires.toISOString()} +Preferred-Languages: en +${canonicals} +Policy: https://github.com/nyuchi/nhimbe/blob/main/SECURITY.md +`; +} + +export async function GET() { + return new Response(buildSecurityTxt(), { + headers: { + "Content-Type": "text/plain; charset=utf-8", + "Cache-Control": "public, max-age=3600", + }, + }); +}