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
@@ -26,6 +26,8 @@ const BUNDLES = [
|
||||
// Known-hosts store: TOFU / changed / unreadable fail-closed behavior.
|
||||
{ entry: 'src/main/knownHosts.ts', out: 'tests/.known-hosts.cjs' },
|
||||
{ entry: 'src/main/settingsStore.ts', out: 'tests/.settings-store.cjs' },
|
||||
// Local-path admission (grants): pure fs/path, no electron surface at all.
|
||||
{ entry: 'src/main/localPathGrants.ts', out: 'tests/.local-path-grants.cjs' },
|
||||
// Lock-password store: scrypt verifier, round trip, damaged-file handling.
|
||||
{ entry: 'src/main/lockStore.ts', out: 'tests/.lock-store.cjs' },
|
||||
// Lock controller: cooldown ladder, serialized attempts, persisted flags.
|
||||
|
||||
@@ -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')
|
||||
@@ -5,11 +5,29 @@
|
||||
import { createRequire } from 'module'
|
||||
import { randomUUID } from 'crypto'
|
||||
import { promises as fsp } from 'fs'
|
||||
import { join } from 'path'
|
||||
const require_ = createRequire(import.meta.url)
|
||||
const sessionLayer = require_('./.session-e2e.cjs')
|
||||
const sftpMod = await import('./.sftp-svc.mjs')
|
||||
sftpMod.registerSftpClientProvider((id) => sessionLayer.getSshClient(id))
|
||||
|
||||
/**
|
||||
* Local-path admission. In the app the default policy only accepts paths the
|
||||
* user granted through a native dialog (localPathGrants.ts); this harness picks
|
||||
* its own temp paths and has no dialog to grant from, so it supplies a
|
||||
* permissive double. It is NOT a re-export of the real policy — the admission
|
||||
* rules have their own test (tests/local-path-grants.mjs) and these scenarios
|
||||
* stay about transfer mechanics.
|
||||
*/
|
||||
sftpMod.setLocalPathPolicy({
|
||||
readSource: (p) => (typeof p === 'string' ? p : null),
|
||||
readDirectory: (d) => (typeof d === 'string' && d !== '' ? d : null),
|
||||
writeTarget: (dir, name) =>
|
||||
typeof dir === 'string' && dir !== '' && typeof name === 'string' && name !== ''
|
||||
? join(dir, name)
|
||||
: null
|
||||
})
|
||||
|
||||
const events = []
|
||||
sessionLayer.configureSessionRuntime({
|
||||
broadcast: (channel, payload) => events.push({ channel, payload }),
|
||||
|
||||
@@ -83,6 +83,23 @@ if (!existsSync(bundlePath)) {
|
||||
const zmodem = require_('zmodem.js')
|
||||
const engine = require_(bundlePath)
|
||||
|
||||
/**
|
||||
* Local-path admission for the engine. In the app the default policy only
|
||||
* accepts paths the user granted through a native dialog (localPathGrants.ts),
|
||||
* and this harness has no dialog to grant from — so the scenarios supply their
|
||||
* own double. It is deliberately permissive (and deliberately NOT a re-export
|
||||
* of the real policy, which is what keeps these scenarios about transfer
|
||||
* mechanics rather than admission rules).
|
||||
*/
|
||||
engine.setLocalPathPolicy({
|
||||
readSource: (p) => (typeof p === 'string' ? p : null),
|
||||
readDirectory: (d) => (typeof d === 'string' && d !== '' ? d : null),
|
||||
writeTarget: (dir, name) =>
|
||||
typeof dir === 'string' && dir !== '' && typeof name === 'string' && name !== ''
|
||||
? join(dir, name)
|
||||
: null
|
||||
})
|
||||
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user