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.
This commit is contained in:
1 parent
9c75cdc7d4
commit
d22923aedd
14 files changed
+612
-18
No files matched your search
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* 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')
|
||||
Reference in new issue
Block a user