Public: anyone can read this; the crew can change it.
security.txt's Contact is a place to report, not a profile: it opens the sealed report form on the forge's own repository #8369
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/8355-security-txt-s-contact-is-a-place-to-rep → main 3 commits fork 2 threads · 2 unresolved
7 files
changed +116
−31
docs/STANDARDS.md +1 −1
| @@ -7,7 +7,7 @@ its own mark. Nothing here is configured, and nothing here is optional. | ||
| 7 | 7 | |
| 8 | 8 | | path | what it is | from | |
| 9 | 9 | | ------------------------------ | ----------------------------------------------------------------- | ---------------------------------------------------------------------------- | |
| 10 | | `/.well-known/security.txt` | who to tell about a vulnerability (RFC 9116), with a fixed expiry | the first account's page here; an address only from `FORGE_SECURITY_CONTACT` | | |
| 10 | | `/.well-known/security.txt` | who to tell about a vulnerability (RFC 9116), with a fixed expiry | the sealed new-issue form of `FORGE_REPO` when it is public, else the first account's page here; an address only from `FORGE_SECURITY_CONTACT` | | |
| 11 | 11 | | `/.well-known/change-password` | where a password manager sends a person | `/settings/password` | |
| 12 | 12 | | `/robots.txt` | crawlers stay out of accounts, search and the API; the sitemap | — | |
| 13 | 13 | | `/sitemap.xml` | the front page and every public repository, never a private one | `listReposVisibleTo(db, nobody)` | |
docs/features/security-contact.md added +4
| @@ -0,0 +1,4 @@ | ||
| 1 | # Security contact | |
| 2 | ||
| 3 | `/.well-known/security.txt`'s `Contact:` is the forge's own repository's sealed report form (`/<FORGE_REPO>/issues/new?sealed=1`) when `FORGE_REPO` names a public repository here; a sealed report needs an account, so a signed-out visitor is offered only Sign in, and comes back to the sealed form after it (the no-account report is not sealed and is not offered there). | |
| 4 | Otherwise the contact is the first account's page; `FORGE_SECURITY_CONTACT` overrides both (`src/web/standards.ts`, `src/core/forge_repo.ts`). |
src/core/forge_repo.ts added +13
| @@ -0,0 +1,13 @@ | ||
| 1 | /** | |
| 2 | * The forge's own repository (`FORGE_REPO`), when it is public: the one the | |
| 3 | * changelog reads and the one security.txt sends a stranger to. | |
| 4 | */ | |
| 5 | ||
| 6 | import type { App } from "../app.ts"; | |
| 7 | import { getRepo, type Repo } from "./repos.ts"; | |
| 8 | ||
| 9 | export function publicForgeRepo(app: App): Repo | undefined { | |
| 10 | const [owner = "", name = ""] = (app.config.forgeRepo ?? "").split("/"); | |
| 11 | const repo = owner === "" || name === "" ? undefined : getRepo(app.db, owner, name); | |
| 12 | return repo !== undefined && repo.visibility === "public" ? repo : undefined; | |
| 13 | } |
src/release/changelog.ts +2 −8
| @@ -7,7 +7,8 @@ | ||
| 7 | 7 | */ |
| 8 | 8 | |
| 9 | 9 | import type { App } from "../app.ts"; |
| 10 | import { getRepo, type Repo } from "../core/repos.ts"; | |
| 10 | import { publicForgeRepo } from "../core/forge_repo.ts"; | |
| 11 | import type { Repo } from "../core/repos.ts"; | |
| 11 | 12 | import { parseReleaseSubject, type ReleasePull } from "../core/release_subject.ts"; |
| 12 | 13 | import { runGit } from "../git/env.ts"; |
| 13 | 14 | import { commitLog } from "../git/read.ts"; |
| @@ -40,13 +41,6 @@ export async function readChangelog(dir: string, branch: string): Promise<Releas | ||
| 40 | 41 | return entries; |
| 41 | 42 | } |
| 42 | 43 | |
| 43 | /** The forge's own repository, when it is public: the only one the changelog reads. */ | |
| 44 | function publicForgeRepo(app: App): Repo | undefined { | |
| 45 | const [owner = "", name = ""] = (app.config.forgeRepo ?? "").split("/"); | |
| 46 | const repo = owner === "" || name === "" ? undefined : getRepo(app.db, owner, name); | |
| 47 | return repo !== undefined && repo.visibility === "public" ? repo : undefined; | |
| 48 | } | |
| 49 | ||
| 50 | 44 | /** What the page and the terminal both show: the repository and its newest releases. */ |
| 51 | 45 | export async function forgeChangelog( |
| 52 | 46 | app: App, |
src/web/pages/issues.ts +29 −16
| @@ -134,14 +134,14 @@ export function newIssuePage(ctx: Ctx): Response { | ||
| 134 | 134 | notePhase(request, "session", takeSessionTime(request)); |
| 135 | 135 | const repo = timePhase(request, "repo", () => loadRepo(ctx)); |
| 136 | 136 | if (!repo) return timePhase(request, "render", () => notFoundPage(ctx)); |
| 137 | // `?sealed=1`: the repository page's "report it sealed" link. | |
| 138 | const sealed = ctx.url.searchParams.get("sealed") === "1"; | |
| 137 | 139 | if (!ctx.auth.user) { |
| 138 | 140 | const open = timePhase(request, "access", () => canRead(ctx.app.db, repo, undefined)); |
| 139 | 141 | return timePhase(request, "render", () => |
| 140 | open ? accountOrReport(ctx, repo) : signInFirst(ctx), | |
| 142 | open ? accountOrReport(ctx, repo, sealed) : signInFirst(ctx), | |
| 141 | 143 | ); |
| 142 | 144 | } |
| 143 | // `?sealed=1`: the repository page's "report it sealed" link. | |
| 144 | const sealed = ctx.url.searchParams.get("sealed") === "1"; | |
| 145 | 145 | return timePhase(request, "render", () => |
| 146 | 146 | composePage(ctx, repo, { title: "", body: "", sealed }), |
| 147 | 147 | ); |
| @@ -150,10 +150,32 @@ export function newIssuePage(ctx: Ctx): Response { | ||
| 150 | 150 | /** |
| 151 | 151 | * A stranger who asked to open an issue on a public repository: both doors, |
| 152 | 152 | * the one that needs an account and the one that does not, rather than a |
| 153 | * sign-in page that never says the second exists. | |
| 153 | * sign-in page that never says the second exists. A stranger who asked for | |
| 154 | * a sealed report (security.txt's Contact) is offered only the account: the | |
| 155 | * report without one is not sealed, and there is no sealed door without one. | |
| 154 | 156 | */ |
| 155 | function accountOrReport(ctx: Ctx, repo: Repo): Response { | |
| 156 | const base = repoBase(repo); | |
| 157 | function accountOrReport(ctx: Ctx, repo: Repo, sealed: boolean): Response { | |
| 158 | const signIn = (variant: "primary" | "ghost") => | |
| 159 | btn("Sign in", { href: signInHref(ctx), variant }); | |
| 160 | const door = sealed | |
| 161 | ? empty({ | |
| 162 | icon: "issue", | |
| 163 | title: "A sealed security report needs an account", | |
| 164 | note: | |
| 165 | "Only the people who keep this repository see it until its fix is released. " + | |
| 166 | "The report without an account is not sealed: do not use it for a vulnerability.", | |
| 167 | action: signIn("primary"), | |
| 168 | }) | |
| 169 | : empty({ | |
| 170 | icon: "issue", | |
| 171 | title: "Opening an issue needs an account", | |
| 172 | note: "Something wrong? You can report it without one; a person who keeps this repository reads it.", | |
| 173 | action: html`${btn("Report a problem", { | |
| 174 | href: `${repoBase(repo)}/report`, | |
| 175 | variant: "primary", | |
| 176 | })} | |
| 177 | ${signIn("ghost")}`, | |
| 178 | }); | |
| 157 | 179 | return page({ |
| 158 | 180 | ctx, |
| 159 | 181 | title: `New issue · ${repo.ownerName}/${repo.name}`, |
| @@ -162,16 +184,7 @@ function accountOrReport(ctx: Ctx, repo: Repo): Response { | ||
| 162 | 184 | compact: true, |
| 163 | 185 | ownTitle: true, |
| 164 | 186 | })} |
| 165 | ${empty({ | |
| 166 | icon: "issue", | |
| 167 | title: "Opening an issue needs an account", | |
| 168 | note: "Something wrong? You can report it without one; a person who keeps this repository reads it.", | |
| 169 | action: html`${btn("Report a problem", { | |
| 170 | href: `${base}/report`, | |
| 171 | variant: "primary", | |
| 172 | })} | |
| 173 | ${btn("Sign in", { href: signInHref(ctx), variant: "ghost" })}`, | |
| 174 | })}`, | |
| 187 | ${door}`, | |
| 175 | 188 | }); |
| 176 | 189 | } |
| 177 | 190 |
src/web/standards.ts +11 −6
| @@ -33,7 +33,8 @@ import { readLandings } from "../release/landings.ts"; | ||
| 33 | 33 | import { REPORT_LINK_TITLE } from "../actions/report_words.ts"; |
| 34 | 34 | import { sshTellCommand } from "../ssh/command.ts"; |
| 35 | 35 | import { llmsRepos, sitemapRepos, type LlmsListing } from "../core/listings.ts"; |
| 36 | import { canRead, getRepo } from "../core/repos.ts"; | |
| 36 | import { canRead, getRepo, type Repo } from "../core/repos.ts"; | |
| 37 | import { publicForgeRepo } from "../core/forge_repo.ts"; | |
| 37 | 38 | import type { Ctx } from "../http/context.ts"; |
| 38 | 39 | import { notFound } from "../http/respond.ts"; |
| 39 | 40 | import { SITE_LINE } from "./site_line.ts"; |
| @@ -54,13 +55,17 @@ function text(body: string, type = "text/plain; charset=utf-8"): Response { | ||
| 54 | 55 | /** |
| 55 | 56 | * Where a vulnerability report goes. An address is published only when the |
| 56 | 57 | * operator wrote one down for the purpose: a default must never put a |
| 57 | * person's email on the open web. Without one, the contact is the first | |
| 58 | * account's page on this forge — already public, and a real way to reach | |
| 59 | * whoever runs it. | |
| 58 | * person's email on the open web. Without one, the contact is the sealed | |
| 59 | * new-issue form of the forge's own repository (the one the changelog | |
| 60 | * reads), a private place to send it; failing that, the first account's | |
| 61 | * page on this forge — already public, and a real way to reach whoever | |
| 62 | * runs it. | |
| 60 | 63 | */ |
| 61 | function securityContact(ctx: Ctx): string { | |
| 64 | function securityContact(ctx: Ctx, own: Repo | undefined): string { | |
| 62 | 65 | const configured = process.env["FORGE_SECURITY_CONTACT"]?.trim(); |
| 63 | 66 | if (configured) return configured; |
| 67 | if (own !== undefined) | |
| 68 | return `${ctx.url.origin}/${own.ownerName}/${own.name}/issues/new?sealed=1`; | |
| 64 | 69 | const admin = ctx.app.db |
| 65 | 70 | .query< |
| 66 | 71 | { username: string }, |
| @@ -81,7 +86,7 @@ export function securityTxt(ctx: Ctx): Response { | ||
| 81 | 86 | const expires = new Date(Date.UTC(now.getUTCFullYear() + 1, 0, 1)); |
| 82 | 87 | return text( |
| 83 | 88 | [ |
| 84 | `Contact: ${securityContact(ctx)}`, | |
| 89 | `Contact: ${securityContact(ctx, publicForgeRepo(ctx.app))}`, | |
| 85 | 90 | `Expires: ${expires.toISOString().replace(/\.\d{3}Z$/, "Z")}`, |
| 86 | 91 | "Preferred-Languages: en", |
outdated
Advertises the public /report route next to a security Contact; /report is not sealed (see the footer: "Not a vulnerability? …"). Drop it or label it non-security only. |
||
| 87 | 92 | `Canonical: ${ctx.url.origin}/.well-known/security.txt`, |
test/standards.test.ts +56
| @@ -84,6 +84,62 @@ describe("what the open web expects", () => { | ||
| 84 | 84 | } |
| 85 | 85 | }); |
| 86 | 86 | |
| 87 | test("security.txt's contact is the sealed form of the forge's public repository", async () => { | |
| 88 | app.config.forgeRepo = "alice/open"; | |
| 89 | try { | |
| 90 | const body = await fetch(`${base}/.well-known/security.txt`).then((r) => r.text()); | |
| 91 | expect(body).toContain(`Contact: ${base}/alice/open/issues/new?sealed=1\n`); | |
| 92 | // The no-account report is not sealed: it is never offered beside | |
| 93 | // the contact, and the footer names it for what is not a vulnerability. | |
| 94 | expect(body).not.toContain(`${base}/alice/open/report`); | |
| 95 | expect(body.indexOf("/report")).toBeGreaterThan(body.indexOf("Not a vulnerability?")); | |
| 96 | // The configured contact still wins. | |
| 97 | process.env["FORGE_SECURITY_CONTACT"] = "mailto:[email protected]"; | |
| 98 | try { | |
| 99 | const set = await fetch(`${base}/.well-known/security.txt`).then((r) => r.text()); | |
| 100 | expect(set).toContain("Contact: mailto:[email protected]\n"); | |
| 101 | expect(set).not.toContain("alice/open"); | |
| 102 | } finally { | |
| 103 | delete process.env["FORGE_SECURITY_CONTACT"]; | |
| 104 | } | |
| 105 | } finally { | |
| 106 | delete app.config.forgeRepo; | |
| 107 | } | |
| 108 | }); | |
| 109 | ||
| 110 | test("the sealed form, signed out, offers the account only and comes back sealed", async () => { | |
| 111 | const form = await fetch(`${base}/alice/open/issues/new?sealed=1`).then((r) => r.text()); | |
| 112 | expect(form).toContain("A sealed security report needs an account"); | |
| 113 | expect(form).not.toContain(">Report a problem</a"); | |
| 114 | // The password form brings the stranger back to the sealed form. | |
| 115 | const next = encodeURIComponent("/alice/open/issues/new?sealed=1"); | |
| 116 | expect(form).toContain(`href="/signin?next=${next}"`); | |
| 117 | const plain = await fetch(`${base}/alice/open/issues/new`).then((r) => r.text()); | |
| 118 | expect(plain).toContain("Opening an issue needs an account"); | |
| 119 | expect(plain).toContain(">Report a problem</a"); | |
| 120 | // Behind a platform edge, the edge's login comes back sealed too. | |
| 121 | app.config.edgeToken = "edge-secret-0123456789"; | |
| 122 | try { | |
| 123 | const edge = await fetch(`${base}/alice/open/issues/new?sealed=1`).then((r) => r.text()); | |
| 124 | expect(edge).toContain(`href="/_plat/login?rd=${next}"`); | |
| 125 | } finally { | |
| 126 | delete app.config.edgeToken; | |
| 127 | } | |
| 128 | }); | |
| 129 | ||
| 130 | test("security.txt names a profile for a private or malformed forge repository", async () => { | |
| 131 | for (const named of ["alice/closed", "alice"]) { | |
| 132 | app.config.forgeRepo = named; | |
| 133 | try { | |
| 134 | const body = await fetch(`${base}/.well-known/security.txt`).then((r) => r.text()); | |
| 135 | expect(body).toContain(`Contact: ${base}/alice\n`); | |
| 136 | expect(body).not.toContain("issues/new?sealed=1\n"); | |
| 137 | } finally { | |
| 138 | delete app.config.forgeRepo; | |
| 139 | } | |
| 140 | } | |
| 141 | }); | |
| 142 | ||
| 87 | 143 | test("change-password sends a password manager to the right page", async () => { |
| 88 | 144 | const res = await fetch(`${base}/.well-known/change-password`, { |
| 89 | 145 | redirect: "manual", |
new above
offline
Exporting from the changelog module makes web/standards depend on release/changelog. Move this lookup to src/core and import from there in both.