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.
186 lines
7.5 KiB
JavaScript
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')
|