Files
OpenTerminal/tests/local-path-grants.mjs
T
Bill d22923aedd security(main): dialog-grant admission for local paths, explicit webPreferences
New localPathGrants.ts: an in-memory registry of paths the user picked in a
native dialog. Every check resolves realpath + stat at grant and use time;
Windows case folding; missing files, directories-as-files, devices and
symlinked parents can never pass. SFTP upload/download and zmodem send/
receive now refuse renderer-supplied local paths that were never granted,
returning the resolved path so callers never re-traverse a symlink. keyPath
is validated as a regular file <= 1MB before reading (a device node would
have blocked the UI thread forever). Tests inject a stub policy via
setLocalPathPolicy; production always defaults to the real registry.

Also: webPreferences now explicitly pins contextIsolation/nodeIntegration/
webSecurity instead of relying on defaults.

New offline test tests/local-path-grants.mjs (37 assertions incl. symlink
escape); offline suite grows to 13. i18n: 6 main.sftp/main.key error keys
in 4 languages.
2026-10-07 22:06:45 +08:00

186 lines
7.5 KiB
JavaScript

/**
* Local-path admission (local-path-grants.mjs).
*
* src/main/localPathGrants.ts is the check that stands between a renderer-
* supplied path and the main process's filesystem access, so its rules are
* pinned here against a real temp tree rather than a mocked `fs`:
*
* - nothing is usable before the user granted it through the dialog
* - a granted file is readable, a granted directory (and its subdirectories)
* is writable, and unrelated paths stay refused
* - a peer-supplied file name cannot climb out of the download directory
* (`../x`, an absolute path, a name with a separator, `.`/`..`, empty)
* - a symlink planted at the target name is resolved, so a link pointing
* outside the granted tree is refused while one pointing inside is not
* - a grant is re-checked on every use: a path that has since disappeared
* stops being valid
* - granting through the wrong dialog (a directory via pickFiles, a file via
* pickDirectory) grants nothing
*
* Build: node tests/build-bundles.cjs
* Run: node tests/local-path-grants.mjs (must exit 0)
*/
import { createRequire } from 'node:module'
import { execFileSync } from 'node:child_process'
import { existsSync, mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
const __dirname = dirname(fileURLToPath(import.meta.url))
const bundlePath = join(__dirname, '.local-path-grants.cjs')
// Same self-build fallback as zmodem-e2e.mjs, so the file can be run on its own.
if (!existsSync(bundlePath)) {
console.log('[local-path-grants] building bundle ...')
const cmd = process.platform === 'win32' ? 'npx.cmd' : 'npx'
execFileSync(
cmd,
[
'esbuild',
join(__dirname, '../src/main/localPathGrants.ts'),
'--bundle',
'--platform=node',
'--format=cjs',
`--outfile=${bundlePath}`
],
{ stdio: 'inherit', shell: process.platform === 'win32' }
)
}
const require_ = createRequire(import.meta.url)
const { grantedPaths, grantPickedFiles, grantPickedDirectory } = require_(bundlePath)
let failed = 0
const ok = (cond, msg) => {
console.log(` ${cond ? 'ok' : 'FAIL'}: ${msg}`)
if (!cond) failed += 1
}
const root = mkdtempSync(join(tmpdir(), 'ot-grants-'))
// A symlink need not be creatable (Windows without developer mode): the cases
// that need one say so instead of failing the suite.
let canSymlink = true
const symlink = (target, path, kind) => {
if (!canSymlink) return false
try {
symlinkSync(target, path, kind)
return true
} catch {
canSymlink = false
console.log(` skip: symlinks are not creatable here (${path})`)
return false
}
}
try {
const picked = join(root, 'picked')
const other = join(root, 'other')
mkdirSync(picked)
mkdirSync(other)
const pickedDir = realpathSync(picked)
const otherDir = realpathSync(other)
const subDir = join(pickedDir, 'sub')
mkdirSync(subDir)
const pickedFile = join(pickedDir, 'id_ed25519')
writeFileSync(pickedFile, 'key')
const otherFile = join(otherDir, 'secret.txt')
writeFileSync(otherFile, 'secret')
console.log('nothing is allowed before any grant')
ok(grantedPaths.readSource(pickedFile) === null, 'an un-granted existing file is refused')
ok(grantedPaths.readDirectory(pickedDir) === null, 'an un-granted directory is refused')
ok(grantedPaths.writeTarget(pickedDir, 'a.txt') === null, 'a write into it is refused')
console.log('a picked file is readable, and only that file')
grantPickedFiles([pickedFile])
ok(grantedPaths.readSource(pickedFile) === realpathSync(pickedFile), 'the granted file is accepted')
ok(grantedPaths.readSource(otherFile) === null, 'a different file is still refused')
ok(grantedPaths.readSource(pickedDir) === null, 'a directory is not a valid read source')
ok(grantedPaths.readSource(`${pickedFile}.nope`) === null, 'a missing path is refused')
ok(grantedPaths.readSource('') === null, 'an empty path is refused')
console.log('a picked directory accepts writes below it, and nothing else')
grantPickedDirectory(pickedDir)
ok(grantedPaths.readDirectory(pickedDir) === pickedDir, 'the granted directory is accepted')
ok(grantedPaths.readDirectory(subDir) === subDir, 'a subdirectory of it is accepted')
ok(grantedPaths.readDirectory(otherDir) === null, 'an unrelated directory is refused')
ok(
grantedPaths.writeTarget(pickedDir, 'new.txt') === join(pickedDir, 'new.txt'),
'a fresh leaf lands in the directory'
)
ok(
grantedPaths.writeTarget(subDir, 'new.txt') === join(subDir, 'new.txt'),
'a fresh leaf lands in a subdirectory'
)
ok(grantedPaths.writeTarget(otherDir, 'new.txt') === null, 'an unrelated directory is refused')
ok(
grantedPaths.writeTarget(pickedDir, 'new.txt') === join(pickedDir, 'new.txt'),
'extra arguments do not change the target'
)
console.log('a peer-supplied name cannot climb out of the directory')
for (const name of ['../escape.txt', '..\\escape.txt', '..', '.', '', 'a/b', 'a\\b', null, 42, {}]) {
ok(grantedPaths.writeTarget(pickedDir, name) === null, `refused: ${JSON.stringify(name) ?? name}`)
}
console.log('a symlink at the target name is resolved, not followed blindly')
const outside = join(otherDir, 'outside.txt')
writeFileSync(outside, 'orig')
if (symlink(outside, join(pickedDir, 'escape-link.txt'), 'file')) {
ok(
grantedPaths.writeTarget(pickedDir, 'escape-link.txt') === null,
'a link pointing outside the granted tree is refused'
)
}
const insideTarget = join(subDir, 'real.txt')
if (symlink(insideTarget, join(pickedDir, 'inside-link.txt'), 'file')) {
ok(
grantedPaths.writeTarget(pickedDir, 'inside-link.txt') === join(pickedDir, 'inside-link.txt'),
'a link pointing inside the granted tree is still accepted'
)
}
const linkedDir = join(otherDir, 'linked')
if (symlink(linkedDir, join(pickedDir, 'linked-dir'), 'dir')) {
// Nothing was ever granted at other/linked, so the resolved path is outside.
ok(
grantedPaths.writeTarget(join(pickedDir, 'linked-dir'), 'x.txt') === null,
'a symlinked directory leading outside is refused'
)
}
console.log('the wrong dialog grants nothing')
grantPickedFiles([otherDir])
ok(grantedPaths.readSource(otherDir) === null, 'pickFiles of a directory grants no file')
grantPickedDirectory(pickedFile)
ok(grantedPaths.readDirectory(pickedFile) === null, 'pickDirectory of a file grants no directory')
console.log('a grant is re-validated on every use')
const doomed = join(root, 'doomed')
mkdirSync(doomed)
grantPickedDirectory(doomed)
ok(grantedPaths.readDirectory(doomed) === realpathSync(doomed), 'usable while it exists')
rmSync(doomed, { recursive: true })
ok(grantedPaths.readDirectory(doomed) === null, 'refused once it is gone')
ok(grantedPaths.writeTarget(doomed, 'a.txt') === null, 'and no write targets it')
console.log('the grant API tolerates junk from its caller')
grantPickedFiles(undefined)
grantPickedFiles('not-an-array')
grantPickedFiles([null, 42, {}])
grantPickedDirectory(undefined)
grantPickedDirectory('')
ok(grantedPaths.readSource(null) === null, 'a null source is refused')
ok(grantedPaths.readDirectory(undefined) === null, 'an undefined directory is refused')
ok(true, 'no junk input threw')
} finally {
rmSync(root, { recursive: true, force: true })
}
if (failed > 0) {
console.error(`\n[local-path-grants] ${failed} check(s) FAILED`)
process.exit(1)
}
console.log('\n[local-path-grants] ALL CHECKS PASSED')