Public: anyone can read this; the crew can change it.
A local forge with FORGE_URL set refuses every form, and says nothing #169
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/167-a-local-forge-with-forge-url-set-refuses → main 1 commit fork
8 files
changed +103
−8
README.md +6
| @@ -34,6 +34,12 @@ bun install # dev-only: types + tsc, no runtime deps | ||
| 34 | 34 | bun run src/main.ts # web on :3000, ssh on :2222 |
| 35 | 35 | |
| 36 | 36 | |
| 37 | If your shell already has `FORGE_URL` set — a crew workspace sets it to the | |
| 38 | forge you work for — this forge takes it as its own canonical address and | |
| 39 | refuses every form a browser posts to `localhost`: run it with | |
| 40 | `env -u FORGE_URL bun run src/main.ts`, or set `FORGE_URL` to wherever you | |
| 41 | will actually browse it. | |
| 42 | ||
| 37 | 43 | The first account you create becomes the admin — from the browser, the API, or |
| 38 | 44 | a terminal (`ssh -p 2222 localhost` on a fresh forge offers to create it). Then: |
| 39 | 45 |
docs/APPS.md +5
| @@ -180,6 +180,11 @@ The forge starts the app with: | ||
| 180 | 180 | | `FORGE_APP_SSH` | the exact `ssh …` that opens this environment's terminal, when SSH is on | |
| 181 | 181 | | `FORGE_URL` | the forge's own base URL | |
| 182 | 182 | |
| 183 | That last one is for the app to call the forge back on, not to run the forge | |
| 184 | software itself — a shell that already has it set (a crew workspace does) | |
| 185 | will make a locally-run forge refuse browser form posts as cross-site; see | |
| 186 | `README.md`'s "Run it". | |
| 187 | ||
| 183 | 188 | A terminal session (`tty`) gets the same variables plus `FORGE_APP_TTY=1` |
| 184 | 189 | and `FORGE_APP_USER`, the forge account at the keyboard or empty for a guest. |
| 185 | 190 |
src/http/api.ts +2 −2
| @@ -775,7 +775,7 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 775 | 775 | router.add("DELETE", "/api/v1/sessions", (ctx) => { |
| 776 | 776 | // Cookie-authenticated logout, so it must be same-origin like any other |
| 777 | 777 | // cookie mutation — otherwise it's a cross-site forced-logout. |
| 778 | if (crossOrigin(ctx.request, ctx.url)) return forbidden(); | |
| 778 | if (crossOrigin(ctx.request, ctx.url, ctx.app.log)) return forbidden(); | |
| 779 | 779 | const token = cookieValue(ctx.request, "forge_session"); |
| 780 | 780 | if (token !== undefined) destroySession(ctx.app.db, token); |
| 781 | 781 | return json({}, 200, { |
| @@ -859,7 +859,7 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 859 | 859 | (ctx.auth.via === "session" || ctx.auth.via === "edge") && |
| 860 | 860 | ctx.auth.user |
| 861 | 861 | ) { |
| 862 | if (crossOrigin(ctx.request, ctx.url)) return forbidden(); | |
| 862 | if (crossOrigin(ctx.request, ctx.url, ctx.app.log)) return forbidden(); | |
| 863 | 863 | user = ctx.auth.user; |
| 864 | 864 | } else { |
| 865 | 865 | const verified = await verifyPassword( |
src/http/context.ts +11 −2
| @@ -7,6 +7,7 @@ import type { App } from "../app.ts"; | ||
| 7 | 7 | import type { TokenScope } from "../core/tokens.ts"; |
| 8 | 8 | import type { User } from "../core/users.ts"; |
| 9 | 9 | import type { ThemeName } from "../design/themes.ts"; |
| 10 | import type { Log } from "../kernel/log.ts"; | |
| 10 | 11 | import type { Mode } from "../web/mode.ts"; |
| 11 | 12 | |
| 12 | 13 | export interface Auth { |
| @@ -55,10 +56,18 @@ export function publicUrl(request: Request, base: string | undefined): URL { | ||
| 55 | 56 | * Browser CSRF: a session-cookie mutation must originate from this forge. |
| 56 | 57 | * Browsers always send Origin on cross-site requests; a missing Origin |
| 57 | 58 | * means a non-browser client, which cookies alone cannot impersonate. |
| 59 | * | |
| 60 | * A mismatch is logged — one line, the two origins, nothing else — because | |
| 61 | * the usual cause is `FORGE_URL` naming somewhere other than where the | |
| 62 | * browser is (README's "Run it"), and that is otherwise silent: the response | |
| 63 | * stays a terse `forbidden` on purpose, so the reason has to live in the log. | |
| 58 | 64 | */ |
| 59 | export function crossOrigin(request: Request, url: URL): boolean { | |
| 65 | export function crossOrigin(request: Request, url: URL, log?: Log): boolean { | |
| 60 | 66 | const origin = request.headers.get("origin"); |
| 61 | return origin !== null && origin !== url.origin; | |
| 67 | const mismatch = origin !== null && origin !== url.origin; | |
| 68 | if (mismatch) | |
| 69 | log?.warn("refused cross-origin mutation", { origin, expected: url.origin }); | |
| 70 | return mismatch; | |
| 62 | 71 | } |
| 63 | 72 | |
| 64 | 73 | /** One cookie's value from the request, or undefined when absent. */ |
src/http/respond.ts +5 −2
| @@ -174,7 +174,8 @@ export function requireScope( | ||
| 174 | 174 | needed: TokenScope, |
| 175 | 175 | ): Response | undefined { |
| 176 | 176 | if (!ctx.auth.user || ctx.auth.scope === undefined) return unauthorized(); |
| 177 | if (browserAuth(ctx) && crossOrigin(ctx.request, ctx.url)) return forbidden(); | |
| 177 | if (browserAuth(ctx) && crossOrigin(ctx.request, ctx.url, ctx.app.log)) | |
| 178 | return forbidden(); | |
| 178 | 179 | if (!scopeAllows(ctx.auth.scope, needed)) return forbidden(); |
| 179 | 180 | return undefined; |
| 180 | 181 | } |
| @@ -191,7 +192,9 @@ export function requireAccount( | ||
| 191 | 192 | ): Response | undefined { |
| 192 | 193 | if (!ctx.auth.user) return unauthorized(); |
| 193 | 194 | if (browserAuth(ctx)) |
| 194 | return crossOrigin(ctx.request, ctx.url) ? forbidden() : undefined; | |
| 195 | return crossOrigin(ctx.request, ctx.url, ctx.app.log) | |
| 196 | ? forbidden() | |
| 197 | : undefined; | |
| 195 | 198 | if (ctx.auth.via === "password") return undefined; |
| 196 | 199 | if ( |
| 197 | 200 | options.adminToken && |
src/web/form.ts +1 −1
| @@ -17,7 +17,7 @@ export async function readForm( | ||
| 17 | 17 | ctx: Ctx, |
| 18 | 18 | minScope?: TokenScope, |
| 19 | 19 | ): Promise<Form | Response> { |
| 20 | if (crossOrigin(ctx.request, ctx.url)) | |
| 20 | if (crossOrigin(ctx.request, ctx.url, ctx.app.log)) | |
| 21 | 21 | return new Response("forbidden", { status: 403 }); |
| 22 | 22 | if (minScope !== undefined) { |
| 23 | 23 | if ( |
src/web/pages/site.ts +1 −1
| @@ -1014,7 +1014,7 @@ export async function themeSubmit(ctx: Ctx): Promise<Response> { | ||
| 1014 | 1014 | } |
| 1015 | 1015 | |
| 1016 | 1016 | export function logoutSubmit(ctx: Ctx): Response { |
| 1017 | if (crossOrigin(ctx.request, ctx.url)) | |
| 1017 | if (crossOrigin(ctx.request, ctx.url, ctx.app.log)) | |
| 1018 | 1018 | return new Response("forbidden", { status: 403 }); |
| 1019 | 1019 | const cookie = ctx.request.headers.get("cookie") ?? ""; |
| 1020 | 1020 | const match = cookie.match(/forge_session=([^;]+)/); |
test/web.test.ts +72
| @@ -753,3 +753,75 @@ describe("the security floor is on every response", () => { | ||
| 753 | 753 | expect(own.headers.get("x-content-type-options")).toBe("nosniff"); |
| 754 | 754 | }); |
| 755 | 755 | }); |
| 756 | ||
| 757 | describe("a refused cross-origin mutation says why in the log", () => { | |
| 758 | let dataDir: string; | |
| 759 | let app: App; | |
| 760 | let servers: Servers; | |
| 761 | let base: string; | |
| 762 | ||
| 763 | beforeAll(async () => { | |
| 764 | dataDir = await mkdtemp(join(tmpdir(), "forge-crossorigin-")); | |
| 765 | app = createApp({ dataDir, port: 0, url: "https://forge.example" }); | |
| 766 | servers = await serve(app); | |
| 767 | base = servers.url; | |
| 768 | }); | |
| 769 | ||
| 770 | afterAll(async () => { | |
| 771 | servers.stop(); | |
| 772 | app.close(); | |
| 773 | await rm(dataDir, { recursive: true, force: true }); | |
| 774 | }); | |
| 775 | ||
| 776 | async function post( | |
| 777 | username: string, | |
| 778 | origin: string | undefined, | |
| 779 | ): Promise<{ status: number; logs: string }> { | |
| 780 | const logs: string[] = []; | |
| 781 | const wrote = (...args: unknown[]): void => { | |
| 782 | logs.push(args.map(String).join(" ")); | |
| 783 | }; | |
| 784 | const real = { log: console.log, warn: console.warn, error: console.error }; | |
| 785 | console.log = wrote; | |
| 786 | console.warn = wrote; | |
| 787 | console.error = wrote; | |
| 788 | let response: Response; | |
| 789 | try { | |
| 790 | response = await fetch(`${base}/signup`, { | |
| 791 | method: "POST", | |
| 792 | ...(origin === undefined ? {} : { headers: { origin } }), | |
| 793 | body: new URLSearchParams({ | |
| 794 | username, | |
| 795 | email: `${username}@forge.example`, | |
| 796 | password: "not-from-here-either", | |
| 797 | }), | |
| 798 | redirect: "manual", | |
| 799 | }); | |
| 800 | } finally { | |
| 801 | console.log = real.log; | |
| 802 | console.warn = real.warn; | |
| 803 | console.error = real.error; | |
| 804 | } | |
| 805 | return { status: response.status, logs: logs.join("\n") }; | |
| 806 | } | |
| 807 | ||
| 808 | test("a mismatched origin is refused and logged with both origins", async () => { | |
| 809 | const { status, logs } = await post("mallory", base); | |
| 810 | expect(status).toBe(403); | |
| 811 | expect(logs).toContain("refused cross-origin mutation"); | |
| 812 | expect(logs).toContain(`origin=${base}`); | |
| 813 | expect(logs).toContain("expected=https://forge.example"); | |
| 814 | }); | |
| 815 | ||
| 816 | test("the matching origin passes, and logs nothing about it", async () => { | |
| 817 | const { status, logs } = await post("ada", "https://forge.example"); | |
| 818 | expect(status).toBe(303); | |
| 819 | expect(logs).not.toContain("refused cross-origin mutation"); | |
| 820 | }); | |
| 821 | ||
| 822 | test("no Origin header at all also passes, and logs nothing about it", async () => { | |
| 823 | const { status, logs } = await post("bob", undefined); | |
| 824 | expect(status).toBe(303); | |
| 825 | expect(logs).not.toContain("refused cross-origin mutation"); | |
| 826 | }); | |
| 827 | }); |
new above
offline