Skip to content

fix(ipc): redact credential leaves before broadcasting config:changed#59

Open
armstrongsamr wants to merge 1 commit into
LegionIO:mainfrom
armstrongsamr:fix/redact-config-broadcast-secrets
Open

fix(ipc): redact credential leaves before broadcasting config:changed#59
armstrongsamr wants to merge 1 commit into
LegionIO:mainfrom
armstrongsamr:fix/redact-config-broadcast-secrets

Conversation

@armstrongsamr

Copy link
Copy Markdown
Contributor

What

Apply credential redaction to the config:changed IPC broadcast. Before
this fix, flushConfigBroadcast was passing the raw AppConfig to
broadcastToAllWindows (electron/ipc/config.ts:1110), which fans the
payload out to:

  • every BrowserWindow.webContents.send — delivering to all in-process
    renderers, including third-party plugin renderers, for which
    PluginAPI.config.onChanged would otherwise apply the redaction defined
    in electron/plugins/safe-config.ts;
  • broadcastToWebClients — every connected WebSocket on the local web
    server, which crosses a network trust boundary when the web server is
    enabled.

The affected fields are every credential-bearing leaf in
electron/config/schema.ts; the authoritative denylist lives in
electron/plugins/safe-config.ts.

Why

The redaction shape already existed: PluginAPI.config.get was gated by
the config:read-secrets manifest permission, and toPluginSafeConfig
stripped credential leaves on that path. The config:changed broadcast
was the asymmetric gap — every renderer subscribed to the raw IPC channel
received cleartext secrets on every config change, regardless of any
permission declaration, with no audit trail. This PR closes that
asymmetry.

toBroadcastSafeConfig is a thin named alias for toPluginSafeConfig
(same denylist) — the separate symbol documents intent at the broadcast
call site and creates a seam for future divergence.
flushConfigBroadcast now passes the redacted payload to
broadcastToAllWindows while continuing to pass raw currentConfig to
onChanged?.(...), which the agent runtime and MCP loader depend on.

Renderer side: the only consumer of config:changed is
ConfigProvider.tsx. It previously trusted the broadcast to refresh
local state, which would have emptied the API-key inputs in the settings
UI. It now treats the broadcast as a "something changed" signal and
re-fetches via app.config.get() — the pull channel, only reachable from
first-party renderer code.

Tests

Four regression tests in electron/ipc/__tests__/config-broadcast.test.ts
assert that: every credential sentinel planted in a fully-populated
AppConfig is absent from the broadcast while still reaching
onChanged?.(...); a schema-walk guard rejects surviving key names
matching a credential-shape regex (forcing function for future credential
fields added without redactor updates); a string-leaf sweep rejects
payloads containing known credential prefixes; non-secret fields are
preserved.

Test count: 323 → 327 (+4).

Known scope boundary

This PR narrows scope to the push path — IPC channels that fan out
unsolicited to many recipients (every BrowserWindow, every WebSocket
peer). The pull path is unchanged: config:get and config:set both
still return the raw AppConfig to their caller. The
ipcRenderer.invoke response is scoped to the calling webContents,
which today is always first-party renderer code (plugin renderers reach
config via the permission-gated PluginAPI.config.get, not the raw IPC
channel). Caller-identity-aware redaction on the pull path is a separate
decision worth its own PR if the maintainer wants tighter coverage.

Other channels on the same fan-out path (plugin:event,
plugin:ui-state-changed, conversations:changed, etc.) were audited —
none currently embed credentials.

Checklist

  • pnpm lint passes
  • pnpm type-check passes
  • pnpm test passes (or note "no tests yet for this area" with rationale)
  • pnpm build succeeds
  • If this changes IPC, the preload bridge (electron/preload.ts) is updated and the type contract still holds
  • If this adds a new config section, desktopConfigPayload() allowlist is updated
  • Doc impact considered (README, CONTRIBUTING, AGENTS, CLAUDE)

