All articles

Typed IPC: Why Your Electron App Needs a Type-Safe Customs Office

We killed the generic 'run any capability' channel from the renderer after a threat model review. Every IPC call in Fabriq now goes through a typed, Zod-validated handler. Here's the threat model, the migration, and how it makes the app auditable by default.

ygwa6 min read
securityipcarchitectureelectronadr

TL;DR. Fabriq's renderer initially had a generic dapei:capability:run IPC channel — call any engine capability with any payload. A security review revealed this was one XSS away from catastrophic damage. We replaced it with typed IPC channels: each operation has its own channel, its own Zod schema, and its own handler. Write operations are gated behind explicit user confirmation. This post walks through the threat model, the migration pattern, and the unexpected benefits.


The convenience that scared us

Early in Fabriq's development, the renderer (the UI process) communicated with the engine through a single generic IPC channel:

// Before: one channel to rule them all
ipcMain.handle("dapei:capability:run", async (event, capabilityId, input) => {
  return engine.run(capabilityId, input);
});

This was convenient. Add a new capability? Call it from the renderer immediately. No new IPC handler, no new schema. Just pass the capability ID and the payload through the same generic pipe.

Convenient — and terrifying.

The threat model

Fabriq's renderer renders user content: markdown previews, feature documents, code diffs. Any XSS vulnerability — a malicious markdown file, a crafted feature name, an external link with javascript: — becomes a potential vector.

The question we asked: if an attacker controls the renderer, what can they do?

With the generic capability:run channel, the answer was: everything.

  • repos.remove(".") — delete the workspace
  • feature.close("core-module") — close an in-progress feature without review
  • cdr.behavior.upsert(...) — write unverified cognitive artifacts to the workspace baseline
  • engine.exec("rm -rf /") — direct shell access through a capability

A single compromised renderer path could execute any engine capability with any arguments. The renderer had the keys to the entire system because we never stopped to audit what "the entire system" included.

The fix: typed IPC channels

We replaced the single generic channel with a family of typed channels:

// After: one channel per operation, each with a Zod schema
export const IPC_CHANNELS = {
  // Read-only (renderer can invoke directly)
  "capability:query:codegraph":    { schema: codegraphQuerySchema,    write: false },
  "capability:query:features":     { schema: listFeaturesSchema,      write: false },
  "capability:query:knowledge":    { schema: queryKnowledgeSchema,    write: false },
 
  // Write operations (main process routes them, renderer can't invoke directly)
  "feature:create":               { schema: featureCreateSchema,      write: true },
  "feature:runStage":             { schema: runStageSchema,           write: true },
  "feature:close":                { schema: closeFeatureSchema,       write: true },
  "repos:sync":                   { schema: reposSyncSchema,          write: true },
  "cdr:behavior:upsert":          { schema: upsertBehaviorSchema,     write: true },
  // ...
} as const;

Each channel declares:

  1. A Zod schema — validates the payload before it reaches the handler. Malformed requests are rejected with a structured error, never forwarded to the engine.
  2. A write flag — read-only channels are invokable from the renderer. Write channels require the main process to construct the call, typically triggered by user confirmation in the UI.

The result: even if the renderer is compromised, the attacker can only query data. They cannot mutate state. To delete a repo, they'd need to forge a specific IPC message with a valid Zod-validated payload on a write-only channel — and those channels don't accept renderer-originated calls.

The confirmation layer

Write operations go one step further: they surface in the UI as explicit prompts before execution.

// In the main process, a write handler looks like this:
ipcMain.handle("feature:close", async (event, payload) => {
  // 1. Validate payload via Zod
  const parsed = closeFeatureSchema.parse(payload);
 
  // 2. Check dimension access rules
  assertDimensionAccess("feature:close", parsed.dimension);
 
  // 3. Emit confirmation request to renderer
  const confirmed = await requestUserConfirmation(
    "close-feature",
    {
      feature: parsed.featureName,
      stage: parsed.currentStage,
      pendingReviews: parsed.pendingReviews,
    }
  );
 
  // 4. Only execute if confirmed
  if (!confirmed) return { ok: false, reason: "rejected" };
  return engine.closeFeature(parsed);
});

The user sees a confirmation dialog with the operation details — the target, the dimension, the expected effect. Nothing hits disk without explicit approval.

The audit benefit

An unexpected side effect of typed IPC: auditability by default.

Because every channel has a known schema and a known purpose, we can log every call with structured metadata:

// Middleware in the IPC router
ipcMain.on("before-handle", (channel, payload, userInfo) => {
  auditLog.write({
    timestamp: new Date().toISOString(),
    channel,
    userId: userInfo.id,
    payload: maskSecrets(payload), // Strip credentials before logging
    dimension: payload.dimension,
    result: null, // Filled in by after-handle
  });
});

The audit log becomes a structured, queryable record of every state change in the system. You can answer:

  • "Who closed feature X and when?"
  • "What was the last mutation to the CDR knowledge base?"
  • "How many times was the 'delete repo' capability invoked?"

With the generic channel, none of this was possible. The audit log would say capability:run with a blob of JSON that could mean anything.

Migration pattern

If you're migrating an existing Electron app, the pattern is straightforward:

Step 1: Inventory all renderer-triggered operations

Search your codebase for every ipcRenderer.invoke("capability:run", ...) or equivalent. List every capability ID. This is your attack surface.

Step 2: Classify as read or write

  • Read: Queries, data fetching, status checks. Renderer can invoke these.
  • Write: Mutations, deletions, configuration changes. These need typed channels + confirmation.

Step 3: Define Zod schemas for write operations

export const closeFeatureSchema = z.object({
  featureName: z.string().min(1).max(100),
  currentStage: z.enum(["analysis", "design", "implementation", "verification", "closed"]),
  dimension: z.literal("feature"),
  force: z.boolean().optional().default(false),
});

Step 4: Replace call sites

Replace generic invocations with channel-specific calls:

// Before
await ipcRenderer.invoke("dapei:capability:run", "feature.close", { featureName });
 
// After
await ipcRenderer.invoke("feature:close", { featureName, dimension: "feature" });

Step 5: Remove the generic channel

Once all call sites are migrated, delete the generic handler. Your app is now type-safe at the IPC boundary.

What we gained

ConcernBeforeAfter
XSS blast radiusFull system compromiseRead-only queries only
Payload validationNone (passthrough to engine)Zod schema per channel
Audit granularity"Capability run" with opaque blobStructured event per operation
Developer experience"Just add a capability""Write a schema and a handler"
Error messagesEngine error (inconsistent)Structured Zod or dimension errors

The last row matters more than you'd think. With typed schemas, the renderer gets precise error messages: "Field 'featureName' must be a string, got undefined" instead of "engine error: something went wrong." Development velocity improved, not regressed.

Bottom line

Killing the generic capability:run channel was one of the best architectural decisions we made. It forced us to be explicit about the IPC contract, document every operation's expected payload, and design the confirmation UX early — before we needed it.

In a world where your app runs LLM-generated content in the renderer, a generic IPC channel is not just technical debt. It's a security vulnerability waiting to be triggered.


The full ADR (ADR-0021) is available on GitHub. Fabriq is open source — browse the IPC contracts.

Related articles