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.
This commit is contained in:
1 parent
5ff311ea0f
commit
a0be827650
21 files changed
+2370
-174
No files matched your search
@@ -0,0 +1,226 @@
|
||||
/**
|
||||
* 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)`)
|
||||
Reference in new issue
Block a user