Skip to content
Open
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
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,8 @@ If a tradeoff is required, choose correctness, durability, and debuggability ove

## Security Requirements

- Bind desktop IPC authority to both the exact registered `webContents` and its configured renderer origin (or exact production file). Reject other loopback ports, deceptive loopback hostnames, child frames, and destroyed senders. Keep the trusted renderer on this navigation scope. Sender-aware handlers receive the already-validated event; use strict payload decoding for capability-bearing APIs and reject extra fields before running the handler.

- Write all code as if it will run in a security-conscious environment where adversaries will constantly try to attack local transports, provider sessions, provider credentials, persisted command ledgers, and debug surfaces.
- Local provider daemon and supervisor transports must be loopback-only or IPC by default, authenticated with high-entropy capability tokens, and must never be exposed on a non-loopback interface without an explicit authenticated design.
- Secrets such as provider daemon tokens, Codex auth, Claude credentials, OpenCode server passwords, saved-environment bearer sessions, and app bootstrap credentials must be stored as private files with restrictive permissions where the platform supports them. Never persist secrets in logs, debug JSON, process argv, browser local storage, or user-visible error strings.
Expand Down
102 changes: 90 additions & 12 deletions apps/desktop/src/ipc/DesktopIpc.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,36 @@
import * as ElectronShell from "../electron/ElectronShell.ts";
import { COPY_TEXT_CHANNEL } from "./channels.ts";
import { copyText } from "./methods/window.ts";
import * as Effect from "effect/Effect";
import * as Schema from "effect/Schema";
import * as Layer from "effect/Layer";
import { describe, expect, it, vi } from "vitest";

import * as ElectronShell from "../electron/ElectronShell.ts";
import { COPY_TEXT_CHANNEL } from "./channels.ts";
import * as DesktopIpc from "./DesktopIpc.ts";
import { copyText } from "./methods/window.ts";

describe("strict sender-aware IPC methods", () => {
it("rejects excess payload fields before calling the handler", async () => {
let calls = 0;
const sender = { id: 42 };
const method = DesktopIpc.makeIpcMethod({
channel: "test.strict",
payload: Schema.Struct({ tabId: Schema.String }),
result: Schema.Number,
strict: true,
handler: (_input, event) =>
Effect.sync(() => {
calls++;
expect(event.sender).toBe(sender);
return calls;
}),
});
await expect(
Effect.runPromise(method.handler({ tabId: "tab", extra: "denied" }, { sender })),
).rejects.toThrow();
expect(calls).toBe(0);
expect(await Effect.runPromise(method.handler({ tabId: "tab" }, { sender }))).toBe(1);
});
});

