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" #8498
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/8491-a-refused-page-or-form-answers-the-forge → main 2 commits fork
30 files
changed +265
−74
src/http/admin_gate.ts +2 −12
| @@ -11,9 +11,7 @@ | ||
| 11 | 11 | |
| 12 | 12 | import type { App } from "../app.ts"; |
| 13 | 13 | 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"; | |
| 17 | 15 | import type { Auth, Ctx } from "./context.ts"; |
| 18 | 16 | import { json } from "./respond.ts"; |
| 19 | 17 | |
| @@ -30,13 +28,5 @@ export function adminGate(app: App, request: Request, url: URL, auth: Auth): Res | ||
| 30 | 28 | if (!lacks(app.db, auth.user, "administer")) return undefined; |
| 31 | 29 | if (url.pathname.startsWith("/api/")) return json({ error: "not_admin", message: NOT_AN_ADMIN }, 403); |
| 32 | 30 | 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); | |
| 42 | 32 | } |
src/http/server.ts +8 −2
| @@ -399,7 +399,7 @@ import { | ||
| 399 | 399 | browseTree, |
| 400 | 400 | } from "../web/pages/browse.ts"; |
| 401 | 401 | 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"; | |
| 403 | 403 | import { targetForShortCode } from "../origins/oauth.ts"; |
| 404 | 404 | import { apiRoutes } from "./api.ts"; |
| 405 | 405 | import { pageParamRefused } from "./cursor_lists.ts"; |
| @@ -2440,8 +2440,14 @@ async function answerGet( | ||
| 2440 | 2440 | } |
| 2441 | 2441 | |
| 2442 | 2442 | // 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. | |
| 2443 | 2444 | 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 | } | |
| 2445 | 2451 | |
| 2446 | 2452 | // A proposal token reaches nothing but what its gate names; every other |
| 2447 | 2453 | // 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"; | ||
| 24 | 24 | import { parentUrl, shownTrail, sourceLabel } from "../core/provenance.ts"; |
| 25 | 25 | import type { Database } from "bun:sqlite"; |
| 26 | 26 | import type { Ctx } from "../http/context.ts"; |
| 27 | import { apiError, prefersJson } from "../http/respond.ts"; | |
| 27 | 28 | import { html, raw, when, type Html } from "./html.ts"; |
| 28 | 29 | import { icon } from "./icons.ts"; |
| 29 | 30 | import { page, redirect, type PaletteItem } from "./layout.ts"; |
| @@ -97,6 +98,40 @@ export function notFoundPage(ctx: Ctx, opts: { plain?: boolean } = {}): Response | ||
| 97 | 98 | |
| 98 | 99 | export const repoNotFound = notFoundPage; |
| 99 | 100 | |
| 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 | ||
| 100 | 135 | /** Send an anonymous visitor to sign in, and back where they were going. */ |
| 101 | 136 | export function signInFirst(ctx: Ctx): Response { |
| 102 | 137 | return redirect(signInHref(ctx)); |
src/web/form.ts +3 −2
| @@ -8,6 +8,7 @@ | ||
| 8 | 8 | * enforce. Auth routes (sign in / up) pass no scope. |
| 9 | 9 | */ |
| 10 | 10 | |
| 11 | import { CAME_FROM_ELSEWHERE, forbiddenPage, SENT_FROM_ELSEWHERE } from "./chrome.ts"; | |
| 11 | 12 | import { BODY_MAX, readBounded, STRANGER_MAX } from "../http/limits.ts"; |
| 12 | 13 | import { scopeAllows, type TokenScope } from "../core/tokens.ts"; |
| 13 | 14 | import { isLocalPath } from "./flash.ts"; |
| @@ -25,13 +26,13 @@ export async function readForm( | ||
| 25 | 26 | max: number = ctx.auth.user === undefined ? STRANGER_MAX : BODY_MAX, |
| 26 | 27 | ): Promise<Form | Response> { |
| 27 | 28 | 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); | |
| 29 | 30 | if (minScope !== undefined) { |
| 30 | 31 | if ( |
| 31 | 32 | ctx.auth.scope === undefined || |
| 32 | 33 | !scopeAllows(ctx.auth.scope, minScope) |
| 33 | 34 | ) { |
| 34 | return new Response("forbidden", { status: 403 }); | |
| 35 | return forbiddenPage(ctx, "This token is not allowed to do this."); | |
| 35 | 36 | } |
| 36 | 37 | } |
| 37 | 38 | // 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 { | ||
| 52 | 52 | settingsLayout, |
| 53 | 53 | signInFirst, |
| 54 | 54 | type SettingsSection, |
| 55 | forbiddenPage, | |
| 55 | 56 | } from "../chrome.ts"; |
| 56 | 57 | import { accountSections } from "./origins.ts"; |
| 57 | 58 | import { field, readForm } from "../form.ts"; |
| @@ -713,7 +714,7 @@ export async function tokenCreate(ctx: Ctx): Promise<Response> { | ||
| 713 | 714 | // the platform edge's sign-in, or the password — never from another token |
| 714 | 715 | // (no privilege bootstrapping). readForm below refuses a cross-site post. |
| 715 | 716 | 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."); | |
| 717 | 718 | } |
| 718 | 719 | const form = await readForm(ctx, "admin"); |
| 719 | 720 | if (form instanceof Response) return form; |
src/web/pages/apps.ts +3 −1
| @@ -63,6 +63,8 @@ import { | ||
| 63 | 63 | pageHead, |
| 64 | 64 | repoBase, |
| 65 | 65 | signInFirst, |
| 66 | NOT_YOURS_TO_DO, | |
| 67 | forbiddenPage, | |
| 66 | 68 | } from "../chrome.ts"; |
| 67 | 69 | import { field, readForm } from "../form.ts"; |
| 68 | 70 | import { html, when, type Html } from "../html.ts"; |
| @@ -500,7 +502,7 @@ export async function newAppSubmit(ctx: Ctx): Promise<Response> { | ||
| 500 | 502 | } |
| 501 | 503 | if (!made.ok) { |
| 502 | 504 | if (made.error === "forbidden_owner" || made.error === "unauthorized") |
| 503 | return new Response("forbidden", { status: 403 }); | |
| 505 | return forbiddenPage(ctx, NOT_YOURS_TO_DO); | |
| 504 | 506 | const ownerId = getUserByName( |
| 505 | 507 | ctx.app.db, |
| 506 | 508 | values.owner || ctx.auth.user.username, |
src/web/pages/claim.ts +2 −2
| @@ -49,7 +49,7 @@ import { | ||
| 49 | 49 | type ClaimState, |
| 50 | 50 | type Spawned, |
| 51 | 51 | } from "../../spawn/claim.ts"; |
| 52 | import { pageHead } from "../chrome.ts"; | |
| 52 | import { pageHead, CAME_FROM_ELSEWHERE } from "../chrome.ts"; | |
| 53 | 53 | import { field, readForm } from "../form.ts"; |
| 54 | 54 | import { html, type Html } from "../html.ts"; |
| 55 | 55 | import { page, redirect } from "../layout.ts"; |
| @@ -268,7 +268,7 @@ function problems(ctx: Ctx, username: string, email: string, needEmail: boolean) | ||
| 268 | 268 | export async function claimOptions(ctx: Ctx): Promise<Response> { |
| 269 | 269 | const refuse = (status: number, message: string) => |
| 270 | 270 | 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); | |
| 272 | 272 | const wait = signinAllowed(ctx, `claim:${clientAddress(ctx.request)}`); |
| 273 | 273 | if (wait !== undefined) |
| 274 | 274 | 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 | ||
| 11 | 11 | import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts"; |
| 12 | 12 | import { ASKING } from "../../crew/conversation_confirm.ts"; |
| 13 | 13 | import type { Ctx } from "../../http/context.ts"; |
| 14 | import { settingsLayout, signInFirst } from "../chrome.ts"; | |
| 14 | import { settingsLayout, signInFirst, forbiddenPage } from "../chrome.ts"; | |
| 15 | 15 | import { readForm } from "../form.ts"; |
| 16 | 16 | import { html, type Html } from "../html.ts"; |
| 17 | 17 | import { flashTo, page } from "../layout.ts"; |
| @@ -70,7 +70,7 @@ export async function confirmYes(ctx: Ctx): Promise<Response> { | ||
| 70 | 70 | const user = ctx.auth.user; |
| 71 | 71 | if (user === undefined) return signInFirst(ctx); |
| 72 | 72 | // 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."); | |
| 74 | 74 | const form = await readForm(ctx); |
| 75 | 75 | if (form instanceof Response) return form; |
| 76 | 76 | const yes = await sayYes(ctx, user, ctx.params["id"] ?? ""); |
src/web/pages/conflicts.ts +2 −2
| @@ -26,7 +26,7 @@ import { | ||
| 26 | 26 | } from "../../git/conflicts.ts"; |
| 27 | 27 | import { notePhase, takeSessionTime, timePhase, timePhaseAsync } from "../../core/route_timing.ts"; |
| 28 | 28 | 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"; | |
| 30 | 30 | import { FINISH_REFUSALS, finishConflicts } from "../../actions/finish-conflicts.ts"; |
| 31 | 31 | import { RESOLUTION_ASKED } from "../../actions/pull_ask_words.ts"; |
| 32 | 32 | import { field, readForm } from "../form.ts"; |
| @@ -283,7 +283,7 @@ export async function conflictsFinish(ctx: Ctx): Promise<Response> { | ||
| 283 | 283 | if (found instanceof Response) return found; |
| 284 | 284 | const { repo, pull } = found; |
| 285 | 285 | 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."); | |
| 287 | 287 | const form = await readForm(ctx, "rw"); |
| 288 | 288 | if (form instanceof Response) return form; |
| 289 | 289 | const dest = `${repoBase(repo)}/pulls/${pull.number}`; |
src/web/pages/everyday.ts +5 −5
| @@ -24,7 +24,7 @@ import { addPasskey } from "../../core/passkeys.ts"; | ||
| 24 | 24 | import { cookieValue, crossOrigin, type Ctx } from "../../http/context.ts"; |
| 25 | 25 | import { STRANGER_MAX } from "../../http/limits.ts"; |
| 26 | 26 | 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"; | |
| 28 | 28 | import { field, readForm } from "../form.ts"; |
| 29 | 29 | import { html, type Html } from "../html.ts"; |
| 30 | 30 | import { flashTo, redirect } from "../layout.ts"; |
| @@ -142,11 +142,11 @@ function nameProblem(ctx: Ctx, username: string): string | undefined { | ||
| 142 | 142 | export async function everydayOptions(ctx: Ctx): Promise<Response> { |
| 143 | 143 | const refuse = (status: number, message: string) => |
| 144 | 144 | 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); | |
| 146 | 146 | const admin = ctx.auth.user; |
| 147 | 147 | if (!admin) return refuse(401, "Sign in first."); |
| 148 | 148 | 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); | |
| 150 | 150 | if (everydayOf(ctx.app.db, admin) !== undefined) |
| 151 | 151 | return refuse(409, "You already have an everyday account."); |
| 152 | 152 | const data = await boundedBody(ctx.request, STRANGER_MAX); |
| @@ -163,7 +163,7 @@ export async function everydayCreate(ctx: Ctx): Promise<Response> { | ||
| 163 | 163 | if (!admin) return signInFirst(ctx); |
| 164 | 164 | const form = await readForm(ctx, "admin"); |
| 165 | 165 | 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); | |
| 167 | 167 | const existing = everydayOf(ctx.app.db, admin); |
| 168 | 168 | if (existing !== undefined) |
| 169 | 169 | return flashTo(ctx, "/settings", { |
| @@ -231,7 +231,7 @@ export async function everydayKeep(ctx: Ctx): Promise<Response> { | ||
| 231 | 231 | if (!admin) return signInFirst(ctx); |
| 232 | 232 | const form = await readForm(ctx, "admin"); |
| 233 | 233 | 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); | |
| 235 | 235 | keepAdmin(ctx.app.db, admin); |
| 236 | 236 | return flashTo(ctx, "/", { |
| 237 | 237 | message: |
src/web/pages/first-admin.ts +2 −2
| @@ -17,7 +17,7 @@ import { FIRST_ADMIN_WORDS, firstAdminProblem, redeemFirstAdmin } from "../../ac | ||
| 17 | 17 | import { addPasskey } from "../../core/passkeys.ts"; |
| 18 | 18 | import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts"; |
| 19 | 19 | import { createSession } from "../../core/sessions.ts"; |
| 20 | import { pageHead } from "../chrome.ts"; | |
| 20 | import { pageHead, CAME_FROM_ELSEWHERE } from "../chrome.ts"; | |
| 21 | 21 | import { field, readForm } from "../form.ts"; |
| 22 | 22 | import { html, type Html } from "../html.ts"; |
| 23 | 23 | import { page, redirect } from "../layout.ts"; |
| @@ -127,7 +127,7 @@ const statusOf = (error: string): number => | ||
| 127 | 127 | |
| 128 | 128 | // POST /first-admin/options — a passkey challenge for a live code and a fit name. Nothing is spent. |
| 129 | 129 | 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); | |
| 131 | 131 | const data = await boundedBody(ctx.request, STRANGER_MAX); |
| 132 | 132 | if (data === "too_large") return json({ error: "refused", message: "payload too large" }, 413); |
| 133 | 133 | const username = str(data.username).trim(); |
src/web/pages/migrate.ts +3 −3
| @@ -48,7 +48,7 @@ import { | ||
| 48 | 48 | } from "../../core/route_timing.ts"; |
| 49 | 49 | import type { Ctx } from "../../http/context.ts"; |
| 50 | 50 | 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"; | |
| 52 | 52 | import { field, readForm } from "../form.ts"; |
| 53 | 53 | import { html, raw, type Html } from "../html.ts"; |
| 54 | 54 | import { flashTo, page, redirect } from "../layout.ts"; |
| @@ -720,7 +720,7 @@ export async function migrateSubmit(ctx: Ctx): Promise<Response> { | ||
| 720 | 720 | picked !== undefined && |
| 721 | 721 | (ctx.auth.scope === undefined || !scopeAllows(ctx.auth.scope, "admin")) |
| 722 | 722 | ) |
| 723 | return new Response("forbidden", { status: 403 }); | |
| 723 | return forbiddenPage(ctx, "This token is not allowed to do this."); | |
| 724 | 724 | |
| 725 | 725 | const started = await migrateRepo(ctx.app, ctx.auth.user, { |
| 726 | 726 | source: picked ?? { url: values.url }, |
| @@ -737,7 +737,7 @@ export async function migrateSubmit(ctx: Ctx): Promise<Response> { | ||
| 737 | 737 | }); |
| 738 | 738 | if (!started.ok) { |
| 739 | 739 | if (started.error === "forbidden_owner" || started.error === "unauthorized") |
| 740 | return new Response("forbidden", { status: 403 }); | |
| 740 | return forbiddenPage(ctx, NOT_YOURS_TO_DO); | |
| 741 | 741 | return again( |
| 742 | 742 | migrateMessage(started.error, repositoryLimitMessage(ctx.app, ctx.auth.user!)), |
| 743 | 743 | ); |
src/web/pages/milestones.ts +3 −1
| @@ -52,6 +52,8 @@ import { | ||
| 52 | 52 | repoHeader, |
| 53 | 53 | repoPalette, |
| 54 | 54 | signInFirst, |
| 55 | NOT_YOURS_TO_DO, | |
| 56 | forbiddenPage, | |
| 55 | 57 | } from "../chrome.ts"; |
| 56 | 58 | import { html, when, type Html } from "../html.ts"; |
| 57 | 59 | import { field, readForm } from "../form.ts"; |
| @@ -624,7 +626,7 @@ export async function milestoneWatch(ctx: Ctx): Promise<Response> { | ||
| 624 | 626 | if (!set.ok) |
| 625 | 627 | return set.error === "not_found" |
| 626 | 628 | ? notFoundPage(ctx) |
| 627 | : new Response("forbidden", { status: 403 }); | |
| 629 | : forbiddenPage(ctx, NOT_YOURS_TO_DO); | |
| 628 | 630 | return redirect(milestoneHref(repoBase(repo), number)); |
| 629 | 631 | } |
| 630 | 632 |
src/web/pages/oauth.ts +2 −2
| @@ -16,7 +16,7 @@ import { | ||
| 16 | 16 | } from "../../core/oauth.ts"; |
| 17 | 17 | import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts"; |
| 18 | 18 | import type { Ctx } from "../../http/context.ts"; |
| 19 | import { pageHead } from "../chrome.ts"; | |
| 19 | import { pageHead, SIGN_IN_TO_DO, forbiddenPage } from "../chrome.ts"; | |
| 20 | 20 | import { field, readForm } from "../form.ts"; |
| 21 | 21 | import { html, type Html } from "../html.ts"; |
| 22 | 22 | import { page, redirect } from "../layout.ts"; |
| @@ -202,7 +202,7 @@ export async function authorizeSubmit(ctx: Ctx): Promise<Response> { | ||
| 202 | 202 | const form = await readForm(ctx); |
| 203 | 203 | if (form instanceof Response) return form; |
| 204 | 204 | if (ctx.auth.user === undefined || ctx.auth.via === "token") |
| 205 | return new Response("forbidden", { status: 403 }); | |
| 205 | return forbiddenPage(ctx, SIGN_IN_TO_DO); | |
| 206 | 206 | const request = parseAuthRequest(ctx.app.db, (n) => field(form, n)); |
| 207 | 207 | if (!request.ok) return refused(ctx, request.error); |
| 208 | 208 | const { redirectUri, state } = request.value; |
src/web/pages/origins.ts +5 −3
| @@ -57,6 +57,8 @@ import { | ||
| 57 | 57 | settingsLayout, |
| 58 | 58 | signInFirst, |
| 59 | 59 | type SettingsSection, |
| 60 | NOT_ADMIN_HERE, | |
| 61 | forbiddenPage, | |
| 60 | 62 | } from "../chrome.ts"; |
| 61 | 63 | import { field, readForm } from "../form.ts"; |
| 62 | 64 | import { html, when, type Html } from "../html.ts"; |
| @@ -277,7 +279,7 @@ export function accountOnly(ctx: Ctx): Response | undefined { | ||
| 277 | 279 | if (ctx.auth.via === "session" || ctx.auth.via === "password") |
| 278 | 280 | return undefined; |
| 279 | 281 | 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."); | |
| 281 | 283 | } |
| 282 | 284 | |
| 283 | 285 | // ---- the origin list ---- |
| @@ -1023,14 +1025,14 @@ export async function originTrustedHosts(ctx: Ctx): Promise<Response> { | ||
| 1023 | 1025 | const denied = accountOnly(ctx); |
| 1024 | 1026 | if (denied) return denied; |
| 1025 | 1027 | if (!mayEditTrustedHosts(ctx.auth.user)) |
| 1026 | return new Response("forbidden", { status: 403 }); | |
| 1028 | return forbiddenPage(ctx, NOT_ADMIN_HERE); | |
| 1027 | 1029 | const form = await readForm(ctx, "admin"); |
| 1028 | 1030 | if (form instanceof Response) return form; |
| 1029 | 1031 | const text = field(form, "hosts"); |
| 1030 | 1032 | const saved = updateTrustedHosts(ctx.app.db, ctx.auth.user, text); |
| 1031 | 1033 | if (!saved.ok) { |
| 1032 | 1034 | if (saved.error === "forbidden") |
| 1033 | return new Response("forbidden", { status: 403 }); | |
| 1035 | return forbiddenPage(ctx, NOT_ADMIN_HERE); | |
| 1034 | 1036 | const values = blankValues(); |
| 1035 | 1037 | values.trustedText = text.slice(0, 4000); |
| 1036 | 1038 | 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 | ||
| 27 | 27 | import { clientAddress, clientBucket } from "../../http/peer.ts"; |
| 28 | 28 | import { STRANGER_MAX } from "../../http/limits.ts"; |
| 29 | 29 | import { boundedBody, json, str } from "../../http/respond.ts"; |
| 30 | import { signInFirst } from "../chrome.ts"; | |
| 30 | import { CAME_FROM_ELSEWHERE, signInFirst } from "../chrome.ts"; | |
| 31 | 31 | import { field, readForm } from "../form.ts"; |
| 32 | 32 | import { html } from "../html.ts"; |
| 33 | 33 | import { flashTo, page } from "../layout.ts"; |
| @@ -56,7 +56,7 @@ const refuse = (status: number, message: string) => | ||
| 56 | 56 | |
| 57 | 57 | /** A signed-in browser, on a same-origin request; else the refusal to send. */ |
| 58 | 58 | 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); | |
| 60 | 60 | if (!ctx.auth.user) return refuse(401, "Sign in first."); |
| 61 | 61 | if (ctx.auth.via !== "session") return refuse(403, "Passkeys are changed from a signed-in browser."); |
| 62 | 62 | return undefined; |
| @@ -169,7 +169,7 @@ export async function passkeyDelete(ctx: Ctx): Promise<Response> { | ||
| 169 | 169 | |
| 170 | 170 | // POST /signin/passkey/options — a challenge for someone with no session yet. |
| 171 | 171 | 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); | |
| 173 | 173 | const wait = signinAllowed(ctx, `passkey:${clientAddress(ctx.request)}`, { forgeWide: false }); |
| 174 | 174 | if (wait !== undefined) |
| 175 | 175 | 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 { | ||
| 178 | 178 | |
| 179 | 179 | // POST /signin/passkey — a good assertion gets the session a password gets. |
| 180 | 180 | 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); | |
| 182 | 182 | const wait = signinAllowed(ctx, `passkey:${clientAddress(ctx.request)}`, { forgeWide: false }); |
| 183 | 183 | if (wait !== undefined) |
| 184 | 184 | 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> { | ||
| 293 | 293 | |
| 294 | 294 | // POST /recover/options — a challenge, for a link that still works. |
| 295 | 295 | 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); | |
| 297 | 297 | const data = await boundedBody(ctx.request, STRANGER_MAX); |
| 298 | 298 | if (data === "too_large") return refuse(413, "payload too large"); |
| 299 | 299 | const user = recoveryAccount(ctx.app, str(data.token)); |
| @@ -313,7 +313,7 @@ export async function recoverOptions(ctx: Ctx): Promise<Response> { | ||
| 313 | 313 | |
| 314 | 314 | // POST /recover/passkey — a good registration spends the link and signs in. |
| 315 | 315 | 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); | |
| 317 | 317 | const data = await boundedBody(ctx.request, STRANGER_MAX); |
| 318 | 318 | if (data === "too_large") return refuse(413, "payload too large"); |
| 319 | 319 | const token = str(data.token); |
src/web/pages/proposal-tokens.ts +4 −4
| @@ -8,7 +8,7 @@ | ||
| 8 | 8 | import { listProposals, mintProposal, revokeProposal } from "../../actions/proposal_tokens.ts"; |
| 9 | 9 | import { canWrite, type Repo } from "../../core/repos.ts"; |
| 10 | 10 | 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"; | |
| 12 | 12 | import { field, readForm } from "../form.ts"; |
| 13 | 13 | import { html, type Html } from "../html.ts"; |
| 14 | 14 | import { flashTo } from "../layout.ts"; |
| @@ -69,7 +69,7 @@ async function writerForm(ctx: Ctx) { | ||
| 69 | 69 | const form = await readForm(ctx, "rw"); |
| 70 | 70 | if (form instanceof Response) return form; |
| 71 | 71 | 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."); | |
| 73 | 73 | return { repo, form }; |
| 74 | 74 | } |
| 75 | 75 | |
| @@ -80,7 +80,7 @@ export async function proposalTokenMint(ctx: Ctx): Promise<Response> { | ||
| 80 | 80 | const { repo, form } = gate; |
| 81 | 81 | // Never minted by another token: a credential cannot grow credentials. |
| 82 | 82 | 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."); | |
| 84 | 84 | const minted = mintProposal(ctx.app, ctx.auth.user, repo, { |
| 85 | 85 | child: field(form, "child"), |
| 86 | 86 | origin: field(form, "origin"), |
| @@ -89,7 +89,7 @@ export async function proposalTokenMint(ctx: Ctx): Promise<Response> { | ||
| 89 | 89 | if (!minted.ok) |
| 90 | 90 | return minted.error.code === "invalid_child" || minted.error.code === "invalid_origin" |
| 91 | 91 | ? flashTo(ctx, back, { tone: "danger", message: minted.error.message }) |
| 92 | : new Response("forbidden", { status: 403 }); | |
| 92 | : forbiddenPage(ctx, NOT_YOURS_TO_DO); | |
| 93 | 93 | // Post-redirect-get: the secret rides a one-shot flash, never the page. |
| 94 | 94 | return flashTo(ctx, back, { |
| 95 | 95 | message: "Proposal token minted — copy it now, it is never shown again.", |
src/web/pages/pull.ts +4 −3
| @@ -66,6 +66,7 @@ import { | ||
| 66 | 66 | repoHeader, |
| 67 | 67 | repoPalette, |
| 68 | 68 | signInFirst, |
| 69 | forbiddenPage, | |
| 69 | 70 | } from "../chrome.ts"; |
| 70 | 71 | import { notePhase, takeSessionTime } from "../../core/route_timing.ts"; |
| 71 | 72 | import type { DiffLaps } from "../../git/diff.ts"; |
| @@ -1351,7 +1352,7 @@ export async function pullChecksSubmit(ctx: Ctx): Promise<Response> { | ||
| 1351 | 1352 | const found = pullContext(ctx); |
| 1352 | 1353 | if (found instanceof Response) return found; |
| 1353 | 1354 | 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."); | |
| 1355 | 1356 | const form = await readForm(ctx, "rw"); |
| 1356 | 1357 | if (form instanceof Response) return form; |
| 1357 | 1358 | // 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> { | ||
| 1372 | 1373 | const found = pullContext(ctx); |
| 1373 | 1374 | if (found instanceof Response) return found; |
| 1374 | 1375 | 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."); | |
| 1376 | 1377 | const form = await readForm(ctx, "rw"); |
| 1377 | 1378 | if (form instanceof Response) return form; |
| 1378 | 1379 | |
| @@ -1400,7 +1401,7 @@ export async function pullLandPastSubmit(ctx: Ctx): Promise<Response> { | ||
| 1400 | 1401 | if (found instanceof Response) return found; |
| 1401 | 1402 | const user = ctx.auth.user; |
| 1402 | 1403 | 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."); | |
| 1404 | 1405 | const form = await readForm(ctx, "rw"); |
| 1405 | 1406 | if (form instanceof Response) return form; |
| 1406 | 1407 | 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"; | ||
| 14 | 14 | import { listReleases, type Release } from "../../release/record.ts"; |
| 15 | 15 | import { BOOTED, canRollBackTo, mayRead, mayRollBack, rollBackTo } from "../../release/gate.ts"; |
| 16 | 16 | 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"; | |
| 18 | 18 | import { readForm } from "../form.ts"; |
| 19 | 19 | import { html, when, type Html } from "../html.ts"; |
| 20 | 20 | import { flashTo, page } from "../layout.ts"; |
| @@ -91,10 +91,10 @@ export async function releasesPage(ctx: Ctx): Promise<Response> { | ||
| 91 | 91 | |
| 92 | 92 | export async function rollbackSubmit(ctx: Ctx): Promise<Response> { |
| 93 | 93 | 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); | |
| 95 | 95 | const form = await readForm(ctx, "rw"); |
| 96 | 96 | 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); | |
| 98 | 98 | const target = ctx.params["sha"] ?? ""; |
| 99 | 99 | const refuse = (message: string): Response => |
| 100 | 100 | 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"; | ||
| 15 | 15 | import { STRANGER_MAX } from "../../http/limits.ts"; |
| 16 | 16 | import { clientAddress, clientBucket } from "../../http/peer.ts"; |
| 17 | 17 | import { boundedBody, json, str } from "../../http/respond.ts"; |
| 18 | import { CAME_FROM_ELSEWHERE } from "../chrome.ts"; | |
| 18 | 19 | import { relying, store } from "./passkeys.ts"; |
| 19 | 20 | import { EDGE_NAME_MESSAGE, inviteOf, pinnedTo, safeNext, sessionCookie, signinAllowed, signupSlot } from "./site.ts"; |
| 20 | 21 | 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 { | ||
| 50 | 51 | } |
| 51 | 52 | |
| 52 | 53 | 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); | |
| 54 | 55 | const wait = signinAllowed(ctx, `signup:${clientAddress(ctx.request)}`); |
| 55 | 56 | return wait === undefined ? undefined : tooMany(wait); |
| 56 | 57 | } |
src/web/pages/site.ts +11 −3
| @@ -30,7 +30,15 @@ import { studioDoor, studioHome } from "../studio/home.ts"; | ||
| 30 | 30 | import { parentIssuerOf } from "../../core/federation.ts"; |
| 31 | 31 | import { activeEdgeToken, edgeIdentity, edgeLoginUrl, edgeStrangerLogin } from "../../http/edge.ts"; |
| 32 | 32 | 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"; | |
| 34 | 42 | import { SignupSlot, signupWindow } from "../../actions/signup-window.ts"; |
| 35 | 43 | import { signinWindow } from "../../core/signin-window.ts"; |
| 36 | 44 | import { field, readForm } from "../form.ts"; |
| @@ -712,7 +720,7 @@ export async function themeSubmit(ctx: Ctx): Promise<Response> { | ||
| 712 | 720 | |
| 713 | 721 | export function logoutSubmit(ctx: Ctx): Response { |
| 714 | 722 | 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); | |
| 716 | 724 | signOutHere(ctx.app, cookieValue(ctx.request, "forge_session")); |
| 717 | 725 | return redirect("/", sessionCookie("", new Date(0), ctx.url, ctx.app.config.url)); |
| 718 | 726 | } |
| @@ -835,7 +843,7 @@ export async function newRepoSubmit(ctx: Ctx): Promise<Response> { | ||
| 835 | 843 | }); |
| 836 | 844 | if (!created.ok) { |
| 837 | 845 | if (created.error === "forbidden_owner" || created.error === "unauthorized") |
| 838 | return new Response("forbidden", { status: 403 }); | |
| 846 | return forbiddenPage(ctx, NOT_YOURS_TO_DO); | |
| 839 | 847 | const messages: Record<string, string> = { |
| 840 | 848 | not_member: `Not made: ${NOT_A_MEMBER}.`, |
| 841 | 849 | 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"; | ||
| 21 | 21 | import type { Ctx } from "../../http/context.ts"; |
| 22 | 22 | import { json } from "../../http/respond.ts"; |
| 23 | 23 | import { eventStream } from "../../http/sse.ts"; |
| 24 | import { signInFirst } from "../chrome.ts"; | |
| 24 | import { signInFirst, forbiddenPage } from "../chrome.ts"; | |
| 25 | 25 | import { readForm } from "../form.ts"; |
| 26 | 26 | import { html, type Html } from "../html.ts"; |
| 27 | 27 | import { flashTo, page } from "../layout.ts"; |
| @@ -153,7 +153,7 @@ export async function talkSend(ctx: Ctx): Promise<Response> { | ||
| 153 | 153 | export async function talkConfirm(ctx: Ctx): Promise<Response> { |
| 154 | 154 | const user = ctx.auth.user; |
| 155 | 155 | 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."); | |
| 157 | 157 | const form = await readForm(ctx); |
| 158 | 158 | if (form instanceof Response) return form; |
| 159 | 159 | const answer = form.get("answer"); |
src/web/pages/variants.ts +3 −1
| @@ -18,6 +18,8 @@ import { | ||
| 18 | 18 | notFoundPage, |
| 19 | 19 | repoBase, |
| 20 | 20 | signInFirst, |
| 21 | NOT_YOURS_TO_DO, | |
| 22 | forbiddenPage, | |
| 21 | 23 | } from "../chrome.ts"; |
| 22 | 24 | import { field, readForm } from "../form.ts"; |
| 23 | 25 | import { html, when, type Html } from "../html.ts"; |
| @@ -137,7 +139,7 @@ export const variantVoteSubmit = | ||
| 137 | 139 | ); |
| 138 | 140 | if (!voted.ok) { |
| 139 | 141 | if (voted.error === "forbidden") |
| 140 | return new Response("forbidden", { status: 403 }); | |
| 142 | return forbiddenPage(ctx, NOT_YOURS_TO_DO); | |
| 141 | 143 | return flashTo(ctx, dest, { |
| 142 | 144 | tone: "danger", |
| 143 | 145 | message: |
src/web/studio/asks_page.ts +2 −2
| @@ -30,7 +30,7 @@ import type { User } from "../../core/users.ts"; | ||
| 30 | 30 | import { onYouAnswer, onYouRegion } from "./home.ts"; |
| 31 | 31 | import { liveRegionsJs } from "../client.ts"; |
| 32 | 32 | 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"; | |
| 34 | 34 | import { backTo, field, readForm } from "../form.ts"; |
| 35 | 35 | import { MAX_BODY } from "../../core/issues.ts"; |
| 36 | 36 | import { issueTitles } from "../../core/proposals.ts"; |
| @@ -759,7 +759,7 @@ export function asksProposal(verb: ProposalVerb) { | ||
| 759 | 759 | return done.error.code === "not_found" |
| 760 | 760 | ? notFoundPage(ctx) |
| 761 | 761 | : done.error.code === "forbidden" || done.error.code === "unauthorized" |
| 762 | ? new Response("forbidden", { status: 403 }) | |
| 762 | ? forbiddenPage(ctx, NOT_YOURS_TO_DO) | |
| 763 | 763 | : await asksScreen(ctx, nothingDone(done.error.message)); |
| 764 | 764 | return redirect(backTo(form, "/asks")); |
| 765 | 765 | }; |
src/web/studio/home.ts +2 −2
| @@ -25,7 +25,7 @@ import { type User } from "../../core/users.ts"; | ||
| 25 | 25 | import type { Ctx } from "../../http/context.ts"; |
| 26 | 26 | import { activeEdgeToken, edgeLoginUrl, edgeStrangerLogin } from "../../http/edge.ts"; |
| 27 | 27 | import { sshCommand } from "../../ssh/command.ts"; |
| 28 | import { repoBase, signInFirst } from "../chrome.ts"; | |
| 28 | import { repoBase, signInFirst, NOT_ADMIN_HERE, forbiddenPage } from "../chrome.ts"; | |
| 29 | 29 | import { field, readForm, type Form } from "../form.ts"; |
| 30 | 30 | import { liveRegionsJs } from "../client.ts"; |
| 31 | 31 | import { html, when, type Html } from "../html.ts"; |
| @@ -709,7 +709,7 @@ export async function frontUpdate(ctx: Ctx): Promise<Response> { | ||
| 709 | 709 | if (!ctx.auth.user) return signInFirst(ctx); |
| 710 | 710 | const form = await readForm(ctx, "rw"); |
| 711 | 711 | 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); | |
| 713 | 713 | const next = nextPicks(frontPicksJson(ctx.app), field(form, "action"), form); |
| 714 | 714 | if (next === undefined) return new Response("bad request", { status: 400 }); |
| 715 | 715 | const set = setFront(ctx.app, ctx.auth.user, next); |
src/web/studio/home_archive.ts +2 −2
| @@ -8,7 +8,7 @@ | ||
| 8 | 8 | import { applyFleetIds, getFleetOp, undoFleetOp } from "../../actions/fleet.ts"; |
| 9 | 9 | import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts"; |
| 10 | 10 | import type { Ctx } from "../../http/context.ts"; |
| 11 | import { signInFirst } from "../chrome.ts"; | |
| 11 | import { signInFirst, NOT_ADMIN_HERE, forbiddenPage } from "../chrome.ts"; | |
| 12 | 12 | import { readForm } from "../form.ts"; |
| 13 | 13 | import { html } from "../html.ts"; |
| 14 | 14 | import { page, redirect } from "../layout.ts"; |
| @@ -29,7 +29,7 @@ export async function homeArchive(ctx: Ctx): Promise<Response> { | ||
| 29 | 29 | if (!ctx.auth.user) return signInFirst(ctx); |
| 30 | 30 | const form = await readForm(ctx, "admin"); |
| 31 | 31 | 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); | |
| 33 | 33 | const ids = picked(form); |
| 34 | 34 | if (ids.length === 0 || ids.length > MAX_ROWS) |
| 35 | 35 | return redirect("/", { |
src/web/studio/keeper_row.ts +2 −2
| @@ -8,7 +8,7 @@ | ||
| 8 | 8 | import type { Database } from "bun:sqlite"; |
| 9 | 9 | import { answerKeeper, keeperLine, readKeeper } from "../../flow/keeper.ts"; |
| 10 | 10 | import type { Ctx } from "../../http/context.ts"; |
| 11 | import { signInFirst } from "../chrome.ts"; | |
| 11 | import { signInFirst, NOT_ADMIN_HERE, forbiddenPage } from "../chrome.ts"; | |
| 12 | 12 | import { readForm } from "../form.ts"; |
| 13 | 13 | import { html, type Html } from "../html.ts"; |
| 14 | 14 | import { redirect } from "../layout.ts"; |
| @@ -33,7 +33,7 @@ export async function keeperAnswer(ctx: Ctx): Promise<Response> { | ||
| 33 | 33 | if (!ctx.auth.user) return signInFirst(ctx); |
| 34 | 34 | const form = await readForm(ctx, "admin"); |
| 35 | 35 | 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); | |
| 37 | 37 | const raw = form.get("id"); |
| 38 | 38 | const id = typeof raw === "string" && /^\d{1,12}$/.test(raw) ? Number(raw) : 0; |
| 39 | 39 | 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"; | ||
| 33 | 33 | import { repoDir } from "../../git/repos.ts"; |
| 34 | 34 | import { notePhase, takeSessionTime, timePhase, timePhaseAsync } from "../../core/route_timing.ts"; |
| 35 | 35 | 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"; | |
| 37 | 37 | import { diffTotals, pathAnchor } from "../diff-view.ts"; |
| 38 | 38 | import { field, readForm } from "../form.ts"; |
| 39 | 39 | import { html, when, type Html } from "../html.ts"; |
| @@ -282,7 +282,7 @@ export async function toolbarComment(ctx: Ctx): Promise<Response> { | ||
| 282 | 282 | const form = await readForm(ctx, "rw"); |
| 283 | 283 | if (form instanceof Response) return form; |
| 284 | 284 | const user = ctx.auth.user; |
| 285 | if (!user) return new Response("forbidden", { status: 403 }); | |
| 285 | if (!user) return forbiddenPage(ctx, SIGN_IN_TO_DO); | |
| 286 | 286 | const where = parseEnv(field(form, "env")); |
| 287 | 287 | if (where === undefined) return new Response("bad request", { status: 400 }); |
| 288 | 288 | const db = ctx.app.db; |
test/admin-gate.test.ts +1 −1
| @@ -129,7 +129,7 @@ describe("the admin gate", () => { | ||
| 129 | 129 | expect(res.status, path).toBe(403); |
| 130 | 130 | expect(res.headers.get("content-type")).toContain("text/html"); |
| 131 | 131 | const body = await res.text(); |
| 132 | expect(body).toContain("<h1>403</h1>"); | |
| 132 | expect(body).toContain("<h1>You can't do that here</h1>"); | |
| 133 | 133 | expect(body).toContain(NOT_AN_ADMIN.replace("'", "'")); |
| 134 | 134 | } |
| 135 | 135 | } |
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("'", "'"); | |
| 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("<b>no</b>"); | |
| 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); |
new above
offline