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

A refused page or form answers the forge's own page saying why and the way back, never the bare word "forbidden" #8507

Merged opened this

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

Merged

scout/forge:scout/8491-a-refused-page-or-form-answers-the-forge → main 2 commits fork

  • Merged. scout/8491-a-refused-page-or-form-answers-the-forge was merged into main as b3ff698.

Review

Sign in to review this change.
30 files changed +265 −74
src/http/admin_gate.ts +2 −12
@@ -11,9 +11,7 @@
1111
1212 import type { App } from "../app.ts";
1313 import { lacks, NOT_AN_ADMIN } from "../core/roles.ts";
14 import { empty } from "../web/ui.ts";
15 import { html } from "../web/html.ts";
16 import { page } from "../web/layout.ts";
14 import { forbiddenPage } from "../web/chrome.ts";
1715 import type { Auth, Ctx } from "./context.ts";
1816 import { json } from "./respond.ts";
1917
@@ -30,13 +28,5 @@ export function adminGate(app: App, request: Request, url: URL, auth: Auth): Res
3028 if (!lacks(app.db, auth.user, "administer")) return undefined;
3129 if (url.pathname.startsWith("/api/")) return json({ error: "not_admin", message: NOT_AN_ADMIN }, 403);
3230 const ctx: Ctx = { app, request, url, params: {}, auth, theme: undefined };
33 return page(
34 {
35 ctx,
36 title: "Not allowed",
37 body: html`<div class="page-head"><h1>403</h1></div>
38 ${empty({ icon: "lock", title: "Not allowed", note: NOT_AN_ADMIN })}`,
39 },
40 403,
41 );
31 return forbiddenPage(ctx, NOT_AN_ADMIN);
4232 }
src/http/server.ts +8 −2
@@ -399,7 +399,7 @@ import {
399399 browseTree,
400400 } from "../web/pages/browse.ts";
401401 import { pushThrough } from "../origins/push-through.ts";
402 import { notFoundPage } from "../web/chrome.ts";
402 import { CAME_FROM_ELSEWHERE, forbiddenPage, notFoundPage, SENT_FROM_ELSEWHERE } from "../web/chrome.ts";
403403 import { targetForShortCode } from "../origins/oauth.ts";
404404 import { apiRoutes } from "./api.ts";
405405 import { pageParamRefused } from "./cursor_lists.ts";
@@ -2440,8 +2440,14 @@ async function answerGet(
24402440 }
24412441
24422442 // A cookie's identity never acts for another site: one door for every route.
2443 // A browser's form gets the forge's own page saying why; a script keeps the JSON.
24432444 const originRefusal = originGate(app, request, url, auth);
2444 if (originRefusal !== undefined) return originRefusal;
2445 if (originRefusal !== undefined) {
2446 if (isApiPath || !prefersHtml(request, url.pathname)) return originRefusal;
2447 const themeCookie = cookieValue(request, "forge_theme") ?? "";
2448 const theme = isThemeName(themeCookie) ? themeCookie : undefined;
2449 return forbiddenPage({ app, request, url, params: {}, auth, theme }, SENT_FROM_ELSEWHERE, CAME_FROM_ELSEWHERE);
2450 }
24452451
24462452 // A proposal token reaches nothing but what its gate names; every other
24472453 // request it makes ends here, before the limiter or any handler.
src/web/chrome.ts +35
@@ -24,6 +24,7 @@ import { canEnterApp, type Repo } from "../core/repos.ts";
2424 import { parentUrl, shownTrail, sourceLabel } from "../core/provenance.ts";
2525 import type { Database } from "bun:sqlite";
2626 import type { Ctx } from "../http/context.ts";
27 import { apiError, prefersJson } from "../http/respond.ts";
2728 import { html, raw, when, type Html } from "./html.ts";
2829 import { icon } from "./icons.ts";
2930 import { page, redirect, type PaletteItem } from "./layout.ts";
@@ -97,6 +98,40 @@ export function notFoundPage(ctx: Ctx, opts: { plain?: boolean } = {}): Response
9798
9899 export const repoNotFound = notFoundPage;
99100
101 /** Why a refusal happened, in the words a person reads on the 403 page. */
102 export const NOT_ADMIN_HERE = "Only an admin of this forge can do this.";
103 export const SENT_FROM_ELSEWHERE =
104 "This form was sent from another site, so it was not used — go back and send it again.";
105 /** The same refusal for a script, which has no page to go back to. */
106 export const CAME_FROM_ELSEWHERE = "This request came from another site, so it was not used.";
107 export const SIGN_IN_TO_DO = "Sign in to do this.";
108 export const NOT_YOURS_TO_DO = "Your account is not allowed to do this here.";
109
110 /** The one 403 a browser sees: the forge's chrome, one plain sentence why,
111 * and the way back — Home, and Sign in for someone anonymous. A caller that
112 * asked for JSON gets the API's `{ error, message }` instead, in `forScripts`'
113 * words when the page's would not suit it. */
114 export function forbiddenPage(ctx: Ctx, why: string, forScripts: string = why): Response {
115 if (prefersJson(ctx.request)) return apiError("forbidden", 403, forScripts);
116 const anonymous = ctx.auth.user === undefined;
117 return page(
118 {
119 ctx,
120 title: "Not allowed",
121 body: html`<div class="page-head"><h1>You can't do that here</h1></div>
122 ${empty({
123 icon: "lock",
124 title: why,
125 action: anonymous
126 ? html`${btn("Sign in", { href: signInHref(ctx), variant: "primary" })}
127 ${btn("Go home", { href: "/" })}`
128 : btn("Go home", { href: "/", variant: "primary" }),
129 })}`,
130 },
131 403,
132 );
133 }
134
100135 /** Send an anonymous visitor to sign in, and back where they were going. */
101136 export function signInFirst(ctx: Ctx): Response {
102137 return redirect(signInHref(ctx));
src/web/form.ts +3 −2
@@ -8,6 +8,7 @@
88 * enforce. Auth routes (sign in / up) pass no scope.
99 */
1010
11 import { CAME_FROM_ELSEWHERE, forbiddenPage, SENT_FROM_ELSEWHERE } from "./chrome.ts";
1112 import { BODY_MAX, readBounded, STRANGER_MAX } from "../http/limits.ts";
1213 import { scopeAllows, type TokenScope } from "../core/tokens.ts";
1314 import { isLocalPath } from "./flash.ts";
@@ -25,13 +26,13 @@ export async function readForm(
2526 max: number = ctx.auth.user === undefined ? STRANGER_MAX : BODY_MAX,
2627 ): Promise<Form | Response> {
2728 if (crossOrigin(ctx.request, ctx.url, ctx.app.log))
28 return new Response("forbidden", { status: 403 });
29 return forbiddenPage(ctx, SENT_FROM_ELSEWHERE, CAME_FROM_ELSEWHERE);
2930 if (minScope !== undefined) {
3031 if (
3132 ctx.auth.scope === undefined ||
3233 !scopeAllows(ctx.auth.scope, minScope)
3334 ) {
34 return new Response("forbidden", { status: 403 });
35 return forbiddenPage(ctx, "This token is not allowed to do this.");
3536 }
3637 }
3738 // Counted as it arrives (\`http/limits.ts\`), then parsed as what it says
src/web/pages/account.ts +2 −1
@@ -52,6 +52,7 @@ import {
5252 settingsLayout,
5353 signInFirst,
5454 type SettingsSection,
55 forbiddenPage,
5556 } from "../chrome.ts";
5657 import { accountSections } from "./origins.ts";
5758 import { field, readForm } from "../form.ts";
@@ -713,7 +714,7 @@ export async function tokenCreate(ctx: Ctx): Promise<Response> {
713714 // the platform edge's sign-in, or the password — never from another token
714715 // (no privilege bootstrapping). readForm below refuses a cross-site post.
715716 if (!isAccountItself(ctx.auth)) {
716 return new Response("forbidden", { status: 403 });
717 return forbiddenPage(ctx, "A token is made from your own signed-in account, never by another token.");
717718 }
718719 const form = await readForm(ctx, "admin");
719720 if (form instanceof Response) return form;
src/web/pages/apps.ts +3 −1
@@ -63,6 +63,8 @@ import {
6363 pageHead,
6464 repoBase,
6565 signInFirst,
66 NOT_YOURS_TO_DO,
67 forbiddenPage,
6668 } from "../chrome.ts";
6769 import { field, readForm } from "../form.ts";
6870 import { html, when, type Html } from "../html.ts";
@@ -500,7 +502,7 @@ export async function newAppSubmit(ctx: Ctx): Promise<Response> {
500502 }
501503 if (!made.ok) {
502504 if (made.error === "forbidden_owner" || made.error === "unauthorized")
503 return new Response("forbidden", { status: 403 });
505 return forbiddenPage(ctx, NOT_YOURS_TO_DO);
504506 const ownerId = getUserByName(
505507 ctx.app.db,
506508 values.owner || ctx.auth.user.username,
src/web/pages/claim.ts +2 −2
@@ -49,7 +49,7 @@ import {
4949 type ClaimState,
5050 type Spawned,
5151 } from "../../spawn/claim.ts";
52 import { pageHead } from "../chrome.ts";
52 import { pageHead, CAME_FROM_ELSEWHERE } from "../chrome.ts";
5353 import { field, readForm } from "../form.ts";
5454 import { html, type Html } from "../html.ts";
5555 import { page, redirect } from "../layout.ts";
@@ -268,7 +268,7 @@ function problems(ctx: Ctx, username: string, email: string, needEmail: boolean)
268268 export async function claimOptions(ctx: Ctx): Promise<Response> {
269269 const refuse = (status: number, message: string) =>
270270 json({ error: "refused", message }, status);
271 if (crossOrigin(ctx.request, ctx.url, ctx.app.log)) return refuse(403, "forbidden");
271 if (crossOrigin(ctx.request, ctx.url, ctx.app.log)) return refuse(403, CAME_FROM_ELSEWHERE);
272272 const wait = signinAllowed(ctx, `claim:${clientAddress(ctx.request)}`);
273273 if (wait !== undefined)
274274 return json({ error: "rate_limited", message: `Too many attempts. Try again in ${wait}s.` }, 429, { "retry-after": String(wait) });
src/web/pages/confirms.ts +2 −2
@@ -11,7 +11,7 @@ import { REFUSED, sayYes, waitingConfirms } from "../../actions/conversation_con
1111 import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts";
1212 import { ASKING } from "../../crew/conversation_confirm.ts";
1313 import type { Ctx } from "../../http/context.ts";
14 import { settingsLayout, signInFirst } from "../chrome.ts";
14 import { settingsLayout, signInFirst, forbiddenPage } from "../chrome.ts";
1515 import { readForm } from "../form.ts";
1616 import { html, type Html } from "../html.ts";
1717 import { flashTo, page } from "../layout.ts";
@@ -70,7 +70,7 @@ export async function confirmYes(ctx: Ctx): Promise<Response> {
7070 const user = ctx.auth.user;
7171 if (user === undefined) return signInFirst(ctx);
7272 // A browser's own session, as the API's door: never a token or a password.
73 if (ctx.auth.via !== "session") return new Response("forbidden", { status: 403 });
73 if (ctx.auth.via !== "session") return forbiddenPage(ctx, "Answer this from your own signed-in browser.");
7474 const form = await readForm(ctx);
7575 if (form instanceof Response) return form;
7676 const yes = await sayYes(ctx, user, ctx.params["id"] ?? "");
src/web/pages/conflicts.ts +2 −2
@@ -26,7 +26,7 @@ import {
2626 } from "../../git/conflicts.ts";
2727 import { notePhase, takeSessionTime, timePhase, timePhaseAsync } from "../../core/route_timing.ts";
2828 import type { Ctx } from "../../http/context.ts";
29 import { crumbsFor, notFoundPage, repoBase, repoPalette, repoHeader } from "../chrome.ts";
29 import { crumbsFor, notFoundPage, repoBase, repoPalette, repoHeader, forbiddenPage } from "../chrome.ts";
3030 import { FINISH_REFUSALS, finishConflicts } from "../../actions/finish-conflicts.ts";
3131 import { RESOLUTION_ASKED } from "../../actions/pull_ask_words.ts";
3232 import { field, readForm } from "../form.ts";
@@ -283,7 +283,7 @@ export async function conflictsFinish(ctx: Ctx): Promise<Response> {
283283 if (found instanceof Response) return found;
284284 const { repo, pull } = found;
285285 if (!ctx.auth.user || !canWrite(ctx.app.db, repo, ctx.auth.user))
286 return new Response("forbidden", { status: 403 });
286 return forbiddenPage(ctx, "Only someone who can write to this repository can do this.");
287287 const form = await readForm(ctx, "rw");
288288 if (form instanceof Response) return form;
289289 const dest = `${repoBase(repo)}/pulls/${pull.number}`;
src/web/pages/everyday.ts +5 −5
@@ -24,7 +24,7 @@ import { addPasskey } from "../../core/passkeys.ts";
2424 import { cookieValue, crossOrigin, type Ctx } from "../../http/context.ts";
2525 import { STRANGER_MAX } from "../../http/limits.ts";
2626 import { boundedBody, json, str } from "../../http/respond.ts";
27 import { signInFirst } from "../chrome.ts";
27 import { forbiddenPage, NOT_ADMIN_HERE, CAME_FROM_ELSEWHERE, signInFirst } from "../chrome.ts";
2828 import { field, readForm } from "../form.ts";
2929 import { html, type Html } from "../html.ts";
3030 import { flashTo, redirect } from "../layout.ts";
@@ -142,11 +142,11 @@ function nameProblem(ctx: Ctx, username: string): string | undefined {
142142 export async function everydayOptions(ctx: Ctx): Promise<Response> {
143143 const refuse = (status: number, message: string) =>
144144 json({ error: "refused", message }, status);
145 if (crossOrigin(ctx.request, ctx.url, ctx.app.log)) return refuse(403, "forbidden");
145 if (crossOrigin(ctx.request, ctx.url, ctx.app.log)) return refuse(403, CAME_FROM_ELSEWHERE);
146146 const admin = ctx.auth.user;
147147 if (!admin) return refuse(401, "Sign in first.");
148148 if (ctx.auth.via !== "session") return refuse(403, "Passkeys are made from a signed-in browser.");
149 if (!admin.isAdmin) return refuse(403, "forbidden");
149 if (!admin.isAdmin) return refuse(403, NOT_ADMIN_HERE);
150150 if (everydayOf(ctx.app.db, admin) !== undefined)
151151 return refuse(409, "You already have an everyday account.");
152152 const data = await boundedBody(ctx.request, STRANGER_MAX);
@@ -163,7 +163,7 @@ export async function everydayCreate(ctx: Ctx): Promise<Response> {
163163 if (!admin) return signInFirst(ctx);
164164 const form = await readForm(ctx, "admin");
165165 if (form instanceof Response) return form;
166 if (!admin.isAdmin) return new Response("forbidden", { status: 403 });
166 if (!admin.isAdmin) return forbiddenPage(ctx, NOT_ADMIN_HERE);
167167 const existing = everydayOf(ctx.app.db, admin);
168168 if (existing !== undefined)
169169 return flashTo(ctx, "/settings", {
@@ -231,7 +231,7 @@ export async function everydayKeep(ctx: Ctx): Promise<Response> {
231231 if (!admin) return signInFirst(ctx);
232232 const form = await readForm(ctx, "admin");
233233 if (form instanceof Response) return form;
234 if (!admin.isAdmin) return new Response("forbidden", { status: 403 });
234 if (!admin.isAdmin) return forbiddenPage(ctx, NOT_ADMIN_HERE);
235235 keepAdmin(ctx.app.db, admin);
236236 return flashTo(ctx, "/", {
237237 message:
src/web/pages/first-admin.ts +2 −2
@@ -17,7 +17,7 @@ import { FIRST_ADMIN_WORDS, firstAdminProblem, redeemFirstAdmin } from "../../ac
1717 import { addPasskey } from "../../core/passkeys.ts";
1818 import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts";
1919 import { createSession } from "../../core/sessions.ts";
20 import { pageHead } from "../chrome.ts";
20 import { pageHead, CAME_FROM_ELSEWHERE } from "../chrome.ts";
2121 import { field, readForm } from "../form.ts";
2222 import { html, type Html } from "../html.ts";
2323 import { page, redirect } from "../layout.ts";
@@ -127,7 +127,7 @@ const statusOf = (error: string): number =>
127127
128128 // POST /first-admin/options — a passkey challenge for a live code and a fit name. Nothing is spent.
129129 export async function firstAdminOptions(ctx: Ctx): Promise<Response> {
130 if (crossOrigin(ctx.request, ctx.url, ctx.app.log)) return json({ error: "refused", message: "forbidden" }, 403);
130 if (crossOrigin(ctx.request, ctx.url, ctx.app.log)) return json({ error: "refused", message: CAME_FROM_ELSEWHERE }, 403);
131131 const data = await boundedBody(ctx.request, STRANGER_MAX);
132132 if (data === "too_large") return json({ error: "refused", message: "payload too large" }, 413);
133133 const username = str(data.username).trim();
src/web/pages/migrate.ts +3 −3
@@ -48,7 +48,7 @@ import {
4848 } from "../../core/route_timing.ts";
4949 import type { Ctx } from "../../http/context.ts";
5050 import type { MirrorJob, MirrorStage } from "../../origins/jobs.ts";
51 import { notFoundPage, pageHead, signInFirst } from "../chrome.ts";
51 import { notFoundPage, pageHead, signInFirst, NOT_YOURS_TO_DO, forbiddenPage } from "../chrome.ts";
5252 import { field, readForm } from "../form.ts";
5353 import { html, raw, type Html } from "../html.ts";
5454 import { flashTo, page, redirect } from "../layout.ts";
@@ -720,7 +720,7 @@ export async function migrateSubmit(ctx: Ctx): Promise<Response> {
720720 picked !== undefined &&
721721 (ctx.auth.scope === undefined || !scopeAllows(ctx.auth.scope, "admin"))
722722 )
723 return new Response("forbidden", { status: 403 });
723 return forbiddenPage(ctx, "This token is not allowed to do this.");
724724
725725 const started = await migrateRepo(ctx.app, ctx.auth.user, {
726726 source: picked ?? { url: values.url },
@@ -737,7 +737,7 @@ export async function migrateSubmit(ctx: Ctx): Promise<Response> {
737737 });
738738 if (!started.ok) {
739739 if (started.error === "forbidden_owner" || started.error === "unauthorized")
740 return new Response("forbidden", { status: 403 });
740 return forbiddenPage(ctx, NOT_YOURS_TO_DO);
741741 return again(
742742 migrateMessage(started.error, repositoryLimitMessage(ctx.app, ctx.auth.user!)),
743743 );
src/web/pages/milestones.ts +3 −1
@@ -52,6 +52,8 @@ import {
5252 repoHeader,
5353 repoPalette,
5454 signInFirst,
55 NOT_YOURS_TO_DO,
56 forbiddenPage,
5557 } from "../chrome.ts";
5658 import { html, when, type Html } from "../html.ts";
5759 import { field, readForm } from "../form.ts";
@@ -624,7 +626,7 @@ export async function milestoneWatch(ctx: Ctx): Promise<Response> {
624626 if (!set.ok)
625627 return set.error === "not_found"
626628 ? notFoundPage(ctx)
627 : new Response("forbidden", { status: 403 });
629 : forbiddenPage(ctx, NOT_YOURS_TO_DO);
628630 return redirect(milestoneHref(repoBase(repo), number));
629631 }
630632
src/web/pages/oauth.ts +2 −2
@@ -16,7 +16,7 @@ import {
1616 } from "../../core/oauth.ts";
1717 import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts";
1818 import type { Ctx } from "../../http/context.ts";
19 import { pageHead } from "../chrome.ts";
19 import { pageHead, SIGN_IN_TO_DO, forbiddenPage } from "../chrome.ts";
2020 import { field, readForm } from "../form.ts";
2121 import { html, type Html } from "../html.ts";
2222 import { page, redirect } from "../layout.ts";
@@ -202,7 +202,7 @@ export async function authorizeSubmit(ctx: Ctx): Promise<Response> {
202202 const form = await readForm(ctx);
203203 if (form instanceof Response) return form;
204204 if (ctx.auth.user === undefined || ctx.auth.via === "token")
205 return new Response("forbidden", { status: 403 });
205 return forbiddenPage(ctx, SIGN_IN_TO_DO);
206206 const request = parseAuthRequest(ctx.app.db, (n) => field(form, n));
207207 if (!request.ok) return refused(ctx, request.error);
208208 const { redirectUri, state } = request.value;
src/web/pages/origins.ts +5 −3
@@ -57,6 +57,8 @@ import {
5757 settingsLayout,
5858 signInFirst,
5959 type SettingsSection,
60 NOT_ADMIN_HERE,
61 forbiddenPage,
6062 } from "../chrome.ts";
6163 import { field, readForm } from "../form.ts";
6264 import { html, when, type Html } from "../html.ts";
@@ -277,7 +279,7 @@ export function accountOnly(ctx: Ctx): Response | undefined {
277279 if (ctx.auth.via === "session" || ctx.auth.via === "password")
278280 return undefined;
279281 if (ctx.auth.scope === "admin") return undefined;
280 return new Response("forbidden", { status: 403 });
282 return forbiddenPage(ctx, "This takes your own signed-in account or an admin token.");
281283 }
282284
283285 // ---- the origin list ----
@@ -1023,14 +1025,14 @@ export async function originTrustedHosts(ctx: Ctx): Promise<Response> {
10231025 const denied = accountOnly(ctx);
10241026 if (denied) return denied;
10251027 if (!mayEditTrustedHosts(ctx.auth.user))
1026 return new Response("forbidden", { status: 403 });
1028 return forbiddenPage(ctx, NOT_ADMIN_HERE);
10271029 const form = await readForm(ctx, "admin");
10281030 if (form instanceof Response) return form;
10291031 const text = field(form, "hosts");
10301032 const saved = updateTrustedHosts(ctx.app.db, ctx.auth.user, text);
10311033 if (!saved.ok) {
10321034 if (saved.error === "forbidden")
1033 return new Response("forbidden", { status: 403 });
1035 return forbiddenPage(ctx, NOT_ADMIN_HERE);
10341036 const values = blankValues();
10351037 values.trustedText = text.slice(0, 4000);
10361038 values.trustedError = rejectedSentence(saved.rejected);
src/web/pages/passkeys.ts +6 −6
@@ -27,7 +27,7 @@ import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.t
2727 import { clientAddress, clientBucket } from "../../http/peer.ts";
2828 import { STRANGER_MAX } from "../../http/limits.ts";
2929 import { boundedBody, json, str } from "../../http/respond.ts";
30 import { signInFirst } from "../chrome.ts";
30 import { CAME_FROM_ELSEWHERE, signInFirst } from "../chrome.ts";
3131 import { field, readForm } from "../form.ts";
3232 import { html } from "../html.ts";
3333 import { flashTo, page } from "../layout.ts";
@@ -56,7 +56,7 @@ const refuse = (status: number, message: string) =>
5656
5757 /** A signed-in browser, on a same-origin request; else the refusal to send. */
5858 function sessionOnly(ctx: Ctx): Response | undefined {
59 if (crossOrigin(ctx.request, ctx.url, ctx.app.log)) return refuse(403, "forbidden");
59 if (crossOrigin(ctx.request, ctx.url, ctx.app.log)) return refuse(403, CAME_FROM_ELSEWHERE);
6060 if (!ctx.auth.user) return refuse(401, "Sign in first.");
6161 if (ctx.auth.via !== "session") return refuse(403, "Passkeys are changed from a signed-in browser.");
6262 return undefined;
@@ -169,7 +169,7 @@ export async function passkeyDelete(ctx: Ctx): Promise<Response> {
169169
170170 // POST /signin/passkey/options — a challenge for someone with no session yet.
171171 export function signinOptions(ctx: Ctx): Response {
172 if (crossOrigin(ctx.request, ctx.url, ctx.app.log)) return refuse(403, "forbidden");
172 if (crossOrigin(ctx.request, ctx.url, ctx.app.log)) return refuse(403, CAME_FROM_ELSEWHERE);
173173 const wait = signinAllowed(ctx, `passkey:${clientAddress(ctx.request)}`, { forgeWide: false });
174174 if (wait !== undefined)
175175 return json({ error: "rate_limited", message: `Too many attempts. Try again in ${wait}s.` }, 429, { "retry-after": String(wait) });
@@ -178,7 +178,7 @@ export function signinOptions(ctx: Ctx): Response {
178178
179179 // POST /signin/passkey — a good assertion gets the session a password gets.
180180 export async function signinPasskey(ctx: Ctx): Promise<Response> {
181 if (crossOrigin(ctx.request, ctx.url, ctx.app.log)) return refuse(403, "forbidden");
181 if (crossOrigin(ctx.request, ctx.url, ctx.app.log)) return refuse(403, CAME_FROM_ELSEWHERE);
182182 const wait = signinAllowed(ctx, `passkey:${clientAddress(ctx.request)}`, { forgeWide: false });
183183 if (wait !== undefined)
184184 return json({ error: "rate_limited", message: `Too many attempts. Try again in ${wait}s.` }, 429, { "retry-after": String(wait) });
@@ -293,7 +293,7 @@ export async function recoverRequest(ctx: Ctx): Promise<Response> {
293293
294294 // POST /recover/options — a challenge, for a link that still works.
295295 export async function recoverOptions(ctx: Ctx): Promise<Response> {
296 if (crossOrigin(ctx.request, ctx.url, ctx.app.log)) return refuse(403, "forbidden");
296 if (crossOrigin(ctx.request, ctx.url, ctx.app.log)) return refuse(403, CAME_FROM_ELSEWHERE);
297297 const data = await boundedBody(ctx.request, STRANGER_MAX);
298298 if (data === "too_large") return refuse(413, "payload too large");
299299 const user = recoveryAccount(ctx.app, str(data.token));
@@ -313,7 +313,7 @@ export async function recoverOptions(ctx: Ctx): Promise<Response> {
313313
314314 // POST /recover/passkey — a good registration spends the link and signs in.
315315 export async function recoverFinish(ctx: Ctx): Promise<Response> {
316 if (crossOrigin(ctx.request, ctx.url, ctx.app.log)) return refuse(403, "forbidden");
316 if (crossOrigin(ctx.request, ctx.url, ctx.app.log)) return refuse(403, CAME_FROM_ELSEWHERE);
317317 const data = await boundedBody(ctx.request, STRANGER_MAX);
318318 if (data === "too_large") return refuse(413, "payload too large");
319319 const token = str(data.token);
src/web/pages/proposal-tokens.ts +4 −4
@@ -8,7 +8,7 @@
88 import { listProposals, mintProposal, revokeProposal } from "../../actions/proposal_tokens.ts";
99 import { canWrite, type Repo } from "../../core/repos.ts";
1010 import { isAccountItself, type Ctx } from "../../http/context.ts";
11 import { loadRepo, repoBase, repoNotFound } from "../chrome.ts";
11 import { loadRepo, repoBase, repoNotFound, NOT_YOURS_TO_DO, forbiddenPage } from "../chrome.ts";
1212 import { field, readForm } from "../form.ts";
1313 import { html, type Html } from "../html.ts";
1414 import { flashTo } from "../layout.ts";
@@ -69,7 +69,7 @@ async function writerForm(ctx: Ctx) {
6969 const form = await readForm(ctx, "rw");
7070 if (form instanceof Response) return form;
7171 if (!canWrite(ctx.app.db, repo, ctx.auth.user))
72 return new Response("forbidden", { status: 403 });
72 return forbiddenPage(ctx, "Only someone who can write to this repository can do this.");
7373 return { repo, form };
7474 }
7575
@@ -80,7 +80,7 @@ export async function proposalTokenMint(ctx: Ctx): Promise<Response> {
8080 const { repo, form } = gate;
8181 // Never minted by another token: a credential cannot grow credentials.
8282 if (!isAccountItself(ctx.auth))
83 return new Response("forbidden", { status: 403 });
83 return forbiddenPage(ctx, "A token is made from your own signed-in account, never by another token.");
8484 const minted = mintProposal(ctx.app, ctx.auth.user, repo, {
8585 child: field(form, "child"),
8686 origin: field(form, "origin"),
@@ -89,7 +89,7 @@ export async function proposalTokenMint(ctx: Ctx): Promise<Response> {
8989 if (!minted.ok)
9090 return minted.error.code === "invalid_child" || minted.error.code === "invalid_origin"
9191 ? flashTo(ctx, back, { tone: "danger", message: minted.error.message })
92 : new Response("forbidden", { status: 403 });
92 : forbiddenPage(ctx, NOT_YOURS_TO_DO);
9393 // Post-redirect-get: the secret rides a one-shot flash, never the page.
9494 return flashTo(ctx, back, {
9595 message: "Proposal token minted — copy it now, it is never shown again.",
src/web/pages/pull.ts +4 −3
@@ -66,6 +66,7 @@ import {
6666 repoHeader,
6767 repoPalette,
6868 signInFirst,
69 forbiddenPage,
6970 } from "../chrome.ts";
7071 import { notePhase, takeSessionTime } from "../../core/route_timing.ts";
7172 import type { DiffLaps } from "../../git/diff.ts";
@@ -1351,7 +1352,7 @@ export async function pullChecksSubmit(ctx: Ctx): Promise<Response> {
13511352 const found = pullContext(ctx);
13521353 if (found instanceof Response) return found;
13531354 if (!ctx.auth.user || !canWrite(ctx.app.db, found.repo, ctx.auth.user))
1354 return new Response("forbidden", { status: 403 });
1355 return forbiddenPage(ctx, "Only someone who can write to this repository can do this.");
13551356 const form = await readForm(ctx, "rw");
13561357 if (form instanceof Response) return form;
13571358 // The home's card asks to come back to the home (only "/" is honoured).
@@ -1372,7 +1373,7 @@ export async function pullMergeSubmit(ctx: Ctx): Promise<Response> {
13721373 const found = pullContext(ctx);
13731374 if (found instanceof Response) return found;
13741375 if (!ctx.auth.user || !canWrite(ctx.app.db, found.repo, ctx.auth.user))
1375 return new Response("forbidden", { status: 403 });
1376 return forbiddenPage(ctx, "Only someone who can write to this repository can do this.");
13761377 const form = await readForm(ctx, "rw");
13771378 if (form instanceof Response) return form;
13781379
@@ -1400,7 +1401,7 @@ export async function pullLandPastSubmit(ctx: Ctx): Promise<Response> {
14001401 if (found instanceof Response) return found;
14011402 const user = ctx.auth.user;
14021403 if (!user || user.kind === "agent" || !canAdmin(ctx.app.db, found.repo, user))
1403 return new Response("forbidden", { status: 403 });
1404 return forbiddenPage(ctx, "Only a person who administers this repository can do this.");
14041405 const form = await readForm(ctx, "rw");
14051406 if (form instanceof Response) return form;
14061407 const dest = `${repoBase(found.repo)}/pulls/${found.pull.number}`;
src/web/pages/releases.ts +3 −3
@@ -14,7 +14,7 @@ import type { Ctx } from "../../http/context.ts";
1414 import { listReleases, type Release } from "../../release/record.ts";
1515 import { BOOTED, canRollBackTo, mayRead, mayRollBack, rollBackTo } from "../../release/gate.ts";
1616 import { liveSince, NO_RELEASES_YET } from "../../release/words.ts";
17 import { notFoundPage, signInFirst } from "../chrome.ts";
17 import { notFoundPage, signInFirst, NOT_ADMIN_HERE, SIGN_IN_TO_DO, forbiddenPage } from "../chrome.ts";
1818 import { readForm } from "../form.ts";
1919 import { html, when, type Html } from "../html.ts";
2020 import { flashTo, page } from "../layout.ts";
@@ -91,10 +91,10 @@ export async function releasesPage(ctx: Ctx): Promise<Response> {
9191
9292 export async function rollbackSubmit(ctx: Ctx): Promise<Response> {
9393 const user = ctx.auth.user;
94 if (user === undefined) return new Response("forbidden", { status: 403 });
94 if (user === undefined) return forbiddenPage(ctx, SIGN_IN_TO_DO);
9595 const form = await readForm(ctx, "rw");
9696 if (form instanceof Response) return form;
97 if (!mayRollBack(ctx.app, user)) return new Response("forbidden", { status: 403 });
97 if (!mayRollBack(ctx.app, user)) return forbiddenPage(ctx, NOT_ADMIN_HERE);
9898 const target = ctx.params["sha"] ?? "";
9999 const refuse = (message: string): Response =>
100100 flashTo(ctx, "/releases", { tone: "danger", message });
src/web/pages/signup-passkey.ts +2 −1
@@ -15,6 +15,7 @@ import { crossOrigin, type Ctx } from "../../http/context.ts";
1515 import { STRANGER_MAX } from "../../http/limits.ts";
1616 import { clientAddress, clientBucket } from "../../http/peer.ts";
1717 import { boundedBody, json, str } from "../../http/respond.ts";
18 import { CAME_FROM_ELSEWHERE } from "../chrome.ts";
1819 import { relying, store } from "./passkeys.ts";
1920 import { EDGE_NAME_MESSAGE, inviteOf, pinnedTo, safeNext, sessionCookie, signinAllowed, signupSlot } from "./site.ts";
2021 import { EMAIL_TAKEN_MESSAGE, INVALID_USERNAME_MESSAGE, NAME_RESERVED, USERNAME_TAKEN } from "../../actions/signup_words.ts";
@@ -50,7 +51,7 @@ function tooMany(wait: number): Response {
5051 }
5152
5253 function guard(ctx: Ctx): Response | undefined {
53 if (crossOrigin(ctx.request, ctx.url, ctx.app.log)) return refuse(403, "forbidden");
54 if (crossOrigin(ctx.request, ctx.url, ctx.app.log)) return refuse(403, CAME_FROM_ELSEWHERE);
5455 const wait = signinAllowed(ctx, `signup:${clientAddress(ctx.request)}`);
5556 return wait === undefined ? undefined : tooMany(wait);
5657 }
src/web/pages/site.ts +11 −3
@@ -30,7 +30,15 @@ import { studioDoor, studioHome } from "../studio/home.ts";
3030 import { parentIssuerOf } from "../../core/federation.ts";
3131 import { activeEdgeToken, edgeIdentity, edgeLoginUrl, edgeStrangerLogin } from "../../http/edge.ts";
3232 import { sshCommand } from "../../ssh/command.ts";
33 import { hostOf, pageHead, signInFirst } from "../chrome.ts";
33 import {
34 CAME_FROM_ELSEWHERE,
35 forbiddenPage,
36 hostOf,
37 NOT_YOURS_TO_DO,
38 pageHead,
39 SENT_FROM_ELSEWHERE,
40 signInFirst,
41 } from "../chrome.ts";
3442 import { SignupSlot, signupWindow } from "../../actions/signup-window.ts";
3543 import { signinWindow } from "../../core/signin-window.ts";
3644 import { field, readForm } from "../form.ts";
@@ -712,7 +720,7 @@ export async function themeSubmit(ctx: Ctx): Promise<Response> {
712720
713721 export function logoutSubmit(ctx: Ctx): Response {
714722 if (crossOrigin(ctx.request, ctx.url, ctx.app.log))
715 return new Response("forbidden", { status: 403 });
723 return forbiddenPage(ctx, SENT_FROM_ELSEWHERE, CAME_FROM_ELSEWHERE);
716724 signOutHere(ctx.app, cookieValue(ctx.request, "forge_session"));
717725 return redirect("/", sessionCookie("", new Date(0), ctx.url, ctx.app.config.url));
718726 }
@@ -835,7 +843,7 @@ export async function newRepoSubmit(ctx: Ctx): Promise<Response> {
835843 });
836844 if (!created.ok) {
837845 if (created.error === "forbidden_owner" || created.error === "unauthorized")
838 return new Response("forbidden", { status: 403 });
846 return forbiddenPage(ctx, NOT_YOURS_TO_DO);
839847 const messages: Record<string, string> = {
840848 not_member: `Not made: ${NOT_A_MEMBER}.`,
841849 repository_limit: `Not made: ${repositoryLimitMessage(ctx.app, ctx.auth.user)}.`,
src/web/pages/talk.ts +2 −2
@@ -21,7 +21,7 @@ import { turnRunning } from "../../crew/conversation.ts";
2121 import type { Ctx } from "../../http/context.ts";
2222 import { json } from "../../http/respond.ts";
2323 import { eventStream } from "../../http/sse.ts";
24 import { signInFirst } from "../chrome.ts";
24 import { signInFirst, forbiddenPage } from "../chrome.ts";
2525 import { readForm } from "../form.ts";
2626 import { html, type Html } from "../html.ts";
2727 import { flashTo, page } from "../layout.ts";
@@ -153,7 +153,7 @@ export async function talkSend(ctx: Ctx): Promise<Response> {
153153 export async function talkConfirm(ctx: Ctx): Promise<Response> {
154154 const user = ctx.auth.user;
155155 if (user === undefined) return signInFirst(ctx);
156 if (ctx.auth.via !== "session") return new Response("forbidden", { status: 403 });
156 if (ctx.auth.via !== "session") return forbiddenPage(ctx, "Answer this from your own signed-in browser.");
157157 const form = await readForm(ctx);
158158 if (form instanceof Response) return form;
159159 const answer = form.get("answer");
src/web/pages/variants.ts +3 −1
@@ -18,6 +18,8 @@ import {
1818 notFoundPage,
1919 repoBase,
2020 signInFirst,
21 NOT_YOURS_TO_DO,
22 forbiddenPage,
2123 } from "../chrome.ts";
2224 import { field, readForm } from "../form.ts";
2325 import { html, when, type Html } from "../html.ts";
@@ -137,7 +139,7 @@ export const variantVoteSubmit =
137139 );
138140 if (!voted.ok) {
139141 if (voted.error === "forbidden")
140 return new Response("forbidden", { status: 403 });
142 return forbiddenPage(ctx, NOT_YOURS_TO_DO);
141143 return flashTo(ctx, dest, {
142144 tone: "danger",
143145 message:
src/web/studio/asks_page.ts +2 −2
@@ -30,7 +30,7 @@ import type { User } from "../../core/users.ts";
3030 import { onYouAnswer, onYouRegion } from "./home.ts";
3131 import { liveRegionsJs } from "../client.ts";
3232 import { liveClientJs } from "../live.ts";
33 import { notFoundPage, repoBase, signInFirst } from "../chrome.ts";
33 import { notFoundPage, repoBase, signInFirst, NOT_YOURS_TO_DO, forbiddenPage } from "../chrome.ts";
3434 import { backTo, field, readForm } from "../form.ts";
3535 import { MAX_BODY } from "../../core/issues.ts";
3636 import { issueTitles } from "../../core/proposals.ts";
@@ -759,7 +759,7 @@ export function asksProposal(verb: ProposalVerb) {
759759 return done.error.code === "not_found"
760760 ? notFoundPage(ctx)
761761 : done.error.code === "forbidden" || done.error.code === "unauthorized"
762 ? new Response("forbidden", { status: 403 })
762 ? forbiddenPage(ctx, NOT_YOURS_TO_DO)
763763 : await asksScreen(ctx, nothingDone(done.error.message));
764764 return redirect(backTo(form, "/asks"));
765765 };
src/web/studio/home.ts +2 −2
@@ -25,7 +25,7 @@ import { type User } from "../../core/users.ts";
2525 import type { Ctx } from "../../http/context.ts";
2626 import { activeEdgeToken, edgeLoginUrl, edgeStrangerLogin } from "../../http/edge.ts";
2727 import { sshCommand } from "../../ssh/command.ts";
28 import { repoBase, signInFirst } from "../chrome.ts";
28 import { repoBase, signInFirst, NOT_ADMIN_HERE, forbiddenPage } from "../chrome.ts";
2929 import { field, readForm, type Form } from "../form.ts";
3030 import { liveRegionsJs } from "../client.ts";
3131 import { html, when, type Html } from "../html.ts";
@@ -709,7 +709,7 @@ export async function frontUpdate(ctx: Ctx): Promise<Response> {
709709 if (!ctx.auth.user) return signInFirst(ctx);
710710 const form = await readForm(ctx, "rw");
711711 if (form instanceof Response) return form;
712 if (!ctx.auth.user.isAdmin) return new Response("forbidden", { status: 403 });
712 if (!ctx.auth.user.isAdmin) return forbiddenPage(ctx, NOT_ADMIN_HERE);
713713 const next = nextPicks(frontPicksJson(ctx.app), field(form, "action"), form);
714714 if (next === undefined) return new Response("bad request", { status: 400 });
715715 const set = setFront(ctx.app, ctx.auth.user, next);
src/web/studio/home_archive.ts +2 −2
@@ -8,7 +8,7 @@
88 import { applyFleetIds, getFleetOp, undoFleetOp } from "../../actions/fleet.ts";
99 import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts";
1010 import type { Ctx } from "../../http/context.ts";
11 import { signInFirst } from "../chrome.ts";
11 import { signInFirst, NOT_ADMIN_HERE, forbiddenPage } from "../chrome.ts";
1212 import { readForm } from "../form.ts";
1313 import { html } from "../html.ts";
1414 import { page, redirect } from "../layout.ts";
@@ -29,7 +29,7 @@ export async function homeArchive(ctx: Ctx): Promise<Response> {
2929 if (!ctx.auth.user) return signInFirst(ctx);
3030 const form = await readForm(ctx, "admin");
3131 if (form instanceof Response) return form;
32 if (!ctx.auth.user.isAdmin) return new Response("forbidden", { status: 403 });
32 if (!ctx.auth.user.isAdmin) return forbiddenPage(ctx, NOT_ADMIN_HERE);
3333 const ids = picked(form);
3434 if (ids.length === 0 || ids.length > MAX_ROWS)
3535 return redirect("/", {
src/web/studio/keeper_row.ts +2 −2
@@ -8,7 +8,7 @@
88 import type { Database } from "bun:sqlite";
99 import { answerKeeper, keeperLine, readKeeper } from "../../flow/keeper.ts";
1010 import type { Ctx } from "../../http/context.ts";
11 import { signInFirst } from "../chrome.ts";
11 import { signInFirst, NOT_ADMIN_HERE, forbiddenPage } from "../chrome.ts";
1212 import { readForm } from "../form.ts";
1313 import { html, type Html } from "../html.ts";
1414 import { redirect } from "../layout.ts";
@@ -33,7 +33,7 @@ export async function keeperAnswer(ctx: Ctx): Promise<Response> {
3333 if (!ctx.auth.user) return signInFirst(ctx);
3434 const form = await readForm(ctx, "admin");
3535 if (form instanceof Response) return form;
36 if (!ctx.auth.user.isAdmin) return new Response("forbidden", { status: 403 });
36 if (!ctx.auth.user.isAdmin) return forbiddenPage(ctx, NOT_ADMIN_HERE);
3737 const raw = form.get("id");
3838 const id = typeof raw === "string" && /^\d{1,12}$/.test(raw) ? Number(raw) : 0;
3939 const answered = id > 0 ? answerKeeper(ctx.app, id, ctx.auth.user.id) : { done: false, message: "That question is already answered." };
src/web/studio/toolbar.ts +2 −2
@@ -33,7 +33,7 @@ import { commitLog } from "../../git/read.ts";
3333 import { repoDir } from "../../git/repos.ts";
3434 import { notePhase, takeSessionTime, timePhase, timePhaseAsync } from "../../core/route_timing.ts";
3535 import type { Ctx } from "../../http/context.ts";
36 import { notFoundPage, pageHead, loadRepo, repoBase } from "../chrome.ts";
36 import { notFoundPage, pageHead, loadRepo, repoBase, SIGN_IN_TO_DO, forbiddenPage } from "../chrome.ts";
3737 import { diffTotals, pathAnchor } from "../diff-view.ts";
3838 import { field, readForm } from "../form.ts";
3939 import { html, when, type Html } from "../html.ts";
@@ -282,7 +282,7 @@ export async function toolbarComment(ctx: Ctx): Promise<Response> {
282282 const form = await readForm(ctx, "rw");
283283 if (form instanceof Response) return form;
284284 const user = ctx.auth.user;
285 if (!user) return new Response("forbidden", { status: 403 });
285 if (!user) return forbiddenPage(ctx, SIGN_IN_TO_DO);
286286 const where = parseEnv(field(form, "env"));
287287 if (where === undefined) return new Response("bad request", { status: 400 });
288288 const db = ctx.app.db;
test/admin-gate.test.ts +1 −1
@@ -129,7 +129,7 @@ describe("the admin gate", () => {
129129 expect(res.status, path).toBe(403);
130130 expect(res.headers.get("content-type")).toContain("text/html");
131131 const body = await res.text();
132 expect(body).toContain("<h1>403</h1>");
132 expect(body).toContain("<h1>You can't do that here</h1>");
133133 expect(body).toContain(NOT_AN_ADMIN.replace("'", "&#39;"));
134134 }
135135 }
test/forbidden-page.test.ts added +140
@@ -0,0 +1,140 @@
1 /**
2 * A refused page or form answers the forge's own 403 page (#8491): the
3 * chrome, "You can't do that here", one sentence why and the way back. A
4 * caller that asks for JSON keeps `{ error, message }`, and no form route
5 * answers a browser the bare word "forbidden" any more.
6 */
7 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
8 import { mkdtemp, rm } from "node:fs/promises";
9 import { tmpdir } from "node:os";
10 import { join } from "node:path";
11 import { createApp, type App } from "../src/app.ts";
12 import { createSession } from "../src/core/sessions.ts";
13 import { createUser } from "../src/core/users.ts";
14 import { buildRouter, forgeHttp } from "../src/http/server.ts";
15 import { CAME_FROM_ELSEWHERE, forbiddenPage, NOT_ADMIN_HERE, SENT_FROM_ELSEWHERE } from "../src/web/chrome.ts";
16 import type { Ctx } from "../src/http/context.ts";
17 import { quietLogs } from "./helpers/globals.ts";
18
19 quietLogs();
20
21 const HEADING = "<h1>You can't do that here</h1>";
22 const esc = (s: string) => s.replaceAll("'", "&#39;");
23
24 let dataDir = "", app: App, kim = "", kimId = "";
25
26 beforeAll(async () => {
27 dataDir = await mkdtemp(join(tmpdir(), "forge-forbidden-"));
28 app = createApp({ dataDir, port: 0 });
29 const made = await createUser(app.db, {
30 origin: "seed", trusted: true, username: "kim", email: "[email protected]", password: "correct-horse-battery", isAdmin: false,
31 });
32 if (!made.ok) throw new Error(made.error);
33 kimId = made.value.id;
34 kim = `forge_session=${createSession(app.db, kimId).token}`;
35 });
36
37 afterAll(async () => {
38 app.close();
39 await rm(dataDir, { recursive: true, force: true });
40 });
41
42 const send = (path: string, init: { method?: string; headers?: Record<string, string>; body?: string }) =>
43 forgeHttp(app).handle(new Request(`http://forge.test${path}`, init));
44
45 const form = { "content-type": "application/x-www-form-urlencoded", accept: "text/html" };
46
47 describe("the 403 page", () => {
48 test("a non-admin posting an admin form reads why, in the chrome, with the way home", async () => {
49 const res = await send("/settings/everyday", {
50 method: "POST",
51 headers: { ...form, cookie: kim, origin: "http://forge.test" },
52 body: "username=kim-me&email=kim-me%40x.test",
53 });
54 expect(res.status).toBe(403);
55 expect(res.headers.get("content-type")).toContain("text/html");
56 expect(res.headers.get("content-security-policy")).toBeTruthy();
57 expect(res.headers.get("x-content-type-options")).toBe("nosniff");
58 const body = await res.text();
59 expect(body).toContain(HEADING);
60 expect(body).toContain(NOT_ADMIN_HERE);
61 expect(body).toContain('href="/"');
62 expect(body).not.toContain("Sign in</a>");
63 });
64
65 test("a cross-origin sign-in form gets the page, offering sign-in back", async () => {
66 const res = await send("/signin", {
67 method: "POST",
68 headers: { ...form, origin: "https://evil.example" },
69 body: "username=kim&password=x",
70 });
71 expect(res.status).toBe(403);
72 const body = await res.text();
73 expect(body).toContain(HEADING);
74 expect(body).toContain(esc(SENT_FROM_ELSEWHERE));
75 expect(body).toContain('href="/"');
76 expect(body).toContain("/signin");
77 });
78
79 test("a signed-in cross-origin form meets the origin gate's page; a script keeps its JSON", async () => {
80 const page = await send("/settings/everyday/keep", {
81 method: "POST",
82 headers: { ...form, cookie: kim, origin: "https://evil.example" },
83 });
84 expect(page.status).toBe(403);
85 expect(await page.text()).toContain(HEADING);
86 const script = await send("/settings/everyday/keep", {
87 method: "POST",
88 headers: { cookie: kim, origin: "https://evil.example", accept: "application/json" },
89 });
90 expect(script.status).toBe(403);
91 expect(((await script.json()) as { error: string }).error).toBe("forbidden");
92 });
93
94 test("a script's cross-site post is told so without 'go back'", async () => {
95 const res = await send("/signin", {
96 method: "POST",
97 headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json", origin: "https://evil.example" },
98 body: "username=kim&password=x",
99 });
100 expect(res.status).toBe(403);
101 expect(await res.json()).toEqual({ error: "forbidden", message: CAME_FROM_ELSEWHERE });
102 });
103
104 test("asked for JSON, it is the API's shape; the reason is escaped on the page", async () => {
105 const ctx = (accept: string): Ctx => ({
106 app,
107 request: new Request("http://forge.test/x", { method: "POST", headers: { accept } }),
108 url: new URL("http://forge.test/x"),
109 params: {},
110 auth: { user: undefined, scope: undefined, via: "none" },
111 theme: undefined,
112 });
113 const asJson = forbiddenPage(ctx("application/json"), "Sign in to do this.");
114 expect(asJson.status).toBe(403);
115 expect(await asJson.json()).toEqual({ error: "forbidden", message: "Sign in to do this." });
116 const html = await forbiddenPage(ctx("text/html"), "<b>no</b>").text();
117 expect(html).toContain("&lt;b&gt;no&lt;/b&gt;");
118 expect(html).not.toContain("<b>no</b>");
119 });
120 });
121
122 const FILL: Record<string, string> = { owner: "kim", username: "kim", name: "notes", number: "1", org: "kim", id: "1" };
123 const fill = (pattern: string): string =>
124 pattern.replace(/:([a-z_]+)/g, (_, k: string) => FILL[k] ?? "x").replace(/\*[a-z_]+/, "x");
125
126 test('no form route answers a browser the bare word "forbidden": not across sites, not as a non-admin', async () => {
127 const routes = buildRouter().routes().filter((r) => ["POST", "PUT", "PATCH", "DELETE"].includes(r.method));
128 expect(routes.length).toBeGreaterThan(50);
129 const bare: string[] = [];
130 for (const r of routes) {
131 for (const origin of ["https://evil.example", "http://forge.test"]) {
132 // A fresh session each time: a route before may have signed this one out.
133 const cookie = `forge_session=${createSession(app.db, kimId).token}`;
134 const res = await send(fill(r.pattern), { method: r.method, headers: { ...form, cookie, origin }, body: "" });
135 const body = await res.text();
136 if (res.status === 403 && body.trim() === "forbidden") bare.push(`${r.method} ${r.pattern} from ${origin}`);
137 }
138 }
139 expect(bare).toEqual([]);
140 }, 60_000);

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs