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

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

mason/forge:mason/8355-security-txt-s-contact-is-a-place-to-rep → main 3 commits fork 2 threads · 2 unresolved

  • Merged. mason/8355-security-txt-s-contact-is-a-place-to-rep was merged into main as cdc2593.

Review

Sign in to review this change.
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.
77
88 | path | what it is | from |
99 | ------------------------------ | ----------------------------------------------------------------- | ---------------------------------------------------------------------------- |
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` |
1111 | `/.well-known/change-password` | where a password manager sends a person | `/settings/password` |
1212 | `/robots.txt` | crawlers stay out of accounts, search and the API; the sitemap | — |
1313 | `/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 @@
77 */
88
99 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";
1112 import { parseReleaseSubject, type ReleasePull } from "../core/release_subject.ts";
1213 import { runGit } from "../git/env.ts";
1314 import { commitLog } from "../git/read.ts";
@@ -40,13 +41,6 @@ export async function readChangelog(dir: string, branch: string): Promise<Releas
4041 return entries;
4142 }
4243
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
5044 /** What the page and the terminal both show: the repository and its newest releases. */
outdated

Exporting from the changelog module makes web/standards depend on release/changelog. Move this lookup to src/core and import from there in both.

5145 export async function forgeChangelog(
5246 app: App,
src/web/pages/issues.ts +29 −16
@@ -134,14 +134,14 @@ export function newIssuePage(ctx: Ctx): Response {
134134 notePhase(request, "session", takeSessionTime(request));
135135 const repo = timePhase(request, "repo", () => loadRepo(ctx));
136136 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";
137139 if (!ctx.auth.user) {
138140 const open = timePhase(request, "access", () => canRead(ctx.app.db, repo, undefined));
139141 return timePhase(request, "render", () =>
140 open ? accountOrReport(ctx, repo) : signInFirst(ctx),
142 open ? accountOrReport(ctx, repo, sealed) : signInFirst(ctx),
141143 );
142144 }
143 // `?sealed=1`: the repository page's "report it sealed" link.
144 const sealed = ctx.url.searchParams.get("sealed") === "1";
145145 return timePhase(request, "render", () =>
146146 composePage(ctx, repo, { title: "", body: "", sealed }),
147147 );
@@ -150,10 +150,32 @@ export function newIssuePage(ctx: Ctx): Response {
150150 /**
151151 * A stranger who asked to open an issue on a public repository: both doors,
152152 * 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.
154156 */
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 });
157179 return page({
158180 ctx,
159181 title: `New issue · ${repo.ownerName}/${repo.name}`,
@@ -162,16 +184,7 @@ function accountOrReport(ctx: Ctx, repo: Repo): Response {
162184 compact: true,
163185 ownTitle: true,
164186 })}
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}`,
175188 });
176189 }
177190
src/web/standards.ts +11 −6
@@ -33,7 +33,8 @@ import { readLandings } from "../release/landings.ts";
3333 import { REPORT_LINK_TITLE } from "../actions/report_words.ts";
3434 import { sshTellCommand } from "../ssh/command.ts";
3535 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";
3738 import type { Ctx } from "../http/context.ts";
3839 import { notFound } from "../http/respond.ts";
3940 import { SITE_LINE } from "./site_line.ts";
@@ -54,13 +55,17 @@ function text(body: string, type = "text/plain; charset=utf-8"): Response {
5455 /**
5556 * Where a vulnerability report goes. An address is published only when the
5657 * 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.
6063 */
61 function securityContact(ctx: Ctx): string {
64 function securityContact(ctx: Ctx, own: Repo | undefined): string {
6265 const configured = process.env["FORGE_SECURITY_CONTACT"]?.trim();
6366 if (configured) return configured;
67 if (own !== undefined)
68 return `${ctx.url.origin}/${own.ownerName}/${own.name}/issues/new?sealed=1`;
6469 const admin = ctx.app.db
6570 .query<
6671 { username: string },
@@ -81,7 +86,7 @@ export function securityTxt(ctx: Ctx): Response {
8186 const expires = new Date(Date.UTC(now.getUTCFullYear() + 1, 0, 1));
8287 return text(
8388 [
84 `Contact: ${securityContact(ctx)}`,
89 `Contact: ${securityContact(ctx, publicForgeRepo(ctx.app))}`,
8590 `Expires: ${expires.toISOString().replace(/\.\d{3}Z$/, "Z")}`,
8691 "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.

8792 `Canonical: ${ctx.url.origin}/.well-known/security.txt`,
test/standards.test.ts +56
@@ -84,6 +84,62 @@ describe("what the open web expects", () => {
8484 }
8585 });
8686
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
87143 test("change-password sends a password manager to the right page", async () => {
88144 const res = await fetch(`${base}/.well-known/change-password`, {
89145 redirect: "manual",

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs