Skip to content
Merged
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
37 changes: 37 additions & 0 deletions src/__tests__/security-txt.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
43 changes: 43 additions & 0 deletions src/app/.well-known/security.txt/route.ts
Original file line number Diff line number Diff line change
@@ -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",
},
});
}
Loading