function makeTopFrame(url: string): DesktopIpc.DesktopIpcWebFrame {
const frame = {
Expand Down Expand Up @@ -59,20 +84,26 @@ describe("DesktopIpc sender validation", () => {
expect(DesktopIpc.isTrustedDesktopIpcFrameUrl("http://127.0.0.1:5733/")).toBe(true);
expect(DesktopIpc.isTrustedDesktopIpcFrameUrl("http://localhost:5733/")).toBe(true);
expect(DesktopIpc.isTrustedDesktopIpcFrameUrl("http://[::1]:5733/")).toBe(true);
expect(DesktopIpc.isTrustedDesktopIpcFrameUrl("http://127.attacker.example:5733/")).toBe(false);
expect(DesktopIpc.isTrustedDesktopIpcFrameUrl("http://localhost.attacker.example:5733/")).toBe(
false,
);
expect(DesktopIpc.isTrustedDesktopIpcFrameUrl("https://example.com/")).toBe(false);
expect(DesktopIpc.isTrustedDesktopIpcFrameUrl("app://cafe-code/index.html")).toBe(false);
});

it("allows invoke handlers from registered top-level production and dev frames", async () => {
it("pins registered webContents to their exact production file or development origin", async () => {
const ipcMain = makeIpcMainStub();
const ipc = DesktopIpc.make(ipcMain.ipcMain);
const sender = { id: 7, isDestroyed: () => false };
const productionSender = { id: 7, isDestroyed: () => false };
const developmentSender = { id: 8, isDestroyed: () => false };
let calls = 0;

await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
yield* ipc.trustWebContents(sender);
yield* ipc.trustWebContents(productionSender, "file:///Applications/CafeCode/index.html");
yield* ipc.trustWebContents(developmentSender, "http://127.0.0.1:5733/");
yield* ipc.handle({
channel: "secure.invoke",
handler: (raw) =>
Expand All @@ -85,15 +116,21 @@ describe("DesktopIpc sender validation", () => {
yield* Effect.promise(() =>
Promise.resolve(
ipcMain.getInvokeListener()(
{ sender, senderFrame: makeTopFrame("file:///Applications/CafeCode/index.html") },
{
sender: productionSender,
senderFrame: makeTopFrame("file:///Applications/CafeCode/index.html#/chat"),
},
"production",
),
),
);
yield* Effect.promise(() =>
Promise.resolve(
ipcMain.getInvokeListener()(
{ sender, senderFrame: makeTopFrame("http://127.0.0.1:5733/") },
{
sender: developmentSender,
senderFrame: makeTopFrame("http://127.0.0.1:5733/chat"),
},
"development",
),
),
Expand All @@ -105,6 +142,39 @@ describe("DesktopIpc sender validation", () => {
expect(calls).toBe(2);
});

it("passes the validated sender to sender-aware handlers", async () => {
const ipcMain = makeIpcMainStub();
const ipc = DesktopIpc.make(ipcMain.ipcMain);
const sender = { id: 42, isDestroyed: () => false };
let handledSenderId: number | undefined;

await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
yield* ipc.trustWebContents(sender, "http://127.0.0.1:5733/");
yield* ipc.handleFromSender({
channel: "secure.sender-aware",
handler: (_raw, event) =>
Effect.sync(() => {
handledSenderId = event.sender?.id;
return "handled";
}),
});
yield* Effect.promise(() =>
Promise.resolve(
ipcMain.getInvokeListener()(
{ sender, senderFrame: makeTopFrame("http://127.0.0.1:5733/") },
undefined,
),
),
);
}),
),
);

expect(handledSenderId).toBe(42);
});

it("rejects invoke handlers from untrusted origins and unexpected frames", async () => {
const ipcMain = makeIpcMainStub();
const ipc = DesktopIpc.make(ipcMain.ipcMain);
Expand All @@ -114,7 +184,7 @@ describe("DesktopIpc sender validation", () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
yield* ipc.trustWebContents(sender);
yield* ipc.trustWebContents(sender, "http://127.0.0.1:5733/");
yield* ipc.handle({
channel: "secure.invoke",
handler: () =>
Expand All @@ -134,6 +204,15 @@ describe("DesktopIpc sender validation", () => {
await expect(
listener({ sender, senderFrame: makeTopFrame("https://evil.example/") }, "payload"),
).rejects.toThrow(DesktopIpc.DesktopIpcSenderValidationError);
await expect(
listener({ sender, senderFrame: makeTopFrame("http://127.0.0.1:5734/") }, "payload"),
).rejects.toThrow(DesktopIpc.DesktopIpcSenderValidationError);
await expect(
listener(
{ sender, senderFrame: makeTopFrame("http://127.attacker.example:5733/") },
"payload",
),
).rejects.toThrow(DesktopIpc.DesktopIpcSenderValidationError);
await expect(listener({ sender, senderFrame: childFrame }, "payload")).rejects.toThrow(
DesktopIpc.DesktopIpcSenderValidationError,
);
Expand Down Expand Up @@ -175,7 +254,6 @@ describe("DesktopIpc sender validation", () => {
expect(event.returnValue).toBeNull();
expect(calls).toBe(0);
});

it("decodes trusted clipboard writes and rejects invalid payloads", async () => {
const ipcMain = makeIpcMainStub();
const ipc = DesktopIpc.make(ipcMain.ipcMain);
Expand All @@ -186,7 +264,7 @@ describe("DesktopIpc sender validation", () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
yield* ipc.trustWebContents(sender);
yield* ipc.trustWebContents(sender, "file:///Applications/CafeCode/index.html");
yield* ipc.handle(copyText);

const listener = ipcMain.getInvokeListener();
Expand Down Expand Up @@ -215,7 +293,7 @@ describe("DesktopIpc sender validation", () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
yield* ipc.trustWebContents(sender);
yield* ipc.trustWebContents(sender, "file:///Applications/CafeCode/index.html");
yield* ipc.handle(copyText);
}),
).pipe(Effect.provide(makeElectronShellLayer((text) => copiedTexts.push(text)))),
Expand Down
Loading
Loading