Public: anyone can read this; the crew can change it.
Every /__forge/* answer on an app host wears the security floor, JSON included #6115
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/6094-every-forge-answer-on-an-app-host-wears → main 3 commits fork 2 threads · 2 unresolved
2 files
changed +80
−33
src/apps/proxy.ts +47 −33
| @@ -582,7 +582,7 @@ export async function proxyApp( | ||
| 582 | 582 | !target.nested || |
| 583 | 583 | verifyGrant(runtime.app.config.dataDir, token, target) |
| 584 | 584 | ) |
| 585 | return grant(runtime, request, url, target); | |
| 585 | return securityFloor(grant(runtime, request, url, target)); | |
| 586 | 586 | } |
| 587 | 587 | // The two doors an app links to: sign in sends the visitor to the forge, |
| 588 | 588 | // which vouches for them and sends them back named; sign out drops the |
| @@ -591,23 +591,27 @@ export async function proxyApp( | ||
| 591 | 591 | const open = new URL("/apps/open", runtime.options.forgeUrl); |
| 592 | 592 | open.searchParams.set("host", request.headers.get("host") ?? ""); |
| 593 | 593 | open.searchParams.set("to", safePath(url.searchParams.get("to"))); |
| 594 | return new Response(null, { | |
| 595 | status: 303, | |
| 596 | headers: { location: open.toString(), "cache-control": "no-store" }, | |
| 597 | }); | |
| 594 | return securityFloor( | |
| 595 | new Response(null, { | |
| 596 | status: 303, | |
| 597 | headers: { location: open.toString(), "cache-control": "no-store" }, | |
| 598 | }), | |
| 599 | ); | |
| 598 | 600 | } |
| 599 | 601 | if (url.pathname === "/__forge/signout" && !target.nested) { |
| 600 | 602 | const secure = |
| 601 | 603 | url.protocol === "https:" || |
| 602 | 604 | request.headers.get("x-forwarded-proto") === "https"; |
| 603 | return new Response(null, { | |
| 604 | status: 303, | |
| 605 | headers: { | |
| 606 | location: safePath(url.searchParams.get("to")), | |
| 607 | "set-cookie": `${grantCookieName(target)}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0${secure ? "; Secure" : ""}`, | |
| 608 | "cache-control": "no-store", | |
| 609 | }, | |
| 610 | }); | |
| 605 | return securityFloor( | |
| 606 | new Response(null, { | |
| 607 | status: 303, | |
| 608 | headers: { | |
| 609 | location: safePath(url.searchParams.get("to")), | |
| 610 | "set-cookie": `${grantCookieName(target)}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0${secure ? "; Secure" : ""}`, | |
| 611 | "cache-control": "no-store", | |
| 612 | }, | |
| 613 | }), | |
| 614 | ); | |
| 611 | 615 | } |
| 612 | 616 | // The pill's fourth door: who of the crew is working on this app now, |
| 613 | 617 | // for the person the app's own grant names, read once as the pill opens. |
| @@ -637,13 +641,14 @@ export async function proxyApp( | ||
| 637 | 641 | ); |
| 638 | 642 | return withPhases( |
| 639 | 643 | request, |
| 640 | new Response(body, { | |
| 641 | headers: { | |
| 642 | "content-type": "application/json;charset=utf-8", | |
| 643 | "cache-control": "no-store", | |
| 644 | "x-content-type-options": "nosniff", | |
| 645 | }, | |
| 646 | }), | |
| 644 | securityFloor( | |
| 645 | new Response(body, { | |
| 646 | headers: { | |
| 647 | "content-type": "application/json;charset=utf-8", | |
| 648 | "cache-control": "no-store", | |
| 649 | }, | |
| 650 | }), | |
| 651 | ), | |
| 647 | 652 | ); |
| 648 | 653 | } |
| 649 | 654 | // The forge's small page on the app's own host: the way back when the pill |
| @@ -666,10 +671,12 @@ export async function proxyApp( | ||
| 666 | 671 | ) |
| 667 | 672 | return securityFloor(new Response("not found", { status: 404 })); |
| 668 | 673 | if (request.method !== "GET" && request.method !== "HEAD") |
| 669 | return new Response(null, { | |
| 670 | status: 405, | |
| 671 | headers: { allow: "GET, HEAD", "x-content-type-options": "nosniff" }, | |
| 672 | }); | |
| 674 | return securityFloor( | |
| 675 | new Response(null, { | |
| 676 | status: 405, | |
| 677 | headers: { allow: "GET, HEAD" }, | |
| 678 | }), | |
| 679 | ); | |
| 673 | 680 | const tell = timePhase(request, "app", () => |
| 674 | 681 | tellLink( |
| 675 | 682 | runtime.app.db, |
| @@ -710,14 +717,18 @@ export async function proxyApp( | ||
| 710 | 717 | originFor(runtime, target, request.headers.get("host") ?? undefined) + |
| 711 | 718 | safePath(url.searchParams.get("from")), |
| 712 | 719 | ); |
| 713 | return new Response(null, { | |
| 714 | status: 303, | |
| 715 | headers: { location: report.toString(), "cache-control": "no-store" }, | |
| 716 | }); | |
| 720 | return securityFloor( | |
| 721 | new Response(null, { | |
| 722 | status: 303, | |
| 723 | headers: { location: report.toString(), "cache-control": "no-store" }, | |
| 724 | }), | |
| 725 | ); | |
| 717 | 726 | } |
| 718 | 727 | } |
| 719 | 728 | // The forge button's feedback door: notes from visitors, their status, the |
| 720 | // owner's moderation. Answered here, never reaching the app. | |
| 729 | // owner's moderation. Answered here, never reaching the app. A path under | |
| 730 | // /__forge/note that is none of its doors (`/__forge/notebook`) is the | |
| 731 | // app's own, and goes through below with the app's headers, not the floor. | |
| 721 | 732 | if (url.pathname.startsWith("/__forge/note") && !target.nested) { |
| 722 | 733 | const cookie = cookieValue(request, grantCookieName(target)); |
| 723 | 734 | const signed = |
| @@ -746,7 +757,7 @@ export async function proxyApp( | ||
| 746 | 757 | request.headers.get("x-forwarded-proto") === "https", |
| 747 | 758 | source: clientBucket(request), |
| 748 | 759 | }); |
| 749 | if (answer !== undefined) return answer; | |
| 760 | if (answer !== undefined) return securityFloor(answer); | |
| 750 | 761 | } |
| 751 | 762 | // The beacon's report: filed against this app, never waking it. A private |
| 752 | 763 | // app (or a sealed pull's preview) admits only the people it admits, so |
| @@ -754,16 +765,19 @@ export async function proxyApp( | ||
| 754 | 765 | if (url.pathname === "/__forge/measure" && !target.nested) { |
| 755 | 766 | if (target.app.access === "private" || sealedPull(runtime, target) !== undefined) { |
| 756 | 767 | const admitted = admit(runtime, request, target); |
| 757 | if (!admitted.ok) return admitted.error; | |
| 768 | if (!admitted.ok) return securityFloor(admitted.error); | |
| 758 | 769 | } |
| 759 | return appMeasure(runtime.app, request, target); | |
| 770 | return securityFloor(await appMeasure(runtime.app, request, target)); | |
| 760 | 771 | } |
| 761 | 772 | // An environment asleep wakes for the request that wants it, and the |
| 762 | 773 | // request waits the second that takes rather than seeing a closed door. |
| 763 | 774 | if (runtime.envStatus(target.app, target.env)?.state === "asleep") |
| 764 | 775 | await runtime.wake(target.app, target.env); |
| 765 | 776 | const admitted = admit(runtime, request, target); |
| 766 | if (!admitted.ok) return admitted.error; | |
| 777 | if (!admitted.ok) | |
| 778 | return url.pathname.startsWith("/__forge/") | |
| 779 | ? securityFloor(admitted.error) | |
| 780 | : admitted.error; | |
| 767 | 781 | const { status, port, grant: vouched } = admitted.value; |
| 768 | 782 | runtime.touch(target.app, target.env); |
| 769 | 783 |
test/apps-feedback.test.ts +33
| @@ -589,4 +589,37 @@ describe("the forge button takes notes from anyone", () => { | ||
| 589 | 589 | expect(toolbarJs).toContain("What is this?"); |
| 590 | 590 | expect(toolbarJs).toContain("font:16px"); |
| 591 | 591 | }); |
| 592 | ||
| 593 | test("an unknown /__forge/note* path is the app's, not the forge's", async () => { | |
| 594 | const res = await at("notes", "/__forge/notebook"); | |
| 595 | // No app runs behind it here, so the proxy's own "unavailable" answers; | |
| 596 | // what matters is that the notes door did not claim it. | |
| 597 | expect(await res.text()).not.toContain("not_found"); | |
| 598 | expect(res.headers.get("content-type") ?? "").not.toContain("json"); | |
| 599 | }); | |
| 600 | ||
| 601 | test("every /__forge/ answer wears the security floor, signed in and out", async () => { | |
| 602 | const signedIn = cookieOf(await post("Hello", "", "10.0.0.77")); | |
| 603 | const paths = ["/__forge/", "/__forge/crew", "/__forge/notes", "/__forge/note", "/__forge/note/moderate", "/__forge/measure", "/__forge/signin", "/__forge/signout", "/__forge/report", "/__forge/grant", "/__forge/grant?t=bad"]; | |
| 604 | for (const slug of ["notes", "diary"]) { | |
| 605 | for (const cookie of ["", signedIn]) { | |
| 606 | for (const path of paths) { | |
| 607 | for (const method of ["GET", "POST"]) { | |
| 608 | const res = await at(slug, path, { method, ...(method === "POST" ? { body: "{}" } : {}) }, cookie, "10.0.0.78"); | |
| 609 | const who = `${method} ${slug}${path} ${cookie ? "in" : "out"} -> ${res.status}`; | |
| 610 | expect(res.headers.get("x-content-type-options"), who).toBe("nosniff"); | |
| 611 | expect(res.headers.get("referrer-policy"), who).toBeTruthy(); | |
| 612 | const csp = res.headers.get("content-security-policy"); | |
| 613 | expect(csp, who).toBeTruthy(); | |
| 614 | if (!(res.headers.get("content-type") ?? "").startsWith("text/html")) | |
| 615 | expect(csp, who).toBe("default-src 'none'; frame-ancestors 'none'; base-uri 'none'"); | |
| 616 | else { | |
| 617 | expect(csp, who).toContain("default-src"); | |
| 618 | expect(csp, who).toContain("frame-ancestors 'none'"); | |
| 619 | } | |
| 620 | } | |
| 621 | } | |
| 622 | } | |
| 623 | } | |
| 624 | }); | |
| 592 | 625 | }); |
new above
offline
Add /__forge/signin, /signout, /report and /grant to this list so the test fails when any of them loses the floor.