| 1 | import { Hono } from "hono"; |
| 2 | import type { AppVars } from "../auth/principal.js"; |
| 3 | import type { Env } from "../env.js"; |
| 4 | |
| 5 | /** |
| 6 | * /.well-known/security.txt (RFC 9116): where to report a vulnerability in this instance. The |
| 7 | * first contact is always the instance's own private report form, /security/report |
| 8 | * (web/security-reports.tsx), so the file is served without an email address; any entries of |
| 9 | * SECURITY_CONTACT follow it. Expires is a year from the request, so the file never goes stale. |
| 10 | */ |
| 11 | |
| 12 | /** How far ahead Expires is (RFC 9116 recommends less than a year). */ |
| 13 | export const EXPIRES_DAYS = 360; |
| 14 | |
| 15 | /** One Contact URI per entry of SECURITY_CONTACT: email addresses become mailto: URIs; https URLs stay as they are. */ |
| 16 | export function contactUris(v: string | undefined): string[] { |
| 17 | const out: string[] = []; |
| 18 | for (const raw of (v ?? "").split(",")) { |
| 19 | const s = raw.trim(); |
| 20 | if (!s) continue; |
| 21 | if (/^mailto:[^\s@]+@[^\s@]+$/i.test(s) || /^https:\/\/\S+$/i.test(s) || /^tel:\+?[\d-]+$/i.test(s)) out.push(s); |
| 22 | else if (/^[^\s@:/]+@[^\s@/]+\.[^\s@/]+$/.test(s)) out.push(`mailto:${s}`); |
| 23 | } |
| 24 | return out; |
| 25 | } |
| 26 | |
| 27 | /** The private report form's URL: the first Contact. */ |
| 28 | export const reportUrl = (publicUrl: string): string => `${new URL(publicUrl).origin}/security/report`; |
| 29 | |
| 30 | /** The file's text. */ |
| 31 | export function securityTxt(env: Pick<Env, "PUBLIC_URL" | "SECURITY_CONTACT">, nowMs = Date.now()): string { |
| 32 | const base = new URL(env.PUBLIC_URL).origin; |
| 33 | const form = reportUrl(env.PUBLIC_URL); |
| 34 | const contacts = [form, ...contactUris(env.SECURITY_CONTACT).filter((c) => c !== form)]; |
| 35 | const expires = new Date(nowMs + EXPIRES_DAYS * 86400_000); |
| 36 | expires.setUTCHours(0, 0, 0, 0); |
| 37 | return [ |
| 38 | ...contacts.map((c) => `Contact: ${c}`), |
| 39 | `Expires: ${expires.toISOString().replace(".000Z", "Z")}`, |
| 40 | `Policy: ${base}/docs/security/disclosure`, |
| 41 | "Preferred-Languages: en", |
| 42 | `Canonical: ${base}/.well-known/security.txt`, |
| 43 | "", |
| 44 | ].join("\n"); |
| 45 | } |
| 46 | |
| 47 | export const securityTxtRoutes = new Hono<{ Bindings: Env; Variables: AppVars }>(); |
| 48 | |
| 49 | securityTxtRoutes.get("/.well-known/security.txt", (c) => c.body(securityTxt(c.env), 200, { "content-type": "text/plain; charset=utf-8", "cache-control": "public, max-age=86400" })); |