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

A local forge with FORGE_URL set refuses every form, and says nothing #169

Merged opened this

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

Merged

scout/forge:scout/167-a-local-forge-with-forge-url-set-refuses → main 1 commit fork

  • Merged. scout/167-a-local-forge-with-forge-url-set-refuses was merged into main as 9b966d9.

Review

Sign in to review this change.
8 files changed +103 −8
README.md +6
@@ -34,6 +34,12 @@ bun install # dev-only: types + tsc, no runtime deps
3434 bun run src/main.ts # web on :3000, ssh on :2222
3535 ```
3636
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
3743 The first account you create becomes the admin — from the browser, the API, or
3844 a terminal (`ssh -p 2222 localhost` on a fresh forge offers to create it). Then:
3945
docs/APPS.md +5
@@ -180,6 +180,11 @@ The forge starts the app with:
180180 | `FORGE_APP_SSH` | the exact `ssh …` that opens this environment's terminal, when SSH is on |
181181 | `FORGE_URL` | the forge's own base URL |
182182
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
183188 A terminal session (`tty`) gets the same variables plus `FORGE_APP_TTY=1`
184189 and `FORGE_APP_USER`, the forge account at the keyboard or empty for a guest.
185190
src/http/api.ts +2 −2
@@ -775,7 +775,7 @@ export function apiRoutes(router: Router<Handler>): void {
775775 router.add("DELETE", "/api/v1/sessions", (ctx) => {
776776 // Cookie-authenticated logout, so it must be same-origin like any other
777777 // 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();
779779 const token = cookieValue(ctx.request, "forge_session");
780780 if (token !== undefined) destroySession(ctx.app.db, token);
781781 return json({}, 200, {
@@ -859,7 +859,7 @@ export function apiRoutes(router: Router<Handler>): void {
859859 (ctx.auth.via === "session" || ctx.auth.via === "edge") &&
860860 ctx.auth.user
861861 ) {
862 if (crossOrigin(ctx.request, ctx.url)) return forbidden();
862 if (crossOrigin(ctx.request, ctx.url, ctx.app.log)) return forbidden();
863863 user = ctx.auth.user;
864864 } else {
865865 const verified = await verifyPassword(
src/http/context.ts +11 −2
@@ -7,6 +7,7 @@ import type { App } from "../app.ts";
77 import type { TokenScope } from "../core/tokens.ts";
88 import type { User } from "../core/users.ts";
99 import type { ThemeName } from "../design/themes.ts";
10 import type { Log } from "../kernel/log.ts";
1011 import type { Mode } from "../web/mode.ts";
1112
1213 export interface Auth {
@@ -55,10 +56,18 @@ export function publicUrl(request: Request, base: string | undefined): URL {
5556 * Browser CSRF: a session-cookie mutation must originate from this forge.
5657 * Browsers always send Origin on cross-site requests; a missing Origin
5758 * 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.
5864 */
59 export function crossOrigin(request: Request, url: URL): boolean {
65 export function crossOrigin(request: Request, url: URL, log?: Log): boolean {
6066 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;
6271 }
6372
6473 /** One cookie's value from the request, or undefined when absent. */
src/http/respond.ts +5 −2
@@ -174,7 +174,8 @@ export function requireScope(
174174 needed: TokenScope,
175175 ): Response | undefined {
176176 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();
178179 if (!scopeAllows(ctx.auth.scope, needed)) return forbidden();
179180 return undefined;
180181 }
@@ -191,7 +192,9 @@ export function requireAccount(
191192 ): Response | undefined {
192193 if (!ctx.auth.user) return unauthorized();
193194 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;
195198 if (ctx.auth.via === "password") return undefined;
196199 if (
197200 options.adminToken &&
src/web/form.ts +1 −1
@@ -17,7 +17,7 @@ export async function readForm(
1717 ctx: Ctx,
1818 minScope?: TokenScope,
1919 ): Promise<Form | Response> {
20 if (crossOrigin(ctx.request, ctx.url))
20 if (crossOrigin(ctx.request, ctx.url, ctx.app.log))
2121 return new Response("forbidden", { status: 403 });
2222 if (minScope !== undefined) {
2323 if (
src/web/pages/site.ts +1 −1
@@ -1014,7 +1014,7 @@ export async function themeSubmit(ctx: Ctx): Promise<Response> {
10141014 }
10151015
10161016 export function logoutSubmit(ctx: Ctx): Response {
1017 if (crossOrigin(ctx.request, ctx.url))
1017 if (crossOrigin(ctx.request, ctx.url, ctx.app.log))
10181018 return new Response("forbidden", { status: 403 });
10191019 const cookie = ctx.request.headers.get("cookie") ?? "";
10201020 const match = cookie.match(/forge_session=([^;]+)/);
test/web.test.ts +72
@@ -753,3 +753,75 @@ describe("the security floor is on every response", () => {
753753 expect(own.headers.get("x-content-type-options")).toBe("nosniff");
754754 });
755755 });
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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs