diff --git a/apps/desktop/src/electron/ElectronProtocol.test.ts b/apps/desktop/src/electron/ElectronProtocol.test.ts index 53af0f3870ba..cd3455e47d6d 100644 --- a/apps/desktop/src/electron/ElectronProtocol.test.ts +++ b/apps/desktop/src/electron/ElectronProtocol.test.ts @@ -77,6 +77,11 @@ describe("ElectronProtocol", () => { response.headers.get("content-security-policy") ?? "", "font-src 'self' t3code-dev: data:", ); + assert.include( + response.headers.get("content-security-policy") ?? "", + "frame-ancestors 'none'", + ); + assert.equal(response.headers.get("x-frame-options"), "DENY"); }), ); @@ -162,6 +167,11 @@ describe("ElectronProtocol", () => { assert.equal(yield* Effect.promise(() => responses[1].text()), "
client
"); assert.equal(responses[0].headers.get("content-type"), "text/javascript; charset=utf-8"); assert.include(responses[1].headers.get("content-security-policy") ?? "", "default-src"); + assert.include( + responses[1].headers.get("content-security-policy") ?? "", + "frame-ancestors 'none'", + ); + assert.equal(responses[1].headers.get("x-frame-options"), "DENY"); assert.equal(netFetchMock.mock.calls.length, 0); }), ).pipe(Effect.provide(electronProtocolLayer)), @@ -282,6 +292,7 @@ describe("ElectronProtocol", () => { "https://clerk.t3.codes", "https://challenges.cloudflare.com", ]); + assert.deepEqual(directives["frame-ancestors"], ["'none'"]); assert.deepEqual(directives["connect-src"], ["'self'", "http:", "https:", "ws:", "wss:"]); // A Plannotator review is framed from the environment that owns the thread, // which is never this renderer's origin. diff --git a/apps/desktop/src/electron/ElectronProtocol.ts b/apps/desktop/src/electron/ElectronProtocol.ts index 7630326394fd..097f68c26042 100644 --- a/apps/desktop/src/electron/ElectronProtocol.ts +++ b/apps/desktop/src/electron/ElectronProtocol.ts @@ -99,6 +99,8 @@ export function makeDesktopContentSecurityPolicy(input: DesktopProtocolRegistrat return [ "default-src 'self'", + // T3-CUSTOM(expbkt3): a remote agent frame must not redirect into the desktop shell. + "frame-ancestors 'none'", `script-src ${scriptSources.join(" ")}`, `connect-src ${connectSources.join(" ")}`, `img-src 'self' ${input.scheme}: blob: data: http: https:`, @@ -115,6 +117,8 @@ export function makeDesktopContentSecurityPolicy(input: DesktopProtocolRegistrat function withContentSecurityPolicy(response: Response, policy: string): Response { const headers = new Headers(response.headers); headers.set("Content-Security-Policy", policy); + // T3-CUSTOM(expbkt3): legacy defense in depth for custom-protocol shell responses. + headers.set("X-Frame-Options", "DENY"); return new Response(response.body, { status: response.status, statusText: response.statusText, diff --git a/apps/server/src/agentui/AgentUiService.test.ts b/apps/server/src/agentui/AgentUiService.test.ts index bdef5421c796..4ee9f906a72a 100644 --- a/apps/server/src/agentui/AgentUiService.test.ts +++ b/apps/server/src/agentui/AgentUiService.test.ts @@ -102,6 +102,25 @@ describe("AgentUiService", () => { ), ); + it.effect("preserves distinct fragments for same-origin URL renders", () => + withService((service) => + Effect.gen(function* () { + const firstUrl = "https://fixture.example.test/board?mode=collab#room=alpha,safe-key-a"; + const secondUrl = "https://fixture.example.test/board#room=beta,safe-key-b"; + const first = yield* service.show({ threadId, title: "First", url: firstUrl }); + const second = yield* service.show({ threadId, title: "Second", url: secondUrl }); + + expect(first.renderId).not.toBe(second.renderId); + expect((yield* service.getRender({ threadId, renderId: first.renderId }))?.url).toBe( + firstUrl, + ); + expect((yield* service.getRender({ threadId, renderId: second.renderId }))?.url).toBe( + secondUrl, + ); + }), + ), + ); + it.effect("requires exactly one of html or url", () => withService((service) => Effect.gen(function* () { diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index b7d054925734..afa70766327d 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -308,6 +308,13 @@ export const attachmentUploadRouteLayer = HttpRouter.add( }), ); +// T3-CUSTOM(expbkt3): the signed-in shell must never become a same-origin iframe +// after an agent-supplied cross-origin URL redirects back to T3. +export const T3_HTML_FRAME_HEADERS = { + "content-security-policy": "frame-ancestors 'none'", + "x-frame-options": "DENY", +} as const; + export const staticAndDevRouteLayer = HttpRouter.add( "GET", "*", @@ -384,6 +391,8 @@ export const staticAndDevRouteLayer = HttpRouter.add( return HttpServerResponse.uint8Array(indexData, { status: 200, contentType: "text/html; charset=utf-8", + // T3-CUSTOM(expbkt3): block redirect-based sandbox escapes on SPA fallbacks. + headers: T3_HTML_FRAME_HEADERS, }); } @@ -396,6 +405,8 @@ export const staticAndDevRouteLayer = HttpRouter.add( return HttpServerResponse.uint8Array(data, { status: 200, contentType, + // T3-CUSTOM(expbkt3): only HTML is frame-sensitive; assets keep their normal headers. + ...(contentType.startsWith("text/html") ? { headers: T3_HTML_FRAME_HEADERS } : {}), }); }), ); diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 9389a7eb77ed..aaf6346724da 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -1683,6 +1683,25 @@ it.layer(NodeServices.layer)("server router seam", (it) => { const response = yield* HttpClient.get("/"); assert.equal(response.status, 200); assert.include(yield* response.text, "router-static-ok"); + assert.equal(response.headers["content-security-policy"], "frame-ancestors 'none'"); + assert.equal(response.headers["x-frame-options"], "DENY"); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("makes the SPA fallback HTML unframeable", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const staticDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-router-static-" }); + yield* fileSystem.writeFileString(path.join(staticDir, "index.html"), "fallback"); + + yield* buildAppUnderTest({ config: { staticDir } }); + + const response = yield* HttpClient.get("/missing/client/route"); + assert.equal(response.status, 200); + assert.include(yield* response.text, "fallback"); + assert.equal(response.headers["content-security-policy"], "frame-ancestors 'none'"); + assert.equal(response.headers["x-frame-options"], "DENY"); }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); diff --git a/apps/web/src/fork/agentUiSurface.dom.test.tsx b/apps/web/src/fork/agentUiSurface.dom.test.tsx new file mode 100644 index 000000000000..5137f3031af3 --- /dev/null +++ b/apps/web/src/fork/agentUiSurface.dom.test.tsx @@ -0,0 +1,310 @@ +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import type { ReactNode } from "react"; +import { flushSync } from "react-dom"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +const queryStates = vi.hoisted( + () => + new Map< + string, + { + data?: { render: Record }; + isPending: boolean; + error?: string; + } + >(), +); + +vi.mock("../state/agentUi", () => ({ + agentUiEnvironment: { + render: ({ input }: { input: { renderId: string } }) => input, + }, +})); +vi.mock("../state/query", () => ({ + useEnvironmentQuery: ({ renderId }: { renderId: string }) => + queryStates.get(renderId) ?? { isPending: true }, +})); + +import { AgentUiRenderFrame, AgentUiUrlFrame } from "./agentUiSurface"; +import { useAgentUiUrlFrameCoordinator } from "./agentUiUrlFrameCoordinator"; + +const THREAD_REF = { + environmentId: EnvironmentId.make("environment-fixture"), + threadId: ThreadId.make("thread-fixture"), +} as const; +const FIRST_URL = "https://fixture.example.test/board#room=alpha,safe-key-a"; +const SECOND_URL = "https://fixture.example.test/board#room=beta,safe-key-b"; +const mutations: string[] = []; + +// ReactDOM needs a host, but this focused lifecycle suite intentionally has no +// browser dependency. The host records iframe attachment order so a switch can +// prove that the old browsing context disconnected before the new one mounted. +class TestNode { + parentNode: TestNode | null = null; + childNodes: TestNode[] = []; + readonly attributes = new Map(); + readonly nodeName: string; + readonly tagName: string; + readonly namespaceURI = "http://www.w3.org/1999/xhtml"; + readonly style = {}; + nodeValue: string | null = null; + + constructor( + name: string, + readonly ownerDocument: TestNode | null = null, + readonly nodeType = 1, + ) { + this.nodeName = name.toUpperCase(); + this.tagName = this.nodeName; + } + + set textContent(value: string) { + this.childNodes = []; + this.nodeValue = value; + } + + get textContent(): string { + return this.nodeValue ?? this.childNodes.map((child) => child.textContent).join(""); + } + + appendChild(child: TestNode) { + child.parentNode = this; + this.childNodes.push(child); + if (child.tagName === "IFRAME") mutations.push(`attach:${child.getAttribute("src")}`); + return child; + } + + insertBefore(child: TestNode, before: TestNode | null) { + if (before === null) return this.appendChild(child); + const index = this.childNodes.indexOf(before); + child.parentNode = this; + this.childNodes.splice(index, 0, child); + if (child.tagName === "IFRAME") mutations.push(`attach:${child.getAttribute("src")}`); + return child; + } + + removeChild(child: TestNode) { + if (child.tagName === "IFRAME") mutations.push(`detach:${child.getAttribute("src")}`); + this.childNodes.splice(this.childNodes.indexOf(child), 1); + child.parentNode = null; + return child; + } + + createElement(name: string) { + return new TestNode(name, this); + } + + createTextNode(value: string) { + const node = new TestNode("#text", this, 3); + node.nodeValue = value; + return node; + } + + setAttribute(name: string, value: string) { + this.attributes.set(name, String(value)); + } + + getAttribute(name: string): string | null { + return this.attributes.get(name) ?? null; + } + + removeAttribute(name: string) { + this.attributes.delete(name); + } + + addEventListener() {} + removeEventListener() {} +} + +function installTestDom() { + const document = new TestNode("#document", null, 9); + const window = { + document, + location: { origin: "https://t3.example.test" }, + HTMLIFrameElement: TestNode, + setTimeout: globalThis.setTimeout, + clearTimeout: globalThis.clearTimeout, + addEventListener() {}, + removeEventListener() {}, + }; + vi.stubGlobal("document", document); + vi.stubGlobal("window", window); + vi.stubGlobal("HTMLIFrameElement", TestNode); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + return document; +} + +function urlFrame(renderId: string, url: string, createdAt: string, placement = "inline") { + return ( + + ); +} + +function iframeNodes(root: TestNode): TestNode[] { + return root.childNodes.flatMap((child) => [ + ...(child.tagName === "IFRAME" ? [child] : []), + ...iframeNodes(child), + ]); +} + +async function render(root: { render: (children: ReactNode) => void }, children: ReactNode) { + flushSync(() => root.render(children)); + await Promise.resolve(); + flushSync(() => undefined); +} + +describe("AgentUiUrlFrame DOM lifecycle", () => { + beforeEach(() => { + mutations.length = 0; + queryStates.clear(); + useAgentUiUrlFrameCoordinator.getState().reset(); + }); + + afterEach(async () => { + // React's development scheduler posts an Immediate after a root commits. + // Let it drain while the fake window still exists so parallel CI cannot + // observe a callback after Vitest restores the Node globals. + await new Promise((resolve) => setImmediate(resolve)); + vi.unstubAllGlobals(); + }); + + it("keeps exact same-origin URLs distinct and replaces the iframe on A to B to A", async () => { + const document = installTestDom(); + const { createRoot } = await import("react-dom/client"); + const container = document.createElement("div"); + const root = createRoot(container as unknown as Element); + + try { + await render( + root, + <> + {urlFrame("aui_alpha", FIRST_URL, "2026-08-29T10:00:00.000Z")} + {urlFrame("aui_beta", SECOND_URL, "2026-08-29T10:01:00.000Z")} + , + ); + + const [betaNode] = iframeNodes(container); + expect(betaNode?.getAttribute("src")).toBe(SECOND_URL); + expect(betaNode?.getAttribute("credentialless")).toBe(""); + expect(betaNode?.getAttribute("sandbox")).toContain("allow-same-origin"); + + flushSync(() => + useAgentUiUrlFrameCoordinator + .getState() + .activate("inline:environment-fixture:thread-fixture:aui_alpha"), + ); + await Promise.resolve(); + flushSync(() => undefined); + const [alphaNode] = iframeNodes(container); + expect(betaNode?.parentNode).toBeNull(); + expect(alphaNode).not.toBe(betaNode); + expect(alphaNode?.getAttribute("src")).toBe(FIRST_URL); + + flushSync(() => + useAgentUiUrlFrameCoordinator + .getState() + .activate("inline:environment-fixture:thread-fixture:aui_beta"), + ); + await Promise.resolve(); + flushSync(() => undefined); + const [nextBetaNode] = iframeNodes(container); + expect(alphaNode?.parentNode).toBeNull(); + expect(nextBetaNode).not.toBe(alphaNode); + expect(nextBetaNode).not.toBe(betaNode); + expect(nextBetaNode?.getAttribute("src")).toBe(SECOND_URL); + expect(mutations).toEqual([ + `attach:${SECOND_URL}`, + `detach:${SECOND_URL}`, + `attach:${FIRST_URL}`, + `detach:${FIRST_URL}`, + `attach:${SECOND_URL}`, + ]); + } finally { + flushSync(() => root.unmount()); + } + }); + + it("gives an expanded frame exclusive priority and restores inline after it closes", async () => { + const document = installTestDom(); + const { createRoot } = await import("react-dom/client"); + const container = document.createElement("div"); + const root = createRoot(container as unknown as Element); + + try { + await render( + root, + <> + {urlFrame("aui_beta", SECOND_URL, "2026-08-29T10:01:00.000Z")} + {urlFrame("aui_alpha", FIRST_URL, "2026-08-29T10:00:00.000Z", "expanded")} + , + ); + const [expandedNode] = iframeNodes(container); + expect(iframeNodes(container)).toHaveLength(1); + expect(expandedNode?.getAttribute("src")).toBe(FIRST_URL); + + await render(root, urlFrame("aui_beta", SECOND_URL, "2026-08-29T10:01:00.000Z")); + const [inlineNode] = iframeNodes(container); + expect(expandedNode?.parentNode).toBeNull(); + expect(iframeNodes(container)).toHaveLength(1); + expect(inlineNode).not.toBe(expandedNode); + expect(inlineNode?.getAttribute("src")).toBe(SECOND_URL); + } finally { + flushSync(() => root.unmount()); + } + }); + + it("disconnects an expanded iframe while the replacement query is pending", async () => { + const document = installTestDom(); + const { createRoot } = await import("react-dom/client"); + const container = document.createElement("div"); + const root = createRoot(container as unknown as Element); + const firstRender = { + renderId: "aui_alpha", + title: "First", + kind: "url", + url: FIRST_URL, + createdAt: "2026-08-29T10:00:00.000Z", + }; + const secondRender = { + renderId: "aui_beta", + title: "Second", + kind: "url", + url: SECOND_URL, + createdAt: "2026-08-29T10:01:00.000Z", + }; + queryStates.set(firstRender.renderId, { data: { render: firstRender }, isPending: false }); + queryStates.set(secondRender.renderId, { isPending: true }); + + const expandedFrame = (renderId: string) => ( + undefined} + /> + ); + + try { + await render(root, expandedFrame(firstRender.renderId)); + const [firstNode] = iframeNodes(container); + expect(firstNode?.getAttribute("src")).toBe(FIRST_URL); + + await render(root, expandedFrame(secondRender.renderId)); + expect(firstNode?.parentNode).toBeNull(); + expect(iframeNodes(container)).toHaveLength(0); + + queryStates.set(secondRender.renderId, { data: { render: secondRender }, isPending: false }); + await render(root, expandedFrame(secondRender.renderId)); + const [secondNode] = iframeNodes(container); + expect(secondNode).not.toBe(firstNode); + expect(secondNode?.getAttribute("src")).toBe(SECOND_URL); + } finally { + flushSync(() => root.unmount()); + } + }); +}); diff --git a/apps/web/src/fork/agentUiSurface.test.ts b/apps/web/src/fork/agentUiSurface.test.ts index 9114da206390..61715abbab64 100644 --- a/apps/web/src/fork/agentUiSurface.test.ts +++ b/apps/web/src/fork/agentUiSurface.test.ts @@ -7,7 +7,7 @@ */ import { describe, expect, it } from "vite-plus/test"; -import { resolveAgentUiSurface, resolveEmbedSandbox } from "./agentUiSurface"; +import { resolveAgentUiSurface, resolveEmbedPolicy, resolveEmbedSandbox } from "./agentUiSurface"; describe("resolveAgentUiSurface", () => { it("reads a well-formed handle", () => { @@ -51,6 +51,10 @@ describe("resolveEmbedSandbox", () => { // Without this, localStorage and IndexedDB throw and real apps never boot. expect(sandbox).toContain("allow-same-origin"); expect(sandbox).toContain("allow-scripts"); + expect(resolveEmbedPolicy("https://draw-canvas.dev.beknown.live/", page)).toEqual({ + sandbox, + credentialless: true, + }); }); it("withholds allow-same-origin from a self-referential embed", () => { @@ -58,6 +62,7 @@ describe("resolveEmbedSandbox", () => { // the frame could reach the signed-in session directly. for (const url of [page, `${page}/settings`, `${page}/?x=1#y`]) { expect(resolveEmbedSandbox(url, page)).not.toContain("allow-same-origin"); + expect(resolveEmbedPolicy(url, page).credentialless).toBe(false); } }); diff --git a/apps/web/src/fork/agentUiSurface.tsx b/apps/web/src/fork/agentUiSurface.tsx index 7186fb396812..076a1d4d9f20 100644 --- a/apps/web/src/fork/agentUiSurface.tsx +++ b/apps/web/src/fork/agentUiSurface.tsx @@ -24,6 +24,7 @@ import { agentUiEnvironment } from "../state/agentUi"; import { useAgentUiExpandedStore } from "../agentUiExpandedStore"; import { useEnvironmentQuery } from "../state/query"; import { cn } from "../lib/utils"; +import { useAgentUiUrlFrameCoordinator } from "./agentUiUrlFrameCoordinator"; /** The handle `ActivityPayloadProjection` keeps on an MCP tool-call payload. */ export interface AgentUiSurfaceHandle { @@ -95,14 +96,113 @@ export function resolveEmbedSandbox(url: string, pageOrigin: string): string { return origin === pageOrigin ? EMBED_SANDBOX_BASE : `${EMBED_SANDBOX_BASE} allow-same-origin`; } +export interface AgentUiEmbedPolicy { + readonly sandbox: string; + readonly credentialless: boolean; +} + +/** Cross-origin apps get an ephemeral credential shelf when the browser supports it. */ +export function resolveEmbedPolicy(url: string, pageOrigin: string): AgentUiEmbedPolicy { + const sandbox = resolveEmbedSandbox(url, pageOrigin); + return { + sandbox, + credentialless: sandbox.split(" ").includes("allow-same-origin"), + }; +} + +function resolveUrlOrigin(url: string, renderId: string): string { + try { + return new URL(url).origin; + } catch { + // The server rejects this shape, but a corrupt legacy row should remain + // locked down and must not share ownership with another bad URL. + return `opaque:${renderId}`; + } +} + +const CREDENTIALLESS_IFRAME_PROPS = { credentialless: "" } as const; + +export const AgentUiUrlFrame = memo(function AgentUiUrlFrame(props: { + readonly render: { + readonly renderId: string; + readonly title: string; + readonly url: string; + readonly createdAt: string; + }; + readonly threadRef: ScopedThreadRef; + readonly placement: "inline" | "expanded"; +}) { + const { render, threadRef, placement } = props; + const origin = useMemo( + () => resolveUrlOrigin(render.url, render.renderId), + [render.renderId, render.url], + ); + const slotId = `${placement}:${threadRef.environmentId}:${threadRef.threadId}:${render.renderId}`; + const register = useAgentUiUrlFrameCoordinator((state) => state.register); + const unregister = useAgentUiUrlFrameCoordinator((state) => state.unregister); + const activate = useAgentUiUrlFrameCoordinator((state) => state.activate); + const settle = useAgentUiUrlFrameCoordinator((state) => state.settle); + const activeSlot = useAgentUiUrlFrameCoordinator( + (state) => state.activeSlotByOrigin[origin] ?? null, + ); + const pendingSlot = useAgentUiUrlFrameCoordinator( + (state) => state.pendingSlotByOrigin[origin] ?? null, + ); + + useEffect(() => { + register({ + slotId, + renderId: render.renderId, + origin, + createdAt: render.createdAt, + priority: placement, + }); + return () => unregister(slotId); + }, [origin, placement, register, render.createdAt, render.renderId, slotId, unregister]); + + // `activate` first commits an empty owner. Settling from an effect makes the + // requested iframe a later commit, after the previous DOM node disconnected. + useEffect(() => { + if (activeSlot === null && pendingSlot === slotId) settle(origin); + }, [activeSlot, origin, pendingSlot, settle, slotId]); + + if (activeSlot !== slotId) { + return ( +
+ +
+ ); + } + + const policy = resolveEmbedPolicy(render.url, window.location.origin); + return ( +