Notes on the checklist:

  • IPC contract impact: config:changed payload type changed from raw
    AppConfig to BroadcastSafeConfig (= PluginSafeConfig — same shape,
    credential leaves removed). ConfigProvider.tsx updated to re-fetch
    via config:get. Preload bridge itself is unchanged; this is a
    payload-shape refinement of an existing channel.
  • Breaking change for third-party plugin renderer code that
    destructured credential fields off the raw config:changed payload —
    those fields are now undefined. Plugin-main code via
    PluginAPI.config.onChanged already received PluginSafeConfig unless
    the plugin declared config:read-secrets
    (plugin-manager.ts:696-720), so it's a no-op for them.
  • No new config sections; desktopConfigPayload() allowlist unchanged.
  • No README / CONTRIBUTING / CLAUDE / AGENTS doc impact in this PR — the
    redaction denylist is documented in
    electron/plugins/safe-config.ts.

A recent security review flagged that `flushConfigBroadcast` in
`electron/ipc/config.ts` was passing the raw `AppConfig` to
`broadcastToAllWindows`. That helper fans out to two surfaces with
different trust profiles:

  - Every `BrowserWindow.webContents.send`, which delivers the payload
    to any in-process renderer — including third-party plugin code
    loaded via the `plugin-renderer://` custom scheme. Plugin renderer
    scripts can subscribe to `window.app.config.onChanged` directly
    and were bypassing the manifest-permission gate that the sandboxed
    plugin API enforces via `electron/plugins/safe-config.ts`.

  - `broadcastToWebClients`, which JSON-encodes the payload onto every
    connected WebSocket on the local web server (`electron/web-server`).
    Once the web server is enabled, the unredacted config crosses a
    network trust boundary on every config change.

The affected fields are the credential leaves in `electron/config/schema.ts`:
`models.providers.*.{apiKey, accessKeyId, secretAccessKey, sessionToken,
extraHeaders}`, `audio.azure.subscriptionKey`,
`{realtime, imageGeneration, videoGeneration}.{openai, azure, custom}.apiKey`,
`memory.semanticRecall.embeddingProvider.*.apiKey`, `mcpServers[i].env`,
`webServer.auth.password`, and `webServer.tls.keyPath`.

Fix
---
Add `toBroadcastSafeConfig` to `electron/plugins/safe-config.ts` as a thin
named wrapper around the existing `toPluginSafeConfig`. Both produce the
same redacted shape so there is a single denylist source of truth, but the
separate name documents the intent at the broadcast call site and provides
a seam if the broadcast redaction ever needs to diverge from the plugin
redaction.

`flushConfigBroadcast` now passes the redacted payload to
`broadcastToAllWindows` while continuing to feed the raw `currentConfig`
to `onChanged?.(...)`, which the agent runtime, MCP loader, and other
main-process subsystems depend on.

Renderer side
-------------
`src/providers/ConfigProvider.tsx` previously trusted the broadcast payload
to refresh local state, which would empty out the API-key inputs in
`src/components/settings/` on every external config change. It now treats
`config:changed` as a "something changed" signal and re-fetches via
`app.config.get()`. The dedicated IPC channel is only readable by
first-party renderer code, so secret values stay on the privileged path.

Tests
-----
`electron/ipc/__tests__/config-broadcast.test.ts` adds four assertions:

  - Every credential sentinel planted in a fully-populated `AppConfig`
    (covering every documented secret slot) is absent from the broadcast
    payload, while the raw `currentConfig` still reaches the main-process
    `onChanged` callback.
  - A schema-walk regression guard rejects any surviving key name that
    matches a credential-shape regex — a new credential field added to
    `AppConfig` without an update to the redactor will surface here.
  - A string-leaf sweep rejects any payload containing a known credential
    prefix (sk-test, AKIA, ghp_, Bearer), guarding against fields whose
    name does not match the schema-walk regex.
  - Non-secret fields (provider type/enabled/endpoint, MCP server
    name/command, webServer port/enabled, `envKeys` for env name
    enumeration) are preserved so the broadcast remains useful for
    public state.

Test count: 323 -> 327 (+4); lint and type-check clean.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant