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

Every /__forge/* answer on an app host wears the security floor, JSON included #6115

Merged opened this

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

Merged

scout/forge:scout/6094-every-forge-answer-on-an-app-host-wears → main 3 commits fork 2 threads · 2 unresolved

  • Merged. scout/6094-every-forge-answer-on-an-app-host-wears was merged into main as 2156c0b.

Review

Sign in to review this change.
2 files changed +80 −33
src/apps/proxy.ts +47 −33
@@ -582,7 +582,7 @@ export async function proxyApp(
582582 !target.nested ||
583583 verifyGrant(runtime.app.config.dataDir, token, target)
584584 )
585 return grant(runtime, request, url, target);
585 return securityFloor(grant(runtime, request, url, target));
586586 }
587587 // The two doors an app links to: sign in sends the visitor to the forge,
588588 // which vouches for them and sends them back named; sign out drops the
@@ -591,23 +591,27 @@ export async function proxyApp(
591591 const open = new URL("/apps/open", runtime.options.forgeUrl);
592592 open.searchParams.set("host", request.headers.get("host") ?? "");
593593 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 );
598600 }
599601 if (url.pathname === "/__forge/signout" && !target.nested) {
600602 const secure =
601603 url.protocol === "https:" ||
602604 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 );
611615 }
612616 // The pill's fourth door: who of the crew is working on this app now,
613617 // for the person the app's own grant names, read once as the pill opens.
@@ -637,13 +641,14 @@ export async function proxyApp(
637641 );
638642 return withPhases(
639643 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 ),
647652 );
648653 }
649654 // 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(
666671 )
667672 return securityFloor(new Response("not found", { status: 404 }));
668673 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 );
673680 const tell = timePhase(request, "app", () =>
674681 tellLink(
675682 runtime.app.db,
@@ -710,14 +717,18 @@ export async function proxyApp(
710717 originFor(runtime, target, request.headers.get("host") ?? undefined) +
711718 safePath(url.searchParams.get("from")),
712719 );
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 );
717726 }
718727 }
719728 // 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.
721732 if (url.pathname.startsWith("/__forge/note") && !target.nested) {
722733 const cookie = cookieValue(request, grantCookieName(target));
723734 const signed =
@@ -746,7 +757,7 @@ export async function proxyApp(
746757 request.headers.get("x-forwarded-proto") === "https",
747758 source: clientBucket(request),
748759 });
749 if (answer !== undefined) return answer;
760 if (answer !== undefined) return securityFloor(answer);
750761 }
751762 // The beacon's report: filed against this app, never waking it. A private
752763 // app (or a sealed pull's preview) admits only the people it admits, so
@@ -754,16 +765,19 @@ export async function proxyApp(
754765 if (url.pathname === "/__forge/measure" && !target.nested) {
755766 if (target.app.access === "private" || sealedPull(runtime, target) !== undefined) {
756767 const admitted = admit(runtime, request, target);
757 if (!admitted.ok) return admitted.error;
768 if (!admitted.ok) return securityFloor(admitted.error);
758769 }
759 return appMeasure(runtime.app, request, target);
770 return securityFloor(await appMeasure(runtime.app, request, target));
760771 }
761772 // An environment asleep wakes for the request that wants it, and the
762773 // request waits the second that takes rather than seeing a closed door.
763774 if (runtime.envStatus(target.app, target.env)?.state === "asleep")
764775 await runtime.wake(target.app, target.env);
765776 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;
767781 const { status, port, grant: vouched } = admitted.value;
768782 runtime.touch(target.app, target.env);
769783
test/apps-feedback.test.ts +33
@@ -589,4 +589,37 @@ describe("the forge button takes notes from anyone", () => {
589589 expect(toolbarJs).toContain("What is this?");
590590 expect(toolbarJs).toContain("font:16px");
591591 });
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;
outdated

Add /__forge/signin, /signout, /report and /grant to this list so the test fails when any of them loses the floor.

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 });
592625 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs