Public: anyone can read this; the crew can change it.

Seen anonymously, a private repository's page and its og card say only the title and 'private' #2450

Merged opened this

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

Merged

scout/forge:scout/2447-seen-anonymously-a-private-repository-s → main 2 commits fork 2 threads · 2 unresolved

  • Merged. scout/2447-seen-anonymously-a-private-repository-s was merged into main as b7e0ff1.

Review

Sign in to review this change.
6 files changed +152 −11
src/web/pages/og.ts +32 −6
@@ -3,7 +3,8 @@
33 * and GET /og/audit.svg.
44 * Public by design — it is what a link preview fetches without a session —
55 * so it says only what an anonymous reader may already see: a public repo
6 * (`owner~name`), the public app of one (its slug), or an issue, pull or run
6 * (`owner~name`; any other repo, private or not, is a title and the word
7 * "private", and a signed-in reader who may read it gets the full card), the public app of one (its slug), or an issue, pull or run

This comment line is far past the width of the rest of the header. Re-wrap it.

78 * of a public repo (`owner~name~number`, `owner~name~runid`) that an
89 * anonymous reader could open (not sealed, runs readable); a public repo's
910 * map (`owner~name`) drawn from only the issues and runs it may see; or the
@@ -313,16 +314,38 @@ async function auditCardInput(ctx: Ctx): Promise<CardInput> {
313314 return auditInput(rows);
314315 }
315316
317 /**
318 * A repository's card. The public repo, or one the signed-in viewer may read,
319 * gets the full card; any other well-formed ref gets a card of its title and
320 * the word "private" — the same for a private repo and a name nobody has
321 * taken, so the card cannot be used to find out which exist.
322 */
323 function repoCard(ctx: Ctx, ref: string): CardInput | undefined {
324 const [owner, name, extra] = ref.split("~");
325 if (extra !== undefined || !owner || !name) return undefined;
326 if (!isSafeName(owner) || !isSafeName(name)) return undefined;
327 const repo = getRepo(ctx.app.db, owner, name);
328 if (repo && canRead(ctx.app.db, repo, ctx.auth.user))
329 return repoInput(ctx, repo, "repo");
330 return { kind: "repo", title: `${owner}/${name}`, stat: "private" };
331 }
332
333 /** Whether this response is for the signed-in viewer alone, not for a shared cache. */
334 function personal(ctx: Ctx, kind: OgKind, ref: string): boolean {
335 if (kind !== "repo" || ctx.auth.user === undefined) return false;
336 const [owner, name] = ref.split("~");
337 if (!owner || !name || !isSafeName(owner) || !isSafeName(name)) return false;
338 const repo = getRepo(ctx.app.db, owner, name);
339 return repo !== undefined && repo.visibility !== "public";
340 }
341
316342 async function resolve(ctx: Ctx, kind: OgKind, ref: string): Promise<CardInput | undefined> {
317343 const db = ctx.app.db;
318344 if (kind === "issue" || kind === "pull") return threadInput(ctx, kind, ref);
319345 if (kind === "run") return runInput(ctx, ref);
320346 if (kind === "map") return mapCardInput(ctx, ref);
321347 if (kind === "audit") return ref === "" ? auditCardInput(ctx) : undefined;
322 if (kind === "repo") {
323 const repo = twoPart(ctx, ref);
324 return repo && repoInput(ctx, repo, "repo");
325 }
348 if (kind === "repo") return repoCard(ctx, ref);
326349 if (!isSlug(ref)) return undefined;
327350 const record = getAppBySlug(db, ref);
328351 if (!record || record.access !== "public") return undefined;
@@ -365,7 +388,10 @@ export function ogCard(kind: OgKind, format: "svg" | "png" = "svg") {
365388 const etag = `"${createHash("sha256").update(signature(input)).digest("hex")}${format === "png" ? "-png" : ""}"`;
366389 const headers = {
367390 etag,
368 "cache-control": "public, max-age=300",
391 "cache-control": personal(ctx, kind, ctx.params.ref ?? "")
392 ? "private, max-age=300"
393 : "public, max-age=300",
394 ...(kind === "repo" ? { vary: "cookie" } : {}),
369395 "content-type": format === "png" ? "image/png" : "image/svg+xml; charset=utf-8",
370396 };
371397 if (ctx.request.headers.get("if-none-match") === etag)
src/web/pages/repo.ts +2
@@ -989,6 +989,7 @@ async function codeHome(ctx: Ctx, repo: Repo): Promise<Response> {
989989 ctx,
990990 title: repo.name,
991991 description: repo.description,
992 ogImage: `/og/repo/${repo.ownerName}~${repo.name}.png`,
992993 feed: {
993994 href: `${repoBase(repo)}/activity.atom`,
994995 title: `${repo.ownerName}/${repo.name} activity`,
@@ -1036,6 +1037,7 @@ async function codeHome(ctx: Ctx, repo: Repo): Promise<Response> {
10361037 ctx,
10371038 title: repo.name,
10381039 description: repo.description,
1040 ogImage: `/og/repo/${repo.ownerName}~${repo.name}.png`,
10391041 feed: {
10401042 href: `${repoBase(repo)}/activity.atom`,
10411043 title: `${repo.ownerName}/${repo.name} activity`,
test/og-png.test.ts +2 −2
@@ -72,8 +72,8 @@ describe("og png", () => {
7272 expect(again.status).toBe(304);
7373 });
7474
75 test("private and malformed refs are the same 404", async () => {
76 expect((await fetch(`${base}/og/repo/alice~hidden.png`)).status).toBe(404);
75 test("malformed refs are 404; a private repo's card is only its title and 'private'", async () => {
76 expect((await fetch(`${base}/og/repo/alice~hidden.png`)).status).toBe(200);
7777 expect((await fetch(`${base}/og/repo/..%2F..~x.png`)).status).toBe(404);

The PNG private-card test checks only status 200. Also assert the content-type is image/png and that the body matches the card for an unclaimed name.

7878 });
7979
test/og-private.test.ts added +100
@@ -0,0 +1,100 @@
1 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2 import { mkdtemp, rm } from "node:fs/promises";
3 import { tmpdir } from "node:os";
4 import { join } from "node:path";
5 import { createApp as createForge, type App } from "../src/app.ts";
6 import { createRepo } from "../src/actions/repos.ts";
7 import { createIssue } from "../src/core/issues.ts";
8 import { getRepo } from "../src/core/repos.ts";
9 import { createUser } from "../src/core/users.ts";
10 import { serve, type Servers } from "../src/http/server.ts";
11
12 const SECRET = "zebra-quartz-plan";
13 const PASSWORD = "a-good-passphrase";
14
15 describe("a private repository's page and card, seen anonymously", () => {
16 let dataDir: string;
17 let app: App;
18 let servers: Servers;
19 let cookie = "";
20
21 beforeAll(async () => {
22 dataDir = await mkdtemp(join(tmpdir(), "forge-og-private-"));
23 app = createForge({ dataDir, port: 0 });
24 servers = await serve(app);
25 const u = await createUser(app.db, {
26 trusted: true,
27 username: "alice",
28 email: "[email protected]",
29 password: PASSWORD,
30 adminIfFirst: true,
31 });
32 if (!u.ok) throw new Error(u.error);
33 const made = await createRepo(app, u.value, {
34 name: "vault",
35 visibility: "private",
36 description: `Holds the ${SECRET}`,
37 });
38 if (!made.ok) throw new Error(String(made.error));
39 const issue = createIssue(app.db, {
40 repoId: getRepo(app.db, "alice", "vault")!.id,
41 authorId: u.value.id,
42 title: "An issue",
43 kind: "issue",
44 });
45 if (!issue.ok) throw new Error(String(issue.error));
46 const res = await fetch(`${servers.url}/signin`, {
47 method: "POST",
48 body: new URLSearchParams({ username: "alice", password: PASSWORD }),
49 redirect: "manual",
50 });
51 cookie = (res.headers.get("set-cookie") ?? "").split(";")[0]!;
52 });
53
54 afterAll(async () => {
55 await servers.stop();
56 await rm(dataDir, { recursive: true, force: true });
57 });
58
59 const get = (path: string, headers: Record<string, string> = {}) =>
60 fetch(servers.url + path, { headers, redirect: "manual" });
61
62 test("the page answered anonymously leaks nothing in its head", async () => {
63 const res = await get("/alice/vault");
64 const head = (await res.text()).split("</head>")[0]!;
65 expect(head).not.toContain(SECRET);
66 expect(head).toContain('property="og:title" content="Not found"');
67 expect(head).not.toContain("/og/repo/");
68 });
69
70 test("the card says a title and 'private' and nothing else", async () => {
71 const res = await get("/og/repo/alice~vault.svg");
72 expect(res.status).toBe(200);
73 expect(res.headers.get("cache-control")).toBe("public, max-age=300");
74 const svg = await res.text();
75 expect(svg).toContain("alice/vault");
76 expect(svg).toContain("private");
77 expect(svg).not.toContain(SECRET);
78 expect(svg).not.toMatch(/issues|pulls|ready now|crew/);
79 });
80
81 test("a name nobody has taken gets a card of the same shape", async () => {
82 const real = await (await get("/og/repo/alice~vault.svg")).text();
83 const none = await (await get("/og/repo/alice~nothing.svg")).text();
84 expect(none).toBe(real.replaceAll("alice/vault", "alice/nothing"));
85 });
86
87 test("the owner, signed in, sees the full card, for them alone", async () => {
88 const res = await get("/og/repo/alice~vault.svg", { cookie });
89 expect(res.status).toBe(200);
90 expect(res.headers.get("cache-control")).toBe("private, max-age=300");
91 const svg = await res.text();
92 expect(svg).toContain(SECRET);
93 expect(svg).toContain("1 issues");
94 });
95
96 test("the owner's page points at the card", async () => {
97 const html = await (await get("/alice/vault", { cookie })).text();
98 expect(html).toContain("/og/repo/alice~vault.png");
99 });
100 });
test/og-route.test.ts +13 −2
@@ -141,7 +141,11 @@ describe("og cards", () => {
141141 });
142142
143143 test("a private repo is 404", async () => {
144 expect((await get(`/og/${kind}/${private_}.svg`)).status).toBe(404);
144 // An app's card is refused; a repo's is only its title and "private"
145 // (test/og-private.test.ts).
146 expect((await get(`/og/${kind}/${private_}.svg`)).status).toBe(
147 kind === "app" ? 404 : 200,
148 );
145149 });
146150
147151 test("a private app on a public repo is 404", async () => {
@@ -153,7 +157,14 @@ describe("og cards", () => {
153157 });
154158
155159 test("an unknown or malformed ref is 404", async () => {
156 for (const ref of ["nobody~nothing", "nothing", "a~b~c", "..%2Fx", "A_B"])
160 // A well-formed repo ref that is not readable is the private card.
161 for (const ref of [
162 ...(kind === "app" ? ["nobody~nothing"] : []),
163 "nothing",
164 "a~b~c",
165 "..%2Fx",
166 "A_B",
167 ])
157168 expect((await get(`/og/${kind}/${ref}.svg`)).status).toBe(404);
158169 });
159170 });
test/standards.test.ts +3 −1
@@ -172,7 +172,9 @@ describe("what the open web expects", () => {
172172 expect(html).toContain(
173173 'rel="apple-touch-icon" href="/icons/forge-180.png"',
174174 );
175 expect(html).toContain('name="twitter:card" content="summary"');
175 // A repository page carries its own card (/og/repo), not the icon.
176 expect(html).toContain(`content="${base}/og/repo/alice~open.png"`);
177 expect(html).toContain('name="twitter:card" content="summary_large_image"');
176178 expect(html).toContain("viewport-fit=cover");
177179 });
178180

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs