Files
OpenTerminal/tests/ipc-guard.mjs
T
Bill a0be827650 test(main): cover updater fallback, ipc sender guard, log sanitizer, sftp timeouts
- updater-fallback.mjs (82 assertions): GitHub probe fallback to Gitea,
  timeout budgets, in-flight check never stuck in 'checking'; updater.ts
  gains a setUpdateTimeouts test seam, electron-updater aliased to a stub
- ipc-guard.mjs (43): trusted-frame guard exercised through real
  registerIpc handlers with forged senderFrames; electron-stub now records
  registrations via globalThis so bundle and test share one instance
- log-sanitizer.mjs (71): CSI/OSC/charset state machine, alt-screen fold,
  byte-split fuzz equal to whole-chunk output; fixes a wrong comment
- sftp-timeout.mjs (39): per-op timeouts (metadata 30s, transfer chunk 60s,
  open 10s) evict half-dead channels with one retry, slow-but-progressing
  transfers untouched, late rejections never unhandled
- reservedAccelerators.ts: single pure isReservedAccelerator shared by the
  settings recorder and applyGlobalShortcut (the two tables had drifted —
  main now also refuses Ctrl+=/-/0/PgUp/PgDn legacy values); 56 assertions
- ssh-loopback.mjs loads the real ssh.ts via a bundle (50 assertions):
  TOFU pinning, fail-closed stores, auth gate, connect budget

Offline suite grows 13 -> 17. Renderer test framework evaluated: not
introducing vitest/jsdom; pure logic keeps being extracted and tested
through the existing bundle harness.
2026-10-07 22:57:16 +08:00

227 lines
10 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)
}
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.
for (const channel of [Ipc.CWD_OPEN, Ipc.PTY_CREATE, Ipc.CONNECTIONS_LIST, Ipc.SETTINGS_GET, Ipc.LOCK_STATE_GET]) {
if (!handlers.has(channel)) continue
const m = refused(channel)
ok(typeof m === 'string' && m.includes('untrusted frame'), `refused on ${channel}`)
}
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)')
})
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)`)