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
@@ -157,6 +157,14 @@ function createWindow(): void {
|
||||
...(existsSync(devIcon) ? { icon: nativeImage.createFromPath(devIcon) } : {}),
|
||||
webPreferences: {
|
||||
preload: join(__dirname, '../preload/index.js'),
|
||||
// Written out explicitly even though every value matches Electron's
|
||||
// current default: these are the three that keep the renderer unable to
|
||||
// reach the main process, and a default that silently changed (or a
|
||||
// future Electron release flipping one) must not be the only thing
|
||||
// holding that line. See also the sender guard in ipc.ts.
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
webSecurity: true,
|
||||
// Keep the default renderer sandbox (preload only touches the electron
|
||||
// IPC bridge, so it does not need Node access).
|
||||
sandbox: true
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* Local-path admission for the files the main process is asked to read or write
|
||||
* on behalf of the renderer (SFTP transfers, ZMODEM send/receive).
|
||||
*
|
||||
* Threat model: the renderer is the untrusted half of the app. Every local path
|
||||
* it sends over IPC used to be taken at face value, so a compromised renderer
|
||||
* (a navigation that slipped past the frame guard, a future caller that forgets
|
||||
* the dialog) could make the main process read or write anywhere the user can
|
||||
* reach. Local paths are therefore *granted* first, and the only grant source is
|
||||
* a native file / directory dialog (sftp.ts's pickFiles / pickDirectory) — an
|
||||
* explicit choice the user made with their own hands.
|
||||
*
|
||||
* Granting and checking both go through realpath: the registry stores resolved
|
||||
* paths, and a candidate that does not resolve (missing file, dead drive,
|
||||
* unreadable parent) is never granted and never accepted. Comparing only the
|
||||
* literal spelling would let a symlinked parent steer a path that *looks* like
|
||||
* it sits inside a granted directory somewhere else entirely — the resolved path
|
||||
* is the only one that answers "where would this actually read/write?".
|
||||
*
|
||||
* Grant lifetime is the process: a directory the user picked as a save target
|
||||
* stays valid for later transfers into it. Each dialog returns at most a handful
|
||||
* of paths, so both sets stay tiny.
|
||||
*
|
||||
* `grantedPaths` is the real policy and the DEFAULT for every consumer, so a
|
||||
* caller that forgets to inject one is still enforced. `LocalPathPolicy` exists
|
||||
* only so the offline test harnesses (which have no dialog to grant from) can
|
||||
* supply a double; it is never reachable from renderer input.
|
||||
*/
|
||||
import { realpathSync, statSync } from 'fs'
|
||||
import { basename, join, sep } from 'path'
|
||||
|
||||
/**
|
||||
* What the transfer engines need to know about local paths. Implemented by
|
||||
* `grantedPaths` below; tests inject a permissive double.
|
||||
*
|
||||
* The methods return the *resolved* path to use rather than a boolean, so the
|
||||
* caller opens exactly what was checked — re-joining or re-reading the original
|
||||
* spelling would reopen the symlink window the check just closed.
|
||||
*/
|
||||
export interface LocalPathPolicy {
|
||||
/** The local file an upload may read, or null when it must be refused. */
|
||||
readSource(path: unknown): string | null
|
||||
/** The local directory a download may be written into, or null. */
|
||||
readDirectory(dir: unknown): string | null
|
||||
/**
|
||||
* The local path a write of `fileName` into `dir` may go to, or null when it
|
||||
* must be refused. `fileName` comes off the wire (a remote file's basename,
|
||||
* or the name a ZMODEM peer offered).
|
||||
*/
|
||||
writeTarget(dir: unknown, fileName: unknown): string | null
|
||||
}
|
||||
|
||||
const grantedFiles = new Set<string>()
|
||||
const grantedDirs = new Set<string>()
|
||||
|
||||
/**
|
||||
* Windows compares paths case-insensitively; drive-letter and name casing must
|
||||
* not decide whether a granted path matches. Applied to comparisons only — a
|
||||
* path handed back to a caller keeps the casing the filesystem reported.
|
||||
*/
|
||||
const fold = (p: string): string => (process.platform === 'win32' ? p.toLowerCase() : p)
|
||||
|
||||
/** Real path of an existing regular file, or null. */
|
||||
function realFile(p: unknown): string | null {
|
||||
if (typeof p !== 'string' || p === '') return null
|
||||
try {
|
||||
const real = realpathSync(p)
|
||||
return statSync(real).isFile() ? real : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** Real path of an existing directory, or null. */
|
||||
function realDir(p: unknown): string | null {
|
||||
if (typeof p !== 'string' || p === '') return null
|
||||
try {
|
||||
const real = realpathSync(p)
|
||||
return statSync(real).isDirectory() ? real : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether a folded, already-resolved directory sits at or under a granted dir. */
|
||||
function isGrantedKey(key: string): boolean {
|
||||
for (const dir of grantedDirs) {
|
||||
if (key === dir || key.startsWith(dir + sep)) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/** A single path segment: no separators, no `.`/`..`, not empty. */
|
||||
function isPlainLeaf(name: unknown): name is string {
|
||||
return (
|
||||
typeof name === 'string' &&
|
||||
name !== '' &&
|
||||
name !== '.' &&
|
||||
name !== '..' &&
|
||||
basename(name) === name
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Grant the files a native "open file" dialog just returned. A non-file in the
|
||||
* list (a path that vanished between the dialog and here) grants nothing —
|
||||
* everything reaches the engine through realpath, so an unresolvable entry can
|
||||
* never match later either.
|
||||
*/
|
||||
export function grantPickedFiles(paths: unknown): void {
|
||||
if (!Array.isArray(paths)) return
|
||||
for (const p of paths) {
|
||||
const real = realFile(p)
|
||||
if (real !== null) grantedFiles.add(fold(real))
|
||||
}
|
||||
}
|
||||
|
||||
/** Grant the directory a native "open directory" dialog just returned. */
|
||||
export function grantPickedDirectory(dir: unknown): void {
|
||||
const real = realDir(dir)
|
||||
if (real !== null) grantedDirs.add(fold(real))
|
||||
}
|
||||
|
||||
/**
|
||||
* The real policy. Every method re-resolves the path at check time: a file that
|
||||
* has since been deleted, replaced by a directory, or had a symlink swapped in
|
||||
* over it fails, even though it was granted earlier.
|
||||
*/
|
||||
export const grantedPaths: LocalPathPolicy = {
|
||||
readSource(path: unknown): string | null {
|
||||
const real = realFile(path)
|
||||
return real !== null && grantedFiles.has(fold(real)) ? real : null
|
||||
},
|
||||
|
||||
readDirectory(dir: unknown): string | null {
|
||||
const real = realDir(dir)
|
||||
return real !== null && isGrantedKey(fold(real)) ? real : null
|
||||
},
|
||||
|
||||
writeTarget(dir: unknown, fileName: unknown): string | null {
|
||||
const dirReal = realDir(dir)
|
||||
if (dirReal === null) return null
|
||||
const key = fold(dirReal)
|
||||
if (!isGrantedKey(key)) return null
|
||||
// Checked as a plain leaf first: a name still carrying a separator, or the
|
||||
// bare `..`, must not climb out of the directory the user chose.
|
||||
if (!isPlainLeaf(fileName)) return null
|
||||
// The join is made against the *resolved* directory, so the containment
|
||||
// check and the actual write agree on the target.
|
||||
const target = join(dirReal, fileName)
|
||||
let resolved: string
|
||||
try {
|
||||
resolved = realpathSync(target)
|
||||
} catch {
|
||||
// Not on disk yet: a fresh file created directly inside the granted dir.
|
||||
return target
|
||||
}
|
||||
// Something is already there. Resolve it too: a symlink planted at that
|
||||
// name would otherwise land the bytes somewhere else entirely while the
|
||||
// write still looks like it targets the download folder.
|
||||
const targetKey = fold(resolved)
|
||||
if (targetKey === key || !targetKey.startsWith(key + sep)) return null
|
||||
return target
|
||||
}
|
||||
}
|
||||
+97
-8
@@ -2,10 +2,28 @@ import { dialog } from 'electron'
|
||||
import type { Client, SFTPWrapper } from 'ssh2'
|
||||
import { randomUUID } from 'crypto'
|
||||
import { createWriteStream, promises as fsp } from 'fs'
|
||||
import { basename, join } from 'path'
|
||||
import { basename } from 'path'
|
||||
import { Ipc } from '../shared/ipc'
|
||||
import { t } from '../shared/i18n'
|
||||
import type { SftpEntry, TransferProgressEvent } from '../shared/sftp'
|
||||
import {
|
||||
grantPickedDirectory,
|
||||
grantPickedFiles,
|
||||
grantedPaths,
|
||||
type LocalPathPolicy
|
||||
} from './localPathGrants'
|
||||
|
||||
/**
|
||||
* Local-path admission for this module's transfers. Defaults to the real grant
|
||||
* registry — a caller that forgets to inject one is still enforced — and is
|
||||
* only swapped by the offline test harnesses, which have no dialog to grant
|
||||
* from. Never fed from renderer input.
|
||||
*/
|
||||
let pathPolicy: LocalPathPolicy = grantedPaths
|
||||
|
||||
export function setLocalPathPolicy(policy: LocalPathPolicy): void {
|
||||
pathPolicy = policy
|
||||
}
|
||||
|
||||
/** ssh2 Client lookup injected by pty.ts (kind === 'ssh' sessions only). */
|
||||
let clientProvider: ((id: string) => Client | undefined) | undefined
|
||||
@@ -347,12 +365,74 @@ type Broadcast = (channel: string, payload: unknown) => void
|
||||
const CHUNK = 256 * 1024
|
||||
const UPLOAD_WINDOW = 64
|
||||
|
||||
/**
|
||||
* Path checks for the local side of a transfer.
|
||||
*
|
||||
* Threat model: `localPaths` and `localDir` arrive from the renderer, which is
|
||||
* untrusted — without a check the main process would read or write any path the
|
||||
* user can reach (a compromised renderer, a hand-crafted IPC message, or a
|
||||
* future caller that forgets the dialog). Only paths the user granted through a
|
||||
* native dialog are accepted; see localPathGrants.ts for what a grant is and
|
||||
* why it is realpath-based.
|
||||
*
|
||||
* Both helpers throw, so the invoke rejects right away and the renderer
|
||||
* surfaces the reason instead of starting a transfer that fails later. The
|
||||
* checks run *before* the sftp channel is opened, so a refused request never
|
||||
* costs a subsystem channel. Each returns the *resolved* path to open: the
|
||||
* caller must use that, not the renderer's spelling, or it would re-traverse
|
||||
* the very symlink the check followed.
|
||||
*/
|
||||
function requireUploadSources(localPaths: unknown): { source: string; name: string }[] {
|
||||
if (!Array.isArray(localPaths) || localPaths.length === 0) {
|
||||
throw new Error(t('main.sftp.localNotAllowed', { path: '' }))
|
||||
}
|
||||
return localPaths.map(local => {
|
||||
// readSource also insists on a real regular file: a directory or a device
|
||||
// node must never reach the upload's file handle.
|
||||
const source = pathPolicy.readSource(local)
|
||||
if (source === null) {
|
||||
throw new Error(t('main.sftp.localNotAllowed', { path: String(local) }))
|
||||
}
|
||||
// The remote name stays the one derived from the path the USER saw in the
|
||||
// dialog, not from the resolved target: FilePanel computes its overwrite
|
||||
// confirmation from the same spelling, and a name that differed from the
|
||||
// one the user approved would let an upload overwrite a file it never
|
||||
// warned about. Only the path that gets READ is the resolved one.
|
||||
return { source, name: basename(String(local)) }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Where each remote file lands locally. The directory must be one the user
|
||||
* granted, and the name is the remote file's basename — checked as a plain leaf
|
||||
* with any symlink already sitting at that name resolved (see
|
||||
* localPathGrants.ts).
|
||||
*/
|
||||
function requireDownloadTargets(
|
||||
remotePaths: unknown,
|
||||
localDir: unknown
|
||||
): { remote: string; name: string; target: string }[] {
|
||||
if (!Array.isArray(remotePaths) || remotePaths.length === 0) {
|
||||
throw new Error(t('main.sftp.localNotAllowed', { path: String(localDir) }))
|
||||
}
|
||||
return remotePaths.map(remote => {
|
||||
const path = String(remote)
|
||||
const name = basename(path)
|
||||
const target = pathPolicy.writeTarget(localDir, name)
|
||||
if (target === null) {
|
||||
throw new Error(t('main.sftp.localNotAllowed', { path: String(localDir) }))
|
||||
}
|
||||
return { remote: path, name, target }
|
||||
})
|
||||
}
|
||||
|
||||
export function uploadRemote(
|
||||
sessionId: string,
|
||||
localPaths: string[],
|
||||
remoteDir: string,
|
||||
broadcast: Broadcast
|
||||
): Promise<string> {
|
||||
const sources = requireUploadSources(localPaths)
|
||||
return withSftp(sessionId, async sftp => {
|
||||
const id = randomUUID()
|
||||
const transfer: ActiveTransfer = { kind: 'upload', cancelled: false }
|
||||
@@ -361,9 +441,8 @@ export function uploadRemote(
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
for (const local of localPaths) {
|
||||
for (const { source: local, name } of sources) {
|
||||
if (transfer.cancelled) break
|
||||
const name = basename(local)
|
||||
const size = (await fsp.stat(local)).size
|
||||
const remote = `${remoteDir.replace(/\/+$/, '')}/${name}`
|
||||
const handle = await p<Buffer>(cb => sftp.open(remote, 'w', cb))
|
||||
@@ -447,6 +526,7 @@ export function downloadRemote(
|
||||
localDir: string,
|
||||
broadcast: Broadcast
|
||||
): Promise<string> {
|
||||
const targets = requireDownloadTargets(remotePaths, localDir)
|
||||
return withSftp(sessionId, async sftp => {
|
||||
const id = randomUUID()
|
||||
const transfer: ActiveTransfer = { kind: 'download', cancelled: false }
|
||||
@@ -455,11 +535,9 @@ export function downloadRemote(
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
for (const remote of remotePaths) {
|
||||
for (const { remote, name, target: local } of targets) {
|
||||
if (transfer.cancelled) break
|
||||
const name = basename(remote)
|
||||
const { size } = await p<{ size: number }>(cb => sftp.stat(remote, cb))
|
||||
const local = join(localDir, name)
|
||||
const handle = await p<Buffer>(cb => sftp.open(remote, 'r', cb))
|
||||
try {
|
||||
const localHandle = await fsp.open(local, 'w')
|
||||
@@ -516,13 +594,24 @@ export function downloadRemote(
|
||||
})
|
||||
}
|
||||
|
||||
/** Native file dialogs parented to the focused window. */
|
||||
/**
|
||||
* Native file dialogs parented to the focused window.
|
||||
*
|
||||
* These two are the ONLY grant source (see localPathGrants.ts): whatever the
|
||||
* user picks here becomes usable by the transfer paths, and nothing else does.
|
||||
* A cancelled dialog grants nothing — the empty result must not be read as
|
||||
* "no restriction". Both return `filePaths` verbatim so the renderer's own
|
||||
* display logic is unchanged.
|
||||
*/
|
||||
export async function pickFiles(): Promise<string[]> {
|
||||
const r = await dialog.showOpenDialog({ properties: ['openFile', 'multiSelections'] })
|
||||
grantPickedFiles(r.filePaths)
|
||||
return r.filePaths
|
||||
}
|
||||
|
||||
export async function pickDirectory(): Promise<string> {
|
||||
const r = await dialog.showOpenDialog({ properties: ['openDirectory'] })
|
||||
return r.filePaths[0] ?? ''
|
||||
const dir = r.filePaths[0] ?? ''
|
||||
if (dir !== '') grantPickedDirectory(dir)
|
||||
return dir
|
||||
}
|
||||
+39
-4
@@ -16,11 +16,20 @@
|
||||
import type { SshConnection, SshSecretOverride } from '../shared/connections'
|
||||
import { t } from '../shared/i18n'
|
||||
import { randomUUID } from 'crypto'
|
||||
import { readFileSync } from 'fs'
|
||||
import { readFileSync, realpathSync, statSync } from 'fs'
|
||||
import { fingerprintOf, type HostKeyCheckResult } from './knownHosts'
|
||||
import type { ConnectionsStore } from './connectionsStore'
|
||||
import type { Client, ClientChannel, ConnectConfig } from 'ssh2'
|
||||
|
||||
/**
|
||||
* Private keys are a few KB; a file past this is a mistyped path (a disk image,
|
||||
* a log), and reading it would put the whole thing on the UI thread's heap.
|
||||
*/
|
||||
const MAX_KEY_BYTES = 1024 * 1024
|
||||
|
||||
/** A refusal we produced ourselves: the message is already user-complete. */
|
||||
class RefusedKeyError extends Error {}
|
||||
|
||||
export interface SshSessionHandle {
|
||||
id: string
|
||||
/** established ssh connection; powers the session routing in pty.ts */
|
||||
@@ -350,11 +359,37 @@ export function resolveHostKey(promptId: string, action: 'accept' | 'reject'): v
|
||||
pending.resolve(action === 'accept')
|
||||
}
|
||||
|
||||
/** Read a private key file; surfaces a descriptive error on failure. */
|
||||
/**
|
||||
* Read a private key file, refusing anything that is not a regular file.
|
||||
*
|
||||
* Threat model: `keyPath` reaches here from the renderer (typed into the edit
|
||||
* dialog, persisted in connections.json, then echoed back on connect), and it
|
||||
* lands in a synchronous read on the UI thread. Two failures there would be
|
||||
* fatal to the app, not just to this connection:
|
||||
*
|
||||
* - a device or FIFO (`/dev/zero`, `\\.\pipe\...`) makes the read block
|
||||
* forever — the main process stops answering, and nothing times it out. A
|
||||
* directory at least fails, but only with a confusing EISDIR.
|
||||
* - a huge file (a disk image, a log) is slurped into memory in one call, so
|
||||
* a mistyped path can exhaust the heap.
|
||||
*
|
||||
* A symlink is a third problem: the OS follows it, so the file actually read is
|
||||
* not the file that was named — and a link the user did not create could point
|
||||
* at anything. Resolving with realpath first makes the check and the read agree
|
||||
* on one path, and the read then uses that resolved path.
|
||||
*/
|
||||
function readKeyFile(keyPath: string): string {
|
||||
try {
|
||||
return readFileSync(keyPath, 'utf8')
|
||||
const real = realpathSync(keyPath)
|
||||
const st = statSync(real)
|
||||
if (!st.isFile()) throw new RefusedKeyError(t('main.ssh.keyNotAFile'))
|
||||
if (st.size > MAX_KEY_BYTES) throw new RefusedKeyError(t('main.ssh.keyTooLarge'))
|
||||
return readFileSync(real, 'utf8')
|
||||
} catch (err) {
|
||||
throw new Error(t('main.ssh.readKeyFailed', { path: keyPath, detail: (err as Error).message }))
|
||||
// A refusal is already a complete message; an I/O error has only an errno
|
||||
// to contribute, so it gets wrapped with the path the user typed.
|
||||
if (err instanceof RefusedKeyError) throw err
|
||||
const detail = err instanceof Error ? err.message : String(err)
|
||||
throw new Error(t('main.ssh.readKeyFailed', { path: keyPath, detail }))
|
||||
}
|
||||
}
|
||||
+56
-5
@@ -15,7 +15,7 @@
|
||||
* reach the terminal), and pty.ts's writePty drops user keystrokes via
|
||||
* isZmodemActive.
|
||||
*/
|
||||
import { basename, join } from 'path'
|
||||
import { basename } from 'path'
|
||||
import { createReadStream, createWriteStream } from 'fs'
|
||||
import { stat } from 'fs/promises'
|
||||
import type { Writable } from 'stream'
|
||||
@@ -24,6 +24,19 @@ import { Ipc } from '../shared/ipc'
|
||||
import type { ZmodemDoneEvent, ZmodemResponse } from '../shared/ipc'
|
||||
import type { TransferProgressEvent } from '../shared/sftp'
|
||||
import { t } from '../shared/i18n'
|
||||
import { grantedPaths, type LocalPathPolicy } from './localPathGrants'
|
||||
|
||||
/**
|
||||
* Local-path admission, same shape and same rationale as sftp.ts's: defaults to
|
||||
* the real grant registry so a caller that forgets to inject one is still
|
||||
* enforced, and the offline e2e harness swaps in a double because it has no
|
||||
* dialog to grant from. Never fed from renderer input.
|
||||
*/
|
||||
let pathPolicy: LocalPathPolicy = grantedPaths
|
||||
|
||||
export function setLocalPathPolicy(policy: LocalPathPolicy): void {
|
||||
pathPolicy = policy
|
||||
}
|
||||
|
||||
/** How long to wait for the renderer to answer a ZMODEM_OFFER (ms). */
|
||||
const OFFER_TIMEOUT_MS = 120_000
|
||||
@@ -293,6 +306,11 @@ async function sendOneFile(
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload the approved local files. `paths` are the paths the policy accepted
|
||||
* *and* resolved, so `createReadStream` opens exactly what was checked; the
|
||||
* name offered to the peer is the basename of that resolved path.
|
||||
*/
|
||||
async function runSend(engine: Engine, paths: string[]): Promise<void> {
|
||||
try {
|
||||
const session = engine.session as Zmodem.SendSession
|
||||
@@ -332,8 +350,18 @@ async function runSend(engine: Engine, paths: string[]): Promise<void> {
|
||||
|
||||
function handleOffer(engine: Engine, offer: Zmodem.Offer): void {
|
||||
const details = offer.get_details()
|
||||
// The offered name is the PEER's string, so it is display data only: the path
|
||||
// actually written is decided by the policy below, which refuses anything that
|
||||
// is not a plain leaf inside the directory the user granted. `basename` keeps
|
||||
// the UI honest for a peer that sends a path rather than a name.
|
||||
const name = basename(String(details.name ?? 'file'))
|
||||
const dest = join(engine.dir ?? '.', name)
|
||||
const dest = pathPolicy.writeTarget(engine.dir, name)
|
||||
if (dest === null) {
|
||||
// Refusing one file must not silently look like success: end the transfer
|
||||
// with the reason, which also stops the peer (finalize aborts the session).
|
||||
finalize(engine, false, t('main.zmodem.savePathNotAllowed', { name }))
|
||||
return
|
||||
}
|
||||
if (details.size) engine.totalBytes += details.size
|
||||
|
||||
const stream = createWriteStream(dest)
|
||||
@@ -596,7 +624,15 @@ export function respondZmodem(resp: ZmodemResponse): void {
|
||||
abortSession(engine, t('main.zmodem.noSaveDir'))
|
||||
return
|
||||
}
|
||||
engine.dir = resp.dir
|
||||
// The save directory came from the renderer: only one the user granted
|
||||
// through the dialog is usable (localPathGrants.ts). Resolved once here, so
|
||||
// every file of the transfer is written into the directory that was checked.
|
||||
const dir = pathPolicy.readDirectory(resp.dir)
|
||||
if (dir === null) {
|
||||
abortSession(engine, t('main.zmodem.saveDirNotAllowed'))
|
||||
return
|
||||
}
|
||||
engine.dir = dir
|
||||
const session = engine.session as Zmodem.ReceiveSession
|
||||
session.on('offer', (offer) => handleOffer(engine, offer))
|
||||
void session
|
||||
@@ -605,12 +641,27 @@ export function respondZmodem(resp: ZmodemResponse): void {
|
||||
finalize(engine, false, err instanceof Error ? err.message : t('main.zmodem.receiveFailed'))
|
||||
)
|
||||
} else {
|
||||
const paths = resp.paths ?? []
|
||||
// Same for the files to send: the renderer supplies the paths, so each one
|
||||
// must be a file the user picked. Checked before the transfer starts —
|
||||
// rejecting mid-stream would leave the peer waiting on an abort.
|
||||
const paths = Array.isArray(resp.paths) ? resp.paths : []
|
||||
if (paths.length === 0) {
|
||||
abortSession(engine, t('main.zmodem.noFiles'))
|
||||
return
|
||||
}
|
||||
void runSend(engine, paths)
|
||||
// All or nothing. Dropping just the refused entries would run a transfer
|
||||
// that reports success while quietly shipping fewer files than the user
|
||||
// selected.
|
||||
const allowed: string[] = []
|
||||
for (const p of paths) {
|
||||
const source = pathPolicy.readSource(p)
|
||||
if (source === null) {
|
||||
abortSession(engine, t('main.zmodem.filesNotAllowed'))
|
||||
return
|
||||
}
|
||||
allowed.push(source)
|
||||
}
|
||||
void runSend(engine, allowed)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,8 @@ const main: Record<string, string> = {
|
||||
'main.ssh.shellOpenFailed': 'Could not open the SSH shell ({host}:{port}): {detail}',
|
||||
'main.ssh.initFailed': 'SSH connection initialization failed ({host}:{port}): {detail}',
|
||||
'main.ssh.readKeyFailed': 'Could not read the private key file {path}: {detail}',
|
||||
'main.ssh.keyNotAFile': 'The private key path is not a regular file (directories, devices and other special files are refused)',
|
||||
'main.ssh.keyTooLarge': 'The private key file is too large - the path is probably wrong',
|
||||
|
||||
'main.sftp.sessionGone': 'The SSH session does not exist or has disconnected',
|
||||
'main.sftp.deleteFailed': 'Delete failed: {detail}',
|
||||
@@ -35,6 +37,7 @@ const main: Record<string, string> = {
|
||||
'main.sftp.openTimeout': 'Opening the SFTP channel timed out',
|
||||
'main.sftp.commandExitCode': 'Command exited with code {code}',
|
||||
'main.sftp.cancelled': 'Cancelled',
|
||||
'main.sftp.localNotAllowed': 'Local path not authorised: {path}. Pick it again with the "choose file / choose directory" dialog.',
|
||||
|
||||
'main.zmodem.transferFailed': 'Transfer failed',
|
||||
'main.zmodem.transferTimeout': 'Transfer timed out',
|
||||
@@ -49,6 +52,9 @@ const main: Record<string, string> = {
|
||||
'main.zmodem.sessionCreateFailed': 'Could not establish the ZMODEM session',
|
||||
'main.zmodem.noSaveDir': 'No save directory specified',
|
||||
'main.zmodem.noFiles': 'No files selected',
|
||||
'main.zmodem.saveDirNotAllowed': 'The save directory is not authorised. Pick it again with the "choose directory" dialog.',
|
||||
'main.zmodem.savePathNotAllowed': 'The remote file name was refused, transfer aborted: {name}',
|
||||
'main.zmodem.filesNotAllowed': 'The selected local files are not authorised, transfer aborted',
|
||||
|
||||
'main.ipc.connectionMissing': 'Connection bookmark not found ({id})',
|
||||
|
||||
|
||||
@@ -26,6 +26,8 @@ const main: Record<string, string> = {
|
||||
'main.ssh.shellOpenFailed': 'SSH シェルを開けません ({host}:{port}): {detail}',
|
||||
'main.ssh.initFailed': 'SSH 接続の初期化に失敗しました ({host}:{port}): {detail}',
|
||||
'main.ssh.readKeyFailed': '秘密鍵ファイルを読み取れません {path}: {detail}',
|
||||
'main.ssh.keyNotAFile': '秘密鍵のパスが通常のファイルではありません(ディレクトリ・デバイス・その他の特殊ファイルは受け付けません)',
|
||||
'main.ssh.keyTooLarge': '秘密鍵ファイルが大きすぎます。パスの指定が誤っている可能性があります',
|
||||
|
||||
'main.sftp.sessionGone': 'SSH セッションが存在しないか、切断されています',
|
||||
'main.sftp.deleteFailed': '削除に失敗しました: {detail}',
|
||||
@@ -35,6 +37,7 @@ const main: Record<string, string> = {
|
||||
'main.sftp.openTimeout': 'SFTP チャネルのオープンがタイムアウトしました',
|
||||
'main.sftp.commandExitCode': 'コマンドの終了コード {code}',
|
||||
'main.sftp.cancelled': 'キャンセルしました',
|
||||
'main.sftp.localNotAllowed': 'ローカルパスが許可されていません: {path}。「ファイルを選択 / ディレクトリを選択」ダイアログで選び直してください。',
|
||||
|
||||
'main.zmodem.transferFailed': '転送に失敗しました',
|
||||
'main.zmodem.transferTimeout': '転送がタイムアウトしました',
|
||||
@@ -49,6 +52,9 @@ const main: Record<string, string> = {
|
||||
'main.zmodem.sessionCreateFailed': 'ZMODEM セッションを確立できません',
|
||||
'main.zmodem.noSaveDir': '保存先ディレクトリが指定されていません',
|
||||
'main.zmodem.noFiles': 'ファイルが選択されていません',
|
||||
'main.zmodem.saveDirNotAllowed': '保存先ディレクトリが許可されていません。「ディレクトリを選択」ダイアログで選び直してください。',
|
||||
'main.zmodem.savePathNotAllowed': 'リモートのファイル名を受け付けられないため、転送を中止しました: {name}',
|
||||
'main.zmodem.filesNotAllowed': '選択されたローカルファイルが許可されていないため、転送を中止しました',
|
||||
|
||||
'main.ipc.connectionMissing': '接続ブックマークが見つかりません ({id})',
|
||||
|
||||
|
||||
@@ -26,6 +26,8 @@ const main: Record<string, string> = {
|
||||
'main.ssh.shellOpenFailed': '无法打开 SSH shell ({host}:{port}): {detail}',
|
||||
'main.ssh.initFailed': 'SSH 连接初始化失败 ({host}:{port}): {detail}',
|
||||
'main.ssh.readKeyFailed': '无法读取私钥文件 {path}: {detail}',
|
||||
'main.ssh.keyNotAFile': '私钥路径不是一个普通文件(目录、设备或其他特殊文件均不被接受)',
|
||||
'main.ssh.keyTooLarge': '私钥文件过大,疑似路径填写有误',
|
||||
|
||||
'main.sftp.sessionGone': 'SSH 会话不存在或已断开',
|
||||
'main.sftp.deleteFailed': '删除失败: {detail}',
|
||||
@@ -35,6 +37,7 @@ const main: Record<string, string> = {
|
||||
'main.sftp.openTimeout': 'SFTP 通道打开超时',
|
||||
'main.sftp.commandExitCode': '命令退出码 {code}',
|
||||
'main.sftp.cancelled': '已取消',
|
||||
'main.sftp.localNotAllowed': '本地路径未被授权: {path}。请通过「选择文件 / 选择目录」对话框重新选择。',
|
||||
|
||||
'main.zmodem.transferFailed': '传输失败',
|
||||
'main.zmodem.transferTimeout': '传输超时',
|
||||
@@ -49,6 +52,9 @@ const main: Record<string, string> = {
|
||||
'main.zmodem.sessionCreateFailed': '无法建立 ZMODEM 会话',
|
||||
'main.zmodem.noSaveDir': '未指定保存目录',
|
||||
'main.zmodem.noFiles': '未选择文件',
|
||||
'main.zmodem.saveDirNotAllowed': '保存目录未被授权,请通过「选择目录」对话框重新选择',
|
||||
'main.zmodem.savePathNotAllowed': '远端文件名不被接受,已中止传输: {name}',
|
||||
'main.zmodem.filesNotAllowed': '所选的本地文件未被授权,已中止传输',
|
||||
|
||||
'main.ipc.connectionMissing': '连接书签不存在 ({id})',
|
||||
|
||||
|
||||
@@ -26,6 +26,8 @@ const main: Record<string, string> = {
|
||||
'main.ssh.shellOpenFailed': '無法開啟 SSH shell ({host}:{port}): {detail}',
|
||||
'main.ssh.initFailed': 'SSH 連線初始化失敗 ({host}:{port}): {detail}',
|
||||
'main.ssh.readKeyFailed': '無法讀取私密金鑰檔案 {path}: {detail}',
|
||||
'main.ssh.keyNotAFile': '私密金鑰路徑不是一般檔案(目錄、裝置或其他特殊檔案均不接受)',
|
||||
'main.ssh.keyTooLarge': '私密金鑰檔案過大,路徑可能填寫有誤',
|
||||
|
||||
'main.sftp.sessionGone': 'SSH 連線不存在或已中斷',
|
||||
'main.sftp.deleteFailed': '刪除失敗: {detail}',
|
||||
@@ -35,6 +37,7 @@ const main: Record<string, string> = {
|
||||
'main.sftp.openTimeout': 'SFTP 通道開啟逾時',
|
||||
'main.sftp.commandExitCode': '指令結束碼 {code}',
|
||||
'main.sftp.cancelled': '已取消',
|
||||
'main.sftp.localNotAllowed': '本機路徑未獲授權: {path}。請改用「選擇檔案 / 選擇目錄」對話框重新選擇。',
|
||||
|
||||
'main.zmodem.transferFailed': '傳輸失敗',
|
||||
'main.zmodem.transferTimeout': '傳輸逾時',
|
||||
@@ -49,6 +52,9 @@ const main: Record<string, string> = {
|
||||
'main.zmodem.sessionCreateFailed': '無法建立 ZMODEM 連線',
|
||||
'main.zmodem.noSaveDir': '未指定儲存目錄',
|
||||
'main.zmodem.noFiles': '未選擇檔案',
|
||||
'main.zmodem.saveDirNotAllowed': '儲存目錄未獲授權,請改用「選擇目錄」對話框重新選擇',
|
||||
'main.zmodem.savePathNotAllowed': '遠端檔案名稱不被接受,已中止傳輸: {name}',
|
||||
'main.zmodem.filesNotAllowed': '所選的本機檔案未獲授權,已中止傳輸',
|
||||
|
||||
'main.ipc.connectionMissing': '連線書籤不存在 ({id})',
|
||||
|
||||
|
||||
Reference in new issue
Block a user