Files
OpenTerminal/tests/ipc-guard.mjs
T

275 lines
12 KiB
JavaScript

/**
* IPC sender-frame guard self-test (ipc-guard.mjs).
*
* `installSenderGuard` (src/main/ipc.ts) is the single choke point every IPC
* channel in this app is registered through — four modules register handlers,
* so the check lives where they all pass rather than at each site. It is what
* keeps a frame that navigated away (or an injected one) from driving the main
* process through the preload bridge, which is the app's whole API
* (`createPty` included). What is pinned here:
*
* - `isTrustedRendererUrl`: in a packaged build only the bundled renderer
* file's URL is trusted; in dev only the vite dev server's ORIGIN (any path
* on it, no other port / host). Malformed input is never trusted.
* - through the REAL registration path (`registerIpc` -> the stub's ipcMain),
* a trusted invoke resolves, an untrusted one rejects with the refused
* error instead of reaching the handler, and a missing `senderFrame`
* (Electron gives `null` for a destroyed frame) is refused too.
* - untrusted `send`-style channels are dropped without calling the listener.
* - the guard is installed once, not stacked per registration.
*
* Build: node tests/build-bundles.cjs
* Run: node tests/ipc-guard.mjs (must exit 0)
*/
import { existsSync, mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { createRequire } from 'node:module'
import { fileURLToPath, pathToFileURL } from 'node:url'
const __dirname = dirname(fileURLToPath(import.meta.url))
const userData = mkdtempSync(join(tmpdir(), 'ot-ipc-'))
process.env.OT_STUB_USERDATA = userData
const require = createRequire(import.meta.url)
const stub = require('./electron-stub.cjs')
const { registerIpc, isTrustedRendererUrl } = require('./.ipc.cjs')
const { Ipc } = require('./.ipc-channels.cjs')
let failed = 0
let passed = 0
const ok = (cond, msg) => {
console.log(` ${cond ? 'ok' : 'FAIL'}: ${msg}`)
if (!cond) failed += 1
else passed += 1
}
const DEV_URL = 'http://localhost:5173'
const withDevUrl = (value, fn) => {
const prev = process.env.ELECTRON_RENDERER_URL
if (value === undefined) delete process.env.ELECTRON_RENDERER_URL
else process.env.ELECTRON_RENDERER_URL = value
try {
return fn()
} finally {
if (prev === undefined) delete process.env.ELECTRON_RENDERER_URL
else process.env.ELECTRON_RENDERER_URL = prev
}
}
// The URL a packaged build loads: the renderer file next to the main bundle.
// The test's bundle sits in tests/, the app's in out/main/, so this is the
// `../renderer/index.html` sibling either way.
const PACKAGED_URL = pathToFileURL(join(__dirname, '../renderer/index.html')).href
// ---- 1. the trust predicate -------------------------------------------------
console.log('trusted renderer URL (packaged build: the renderer file and nothing else)')
withDevUrl(undefined, () => {
ok(isTrustedRendererUrl(PACKAGED_URL), 'the bundled renderer file is trusted')
ok(!isTrustedRendererUrl(pathToFileURL(join(__dirname, '../renderer/other.html')).href), 'a sibling file in the renderer dir is not')
ok(!isTrustedRendererUrl(pathToFileURL(join(__dirname, '../package.json')).href), 'another local file is not')
ok(!isTrustedRendererUrl('file:///C:/Windows/System32/drivers/etc/hosts'), 'an unrelated file: URL is not')
ok(!isTrustedRendererUrl('https://evil.example/index.html'), 'a remote origin is not')
ok(!isTrustedRendererUrl('http://localhost:5173/'), 'the dev server is NOT trusted in a packaged build (env ignored)')
})
console.log('trusted renderer URL (dev build: the dev server origin, any path)')
withDevUrl(DEV_URL, () => {
ok(isTrustedRendererUrl(`${DEV_URL}/index.html`), 'a path on the dev origin is trusted')
ok(isTrustedRendererUrl(`${DEV_URL}/`), 'the dev origin root is trusted')
ok(isTrustedRendererUrl(`${DEV_URL}/deep/nested/route?x=1#y`), 'any path/query/hash on that origin is trusted')
ok(!isTrustedRendererUrl('http://localhost:5174/index.html'), 'a different port is a different origin')
ok(!isTrustedRendererUrl('http://127.0.0.1:5173/index.html'), 'a different host spelling is a different origin')
ok(!isTrustedRendererUrl('https://localhost:5173/index.html'), 'a different scheme is a different origin')
ok(!isTrustedRendererUrl('https://evil.example/http://localhost:5173/'), 'a hostile URL embedding the dev URL is not the dev origin')
ok(!isTrustedRendererUrl(PACKAGED_URL), 'the packaged file URL is not the dev origin')
})
console.log('the predicate refuses everything malformed')
withDevUrl(DEV_URL, () => {
for (const raw of ['', 'not a url', '://', 'about:blank', 'javascript:alert(1)', 'data:text/html,x', 'file://']) {
ok(!isTrustedRendererUrl(raw), `refused: ${JSON.stringify(raw)}`)
}
})
// ---- 2. the guard, through the real registration path -----------------------
console.log('the guard on a registered channel')
registerIpc()
const handlers = stub.__handlers
const trustedFrame = { url: `${DEV_URL}/index.html` }
const hostileFrame = { url: 'https://evil.example/hijack.html' }
const invoke = (channel, frame, ...args) => {
const listener = handlers.get(channel)
if (!listener) throw new Error(`no handler registered for ${channel}`)
return listener({ senderFrame: frame, sender: { id: 1 } }, ...args)
}
await withDevUrl(DEV_URL, () => {
ok(typeof handlers.get(Ipc.APP_INFO) === 'function', 'APP_INFO reached the registration path')
ok(handlers.get(Ipc.APP_INFO) !== undefined, 'the stub recorded a handler (registrations are not dropped)')
// The trusted path really executes the handler: it returns the app info
// object instead of rejecting.
const info = invoke(Ipc.APP_INFO, trustedFrame)
ok(
info && typeof info === 'object' && typeof info.platform === 'string' && info.appVersion === '0.0.0-stub',
`a trusted frame reaches the handler (got ${JSON.stringify(info)})`
)
// Path validation inside a handler still applies to a trusted frame: this
// handler refuses non-strings, so a compromised-but-trusted renderer cannot
// open an arbitrary path.
ok(invoke(Ipc.CWD_OPEN, trustedFrame, 'not-a-path') === false, 'handler-level validation still runs for a trusted frame')
ok(invoke(Ipc.CWD_OPEN, trustedFrame, undefined) === false, 'CWD_OPEN refuses a missing path')
ok(invoke(Ipc.CWD_OPEN, trustedFrame, 42) === false, 'CWD_OPEN refuses a non-string path')
const refused = (channel, ...args) => {
try {
invoke(channel, hostileFrame, ...args)
return null
} catch (err) {
return err instanceof Error ? err.message : String(err)
}
}
let msg = refused(Ipc.APP_INFO)
ok(typeof msg === 'string' && msg.includes('untrusted frame'), `an untrusted invoke is refused (${msg})`)
ok(typeof msg === 'string' && msg.includes(Ipc.APP_INFO), 'the refusal names the channel (so it is diagnosable)')
// A hostile frame gets the same refusal on every other invoke channel, and the
// handler is never reached: CWD_OPEN would have thrown/misbehaved, PTY_CREATE
// would have spawned a shell, RULE_VALIDATE would have started a probe worker.
for (const channel of [
Ipc.CWD_OPEN,
Ipc.PTY_CREATE,
Ipc.CONNECTIONS_LIST,
Ipc.SETTINGS_GET,
Ipc.LOCK_STATE_GET,
Ipc.RULE_VALIDATE,
Ipc.RULE_VALIDATION_STATE_GET
]) {
if (!handlers.has(channel)) continue
const m = refused(channel)
ok(typeof m === 'string' && m.includes('untrusted frame'), `refused on ${channel}`)
}
// The rule-validation batch answers a trusted frame without touching settings:
// drafts are probed, never stored. Its own section below awaits the handler,
// so it cannot live inside this synchronous block (see the note there).
const missingFrame = (() => {
try {
handlers.get(Ipc.APP_INFO)({ sender: { id: 1 } }, )
return null
} catch (err) {
return err instanceof Error ? err.message : String(err)
}
})()
ok(
typeof missingFrame === 'string' && missingFrame.includes('untrusted frame'),
'a missing senderFrame (destroyed frame -> null) is refused'
)
// ---- 3. send-style channels are dropped, not rejected ---------------------
// LOG_OPEN_DIR (ipcMain.on) has an observable side effect: it creates the
// logs directory. That makes "the listener really did/did not run"
// checkable, instead of asserting on the absence of a throw.
const send = (frame, ...args) => {
const listener = handlers.get(`on:${Ipc.LOG_OPEN_DIR}`)
if (!listener) throw new Error('LOG_OPEN_DIR was not registered through ipcMain.on')
listener({ senderFrame: frame, sender: { id: 1 } }, ...args)
}
const logsDir = join(userData, 'logs')
ok(typeof handlers.get(`on:${Ipc.LOG_OPEN_DIR}`) === 'function', 'LOG_OPEN_DIR is registered through ipcMain.on (and therefore guarded)')
send(hostileFrame)
ok(!existsSync(logsDir), 'an untrusted send is dropped silently — the listener never ran')
let threw = false
try {
send({ url: 'not a url' })
send(undefined)
} catch {
threw = true
}
ok(!threw && !existsSync(logsDir), 'send on a malformed / missing frame is dropped, not thrown')
send(trustedFrame)
ok(existsSync(logsDir), 'a trusted send reaches the listener (the logs dir was created)')
})
// ---- 4. installed once ------------------------------------------------------
// `installSenderGuard` swaps `ipcMain.handle` / `ipcMain.on` for guarded
// wrappers. If it did not short-circuit on the second call, each registration
// would be wrapped again and the guard would nest — cheap here, but it also
// means a handler added after the first registerIpc could be guarded twice
// while one added before is guarded once, and the two spellings of the same
// check would drift. The wrapper identity is the observable proof.
console.log('the guard is installed once, not stacked')
withDevUrl(DEV_URL, () => {
const handleRef = stub.ipcMain.handle
const onRef = stub.ipcMain.on
registerIpc()
ok(stub.ipcMain.handle === handleRef, 'a second registerIpc does not re-wrap ipcMain.handle')
ok(stub.ipcMain.on === onRef, 'a second registerIpc does not re-wrap ipcMain.on')
const m = (() => {
try {
handlers.get(Ipc.APP_INFO)({ senderFrame: hostileFrame, sender: { id: 1 } })
return null
} catch (err) {
return err instanceof Error ? err.message : String(err)
}
})()
ok(typeof m === 'string' && m.includes('untrusted frame'), 'and an untrusted invoke is still refused after re-registering')
// The duplicated refusals must not multiply: one layer, one message.
ok((m.match(/untrusted frame/g) ?? []).length === 1, 'the refusal message is not nested (exactly one guard layer)')
})
// ---- 4. the rule-validation channel answers a trusted frame -----------------
// Awaiting the handler needs an async scope, and `withDevUrl` restores the env
// var as soon as its callback returns — which for an async callback is the
// moment it hits its first `await`, i.e. before the awaited work resumes. So
// the variable is set by hand around this block instead.
console.log('the rule-validation channel')
{
const previous = process.env.ELECTRON_RENDERER_URL
process.env.ELECTRON_RENDERER_URL = DEV_URL
try {
const report = await invoke(Ipc.RULE_VALIDATE, trustedFrame, {
kind: 'highlight',
items: [{ id: 'draft', pattern: '\\bERROR\\b' }]
})
ok(
report && Array.isArray(report.results) && report.results.length === 1,
'a trusted frame gets a report back from RULE_VALIDATE'
)
// No worker factory is installed in this bundle (the `?nodeWorker` wiring
// lives in index.ts), so every rule stays pending — the fail-closed answer,
// not an error.
ok(report.results[0].state === 'pending', `no worker wired here → pending (got ${report.results[0].state})`)
ok(report.workerAvailable === false, 'the report says the validator is unavailable')
ok(typeof report.runtime === 'string', 'the report names the runtime fingerprint it belongs to')
const state = invoke(Ipc.RULE_VALIDATION_STATE_GET, trustedFrame)
ok(
state && typeof state.pending === 'number' && typeof state.quarantined === 'number',
`the aggregate state answers with counts (${JSON.stringify(state)})`
)
} finally {
if (previous === undefined) delete process.env.ELECTRON_RENDERER_URL
else process.env.ELECTRON_RENDERER_URL = previous
}
}
rmSync(userData, { recursive: true, force: true })
if (failed > 0) {
console.error(`\n[ipc-guard] ${failed} check(s) FAILED`)
process.exit(1)
}
console.log(`\n[ipc-guard] ALL CHECKS PASSED (${passed} assertions)`)