Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions apps/desktop/src/electron/ElectronProtocol.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}),
);

Expand Down Expand Up @@ -162,6 +167,11 @@ describe("ElectronProtocol", () => {
assert.equal(yield* Effect.promise(() => responses[1].text()), "<main>client</main>");
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)),
Expand Down Expand Up @@ -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.
Expand Down
4 changes: 4 additions & 0 deletions apps/desktop/src/electron/ElectronProtocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:`,
Expand All @@ -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,
Expand Down
19 changes: 19 additions & 0 deletions apps/server/src/agentui/AgentUiService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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* () {
Expand Down
11 changes: 11 additions & 0 deletions apps/server/src/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
"*",
Expand Down Expand Up @@ -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,
});
}

Expand All @@ -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 } : {}),
});
}),
);
19 changes: 19 additions & 0 deletions apps/server/src/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
);

Expand Down
Loading
Loading