7e151bd9d3
`connection.json` under the desktop app's Electron `userData` was written with no
file mode, so it landed at the `0644` umask default — while its two
credential-bearing neighbours in the same directory, `desktop-installation.json`
and `native-oauth-tokens.json`, were already `0600`. That file holds the
safeStorage-encrypted gateway token plus the fields that are NOT encrypted: the
gateway URL and the SSH host, user, and key path.
- Route the single write choke point through a helper that creates the file
owner-only and atomically.
- Tighten an already-existing `0644` file once per launch on the read path, so
installs that already have one do not stay world-readable until the next save.
- Refuse to act on a path that is a symlink or not owned by the current user,
matching the guards `desktop-installation.ts` already applies to its sibling.
The symlink guard alone turned out to be insufficient, and that is worth
recording: `writeSecretFileAtomic` tightens its *temp* path, so a symlink planted
at `connection.json.tmp` meant `writeFileSync` followed it, the guard correctly
bailed, and `renameSync` then moved the link onto `connection.json` permanently.
Measured, guard-only vs. as-landed:
guards only token leaked: true config is a symlink: true 755
guards + temp unlink token leaked: false config is a symlink: false 600
So the temp path is unlinked before the write.
Issue #77486's headline claim — that a dashboard session token is persisted in
plaintext — does not hold against main. The token has been safeStorage-encrypted
since the desktop app reached mainline in 51c68d4ab, and `encryptDesktopSecret`
aborts with an actionable message rather than degrading to plaintext when
safeStorage is unavailable. The `{ encoding: 'plain', value }` literal does exist
at main.ts:7084, but only on the `persistToken: false` branch, whose sole caller
is the connection-test handler, which never writes. So no mainline path *writes*
a plaintext token. The commits that did contain a plaintext-writing fallback
(d3d177283, d208f2c2c) are not ancestors of main — they live only on
upstream/bb/gui-* and the desktop-pr20059-installers pre-release tag.
At-rest migration of legacy non-safeStorage payloads is deliberately NOT included.
An earlier revision of this branch implemented it and it was removed after review
reproduced two token-loss paths: it force-converts the opt-in plaintext choice
PR #62319 adds (silently reverting the user's decision, then destroying the token
on the next launch without the `--password-store=basic` flag), and it converts a
portable credential into a keychain-bound one with no consent — destroying the
only recoverable copy while not remediating the real exposure, since every
existing backup still holds the plaintext and the true remedy is rotation. It also
persisted raw `parsed`, bypassing `sanitizeConnectionProfiles`. A comment at the
read path records the three preconditions any future attempt needs.
`decryptDesktopSecret`'s non-safeStorage read fallback is untouched — it is what
lets a pre-release or hand-edited config work at all.
Windows still inherits the userData directory ACL rather than an explicit
owner-only one; mode bits are advisory there, so that half is deferred to
PR #77527 rather than growing a second ACL implementation here.
e2e: `at-rest-connection-token.spec.ts` asserts the at-rest contract
implementation-independently — the token's plaintext value (and its base64 form)
must not appear in a raw-bytes scan of any file under userData or HERMES_HOME,
AND the app must still put the exact original token on the wire after a restart,
so a fix that simply drops the token cannot pass. Proven non-vacuous by mutation:
writing `{ encoding: 'plain', value }` still fails the scan while the
file-exists and gateway-URL guards pass. The migration case is a documented
`test.fixme` naming its three blockers.
Electron project 928 -> 924 tests (-9 migration, +5 new guard and
mechanism-isolation). Two of those five exist because reverting either owner-only
mechanism alone initially scored zero failures — they were masking each other, so
either could have been deleted green.
(cherry picked from commit 6e01add6578f08f015a567d3a7a7378f2ec3e768)
563 lines
19 KiB
TypeScript
563 lines
19 KiB
TypeScript
import fs from 'node:fs'
|
|
import os from 'node:os'
|
|
import path from 'node:path'
|
|
import { fileURLToPath } from 'node:url'
|
|
|
|
const DEFAULT_FETCH_TIMEOUT_MS = 15_000
|
|
// Default / floor / ceiling for Desktop's data-URL file load (composer attach,
|
|
// image preview, etc.). The whole file is base64-buffered in main, so this is
|
|
// a memory guard — not a model limit. Settings → Chat takes a free-form MB
|
|
// value; 16 MB ships as default. The ceiling is only a typo guard (very large
|
|
// values can OOM / crash the app).
|
|
const DATA_URL_READ_DEFAULT_MAX_MB = 16
|
|
const DATA_URL_READ_MIN_MAX_MB = 1
|
|
const DATA_URL_READ_MAX_MAX_MB = 4096
|
|
// Remote file.attach sends one base64 JSON-RPC frame. Cap the dedicated attach
|
|
// reader so the payload still fits uvicorn's raised ws_max_size (384 MiB)
|
|
// after base64 + framing. Preview stays on the Settings-configurable path.
|
|
const ATTACHMENT_UPLOAD_DEFAULT_MAX_BYTES = 256 * 1024 * 1024
|
|
const TEXT_PREVIEW_SOURCE_MAX_BYTES = 64 * 1024 * 1024
|
|
|
|
function clampDataUrlReadMaxMb(value) {
|
|
const parsed = Number(value)
|
|
|
|
if (!Number.isFinite(parsed)) {
|
|
return DATA_URL_READ_DEFAULT_MAX_MB
|
|
}
|
|
|
|
return Math.min(DATA_URL_READ_MAX_MAX_MB, Math.max(DATA_URL_READ_MIN_MAX_MB, Math.round(parsed)))
|
|
}
|
|
|
|
function dataUrlReadMaxBytesFromMb(maxMb) {
|
|
return clampDataUrlReadMaxMb(maxMb) * 1024 * 1024
|
|
}
|
|
|
|
const SAFE_ENV_SUFFIXES = new Set(['dist', 'example', 'sample', 'template'])
|
|
const SENSITIVE_EXTENSIONS = new Set(['.kdbx', '.p12', '.pem', '.pfx'])
|
|
|
|
// Owner-only mode for userData files that carry credentials (the encrypted
|
|
// gateway token in connection.json, and the URL/SSH fields alongside it).
|
|
// connection.json was the odd one out: its two credential-bearing neighbours
|
|
// under userData are already 0600 — desktop-installation.json
|
|
// (desktop-installation.ts) and native-oauth-tokens.json (main.ts
|
|
// `_nativeTokenStoreIo`) — while connection.json was written with no mode at
|
|
// all and landed at the 0644 umask default. This makes the three consistent.
|
|
const SECRET_FILE_MODE = 0o600
|
|
|
|
// The encoding tag that marks a payload as OS-encrypted. One constant because
|
|
// the writer (encryptDesktopSecret, here) and the reader
|
|
// (decryptDesktopSecret, in main.ts) have to agree on the exact string across
|
|
// a file boundary, and the native-token store round-trips the same shape.
|
|
const SAFE_STORAGE_ENCODING = 'safeStorage'
|
|
|
|
interface SecretFileFs {
|
|
chmodSync: typeof fs.chmodSync
|
|
lstatSync: typeof fs.lstatSync
|
|
renameSync: typeof fs.renameSync
|
|
rmSync: typeof fs.rmSync
|
|
writeFileSync: typeof fs.writeFileSync
|
|
}
|
|
|
|
interface SecretFileOptions {
|
|
encoding?: BufferEncoding
|
|
fs?: SecretFileFs
|
|
platform?: string
|
|
}
|
|
|
|
/**
|
|
* Tighten an existing credential file to owner-only (0600), returning whether
|
|
* the file now has that mode.
|
|
*
|
|
* Exists because `fs.writeFileSync(path, data, { mode })` only applies `mode`
|
|
* when it CREATES the file — rewriting an existing path silently keeps the old
|
|
* bits. So a file already on disk at 0644 (every connection.json written
|
|
* before this change, since that write passed no mode at all) needs an explicit
|
|
* chmod; a fresh `mode:` alone would never tighten it.
|
|
*
|
|
* Guards match `readInstallationId` in desktop-installation.ts, which does the
|
|
* same job for the sibling userData credential file: only ever chmod a regular
|
|
* file we own, never a symlink and never another user's file. Without them a
|
|
* symlink planted at the path would send the chmod to whatever it resolves to.
|
|
*
|
|
* POSIX only. Windows has no meaningful chmod (Node maps it to the read-only
|
|
* bit), and userData there is already ACL'd to the user profile, so we report
|
|
* success without touching the file rather than flipping it read-only and
|
|
* breaking the next write.
|
|
*
|
|
* Never throws: a chmod can legitimately fail (read-only mount, file owned by
|
|
* another user), and failing to tighten a file is not a reason to lose the
|
|
* user's configured gateway.
|
|
*/
|
|
function tightenSecretFileMode(filePath, options: SecretFileOptions = {}) {
|
|
const fsImpl = options.fs || fs
|
|
const platform = options.platform || process.platform
|
|
|
|
if (platform === 'win32') {
|
|
return true
|
|
}
|
|
|
|
try {
|
|
const stat = fsImpl.lstatSync(filePath)
|
|
|
|
if (!stat.isFile() || stat.isSymbolicLink()) {
|
|
return false
|
|
}
|
|
|
|
if (typeof process.getuid === 'function' && stat.uid !== process.getuid()) {
|
|
return false
|
|
}
|
|
|
|
if ((stat.mode & 0o777) === SECRET_FILE_MODE) {
|
|
return true
|
|
}
|
|
|
|
fsImpl.chmodSync(filePath, SECRET_FILE_MODE)
|
|
|
|
return true
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Atomically write a credential file, owner-only wherever the OS expresses
|
|
* permissions as mode bits.
|
|
*
|
|
* On POSIX the file is owner-only from the moment it exists. On Windows this
|
|
* only gets the atomic rename: `tightenSecretFileMode` no-ops there (Node maps
|
|
* chmod to the read-only bit), so the file inherits the userData directory's
|
|
* ACL rather than an explicit owner-only one. Tightening Windows ACLs is being
|
|
* handled once, for the Python `_secure_file`, in PR #77527 — the desktop
|
|
* should follow that rather than start a second ACL story here.
|
|
*
|
|
* The temp-then-rename dance is what makes the mode subtle: `renameSync` keeps
|
|
* the TEMP file's permissions, so writing the temp at the default umask (0644)
|
|
* hands those bits to the target — and a crashed earlier write can leave a
|
|
* stale temp file whose existing loose bits `mode:` will not correct. Hence the
|
|
* unlink of any stale temp (which also drops a planted symlink, so the write
|
|
* cannot be redirected), the create-time `mode`, and the chmod before the
|
|
* rename.
|
|
*/
|
|
function writeSecretFileAtomic(targetPath, data, options: SecretFileOptions = {}) {
|
|
const fsImpl = options.fs || fs
|
|
const tmp = targetPath + '.tmp'
|
|
|
|
fsImpl.rmSync(tmp, { force: true })
|
|
fsImpl.writeFileSync(tmp, data, { encoding: options.encoding, mode: SECRET_FILE_MODE })
|
|
tightenSecretFileMode(tmp, options)
|
|
fsImpl.renameSync(tmp, targetPath)
|
|
}
|
|
|
|
function resolveTimeoutMs(timeoutMs, fallbackMs = DEFAULT_FETCH_TIMEOUT_MS) {
|
|
const fallback =
|
|
Number.isFinite(fallbackMs) && Number(fallbackMs) > 0 ? Math.round(Number(fallbackMs)) : DEFAULT_FETCH_TIMEOUT_MS
|
|
|
|
const parsed = Number(timeoutMs)
|
|
|
|
if (Number.isFinite(parsed) && parsed > 0) {
|
|
return Math.round(parsed)
|
|
}
|
|
|
|
return fallback
|
|
}
|
|
|
|
function encryptDesktopSecret(value, safeStorageApi, options: { allowPlainText?: boolean } = {}) {
|
|
const raw = String(value || '')
|
|
|
|
if (!raw) {
|
|
return null
|
|
}
|
|
|
|
// Opt-in escape hatch for keyring-less Linux (e.g. Hyprland/Sway with no
|
|
// GNOME Keyring or KWallet): the renderer sets this once the user confirms
|
|
// the plain-text storage prompt in Settings → Gateway.
|
|
const allowPlainText = options?.allowPlainText === true
|
|
|
|
let encryptionAvailable = false
|
|
|
|
try {
|
|
encryptionAvailable = Boolean(safeStorageApi?.isEncryptionAvailable?.())
|
|
} catch {
|
|
encryptionAvailable = false
|
|
}
|
|
|
|
if (!encryptionAvailable) {
|
|
// Only downgrade to plain text when the user has explicitly opted in;
|
|
// decryptDesktopSecret returns the raw value for any non-'safeStorage'
|
|
// encoding, so this round-trips without any decrypt-side change.
|
|
if (allowPlainText) {
|
|
return { encoding: 'plain', value: raw }
|
|
}
|
|
|
|
throw new Error(
|
|
'Secure token storage is unavailable (no OS keyring service was found), so Hermes Desktop cannot save remote gateway tokens. ' +
|
|
'Either enable an OS keyring (e.g. GNOME Keyring or KWallet providing org.freedesktop.secrets) and try again, ' +
|
|
'confirm the plain-text storage option when prompted in Settings → Gateway, ' +
|
|
'or set HERMES_DESKTOP_REMOTE_URL and HERMES_DESKTOP_REMOTE_TOKEN in your environment.'
|
|
)
|
|
}
|
|
|
|
try {
|
|
return {
|
|
encoding: SAFE_STORAGE_ENCODING,
|
|
value: safeStorageApi.encryptString(raw).toString('base64')
|
|
}
|
|
} catch (error) {
|
|
const detail = error instanceof Error && error.message ? ` (${error.message})` : ''
|
|
throw new Error(
|
|
`Failed to encrypt the remote gateway token for secure storage${detail}. ` +
|
|
'Set HERMES_DESKTOP_REMOTE_URL and HERMES_DESKTOP_REMOTE_TOKEN in your environment as a fallback.'
|
|
)
|
|
}
|
|
}
|
|
|
|
// Keyring-less Linux (e.g. Hyprland/Sway with no GNOME Keyring or KWallet):
|
|
// `--password-store=basic` selects Electron's built-in "basic" backend, but
|
|
// Electron only counts it as available once setUsePlainTextEncryption(true) is
|
|
// called. The caller runs this on whenReady, before createWindow() and anything
|
|
// that could touch safeStorage, so the switch takes effect for the whole run.
|
|
//
|
|
// Semantics are deliberately narrow: only linux, only the exact 'basic' switch
|
|
// value (never 'gnome-libsecret', 'kwallet', '', etc.), and only when the
|
|
// method exists (older/mocked safeStorage may lack it) and does not throw.
|
|
// Anything else is a no-op. Returns true only when it actually flipped the flag,
|
|
// so the caller (and tests) can distinguish "enabled" from "left untouched".
|
|
// Never throws: a failure here is non-fatal — encryption simply stays
|
|
// unavailable and the user can fall back to the plain-text opt-in or the
|
|
// HERMES_DESKTOP_REMOTE_* env vars.
|
|
function enableBasicPasswordStoreEncryption({ platform, passwordStoreSwitch, safeStorageApi }: any = {}) {
|
|
if (platform !== 'linux' || passwordStoreSwitch !== 'basic') {
|
|
return false
|
|
}
|
|
|
|
try {
|
|
if (typeof safeStorageApi?.setUsePlainTextEncryption === 'function') {
|
|
safeStorageApi.setUsePlainTextEncryption(true)
|
|
|
|
return true
|
|
}
|
|
} catch {
|
|
// Non-fatal: fall through and report that encryption was not enabled.
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// The token-persistence seam shared by the connection-config save/apply IPC
|
|
// path. Given the incoming edit, decide what token block to persist:
|
|
// - No incoming token: keep the existing block's token untouched (edits that
|
|
// don't retype the token must not clear it).
|
|
// - persistToken false (the transient test-connection path): store the raw
|
|
// value as a plain block WITHOUT touching secure storage — it is never
|
|
// written to disk, so there is nothing to protect.
|
|
// - Otherwise: run the incoming token through the injected encryptSecret
|
|
// (encryptDesktopSecret in production), forwarding the plain-text opt-in.
|
|
//
|
|
// The plain-text opt-in is coerced with `=== true` HERE so the strictness lives
|
|
// in one place: a truthy-but-not-true value (1, 'yes', etc.) must NOT silently
|
|
// enable plain-text storage. Callers pass `allowPlainText` through raw.
|
|
function resolvePersistedRemoteToken({
|
|
incomingToken,
|
|
persistToken,
|
|
existingToken,
|
|
allowPlainText,
|
|
encryptSecret
|
|
}: any = {}) {
|
|
if (!incomingToken) {
|
|
return existingToken
|
|
}
|
|
|
|
if (!persistToken) {
|
|
return { encoding: 'plain', value: incomingToken }
|
|
}
|
|
|
|
return encryptSecret(incomingToken, { allowPlainText: allowPlainText === true })
|
|
}
|
|
|
|
function sensitiveFileBlockReason(filePath) {
|
|
const normalized = String(filePath || '')
|
|
.replace(/\\/g, '/')
|
|
.toLowerCase()
|
|
|
|
const basename = path.basename(normalized)
|
|
const ext = path.extname(basename)
|
|
|
|
if (!basename) {
|
|
return null
|
|
}
|
|
|
|
if (normalized.includes('/.ssh/')) {
|
|
return 'SSH key/config files are blocked.'
|
|
}
|
|
|
|
if (normalized.includes('/.gnupg/')) {
|
|
return 'GPG key material is blocked.'
|
|
}
|
|
|
|
if (normalized.endsWith('/.aws/credentials')) {
|
|
return 'AWS credential files are blocked.'
|
|
}
|
|
|
|
if (basename === '.env') {
|
|
return '.env files are blocked because they commonly contain secrets.'
|
|
}
|
|
|
|
if (basename.startsWith('.env.')) {
|
|
const suffix = basename.slice('.env.'.length)
|
|
|
|
if (!SAFE_ENV_SUFFIXES.has(suffix)) {
|
|
return `${basename} is blocked because it appears to contain environment secrets.`
|
|
}
|
|
}
|
|
|
|
if (/^id_(rsa|dsa|ecdsa|ed25519)(?:\..+)?$/.test(basename) && !basename.endsWith('.pub')) {
|
|
return 'SSH private key files are blocked.'
|
|
}
|
|
|
|
if (SENSITIVE_EXTENSIONS.has(ext)) {
|
|
return `${ext} key/certificate files are blocked.`
|
|
}
|
|
|
|
if (basename === '.npmrc' || basename === '.netrc' || basename === '.pypirc') {
|
|
return `${basename} is blocked because it may include auth credentials.`
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
function ipcPathError(code: any, message: string): Error & { code: any } {
|
|
const error = new Error(message) as Error & { code: any }
|
|
|
|
;(error as any).code = code
|
|
|
|
return error
|
|
}
|
|
|
|
function rejectUnsafePathSyntax(filePath, purpose = 'File read') {
|
|
if (typeof filePath !== 'string') {
|
|
throw ipcPathError('invalid-path', `${purpose} failed: file path is required.`)
|
|
}
|
|
|
|
const raw = filePath.trim()
|
|
|
|
if (!raw) {
|
|
throw ipcPathError('invalid-path', `${purpose} failed: file path is required.`)
|
|
}
|
|
|
|
if (raw.includes('\0')) {
|
|
throw ipcPathError('invalid-path', `${purpose} failed: file path is invalid.`)
|
|
}
|
|
|
|
const normalized = raw.replace(/\\/g, '/').toLowerCase()
|
|
|
|
if (
|
|
normalized.startsWith('//?/') ||
|
|
normalized.startsWith('//./') ||
|
|
normalized.startsWith('globalroot/device/') ||
|
|
normalized.includes('/globalroot/device/')
|
|
) {
|
|
throw ipcPathError('device-path', `${purpose} blocked: Windows device paths are not allowed.`)
|
|
}
|
|
|
|
return raw
|
|
}
|
|
|
|
function resolveRequestedPathForIpc(filePath, options: { purpose?: string; baseDir?: fs.PathOrFileDescriptor } = {}) {
|
|
const purpose = String(options.purpose || 'File read')
|
|
let raw = rejectUnsafePathSyntax(filePath, purpose)
|
|
|
|
// Gateway-reported cwds (config `terminal.cwd`, remote sessions) routinely
|
|
// arrive as `~/...`. Node's fs has no shell — without expansion the path
|
|
// resolves under process.cwd() and every read "ENOENT"s forever.
|
|
if (raw === '~' || raw.startsWith('~/') || raw.startsWith('~\\')) {
|
|
raw = path.join(os.homedir(), raw.slice(1))
|
|
}
|
|
|
|
if (/^file:/i.test(raw)) {
|
|
let resolvedPath
|
|
|
|
try {
|
|
const parsed = new URL(raw)
|
|
|
|
if (parsed.protocol !== 'file:') {
|
|
throw new Error('not a file URL')
|
|
}
|
|
|
|
resolvedPath = fileURLToPath(parsed)
|
|
} catch {
|
|
throw ipcPathError('invalid-path', `${purpose} failed: file URL is invalid.`)
|
|
}
|
|
|
|
rejectUnsafePathSyntax(resolvedPath, purpose)
|
|
|
|
return path.resolve(resolvedPath)
|
|
}
|
|
|
|
const baseInput = typeof options.baseDir === 'string' && options.baseDir.trim() ? options.baseDir : process.cwd()
|
|
const safeBaseInput = rejectUnsafePathSyntax(baseInput, purpose)
|
|
const resolvedBase = path.resolve(safeBaseInput)
|
|
rejectUnsafePathSyntax(resolvedBase, purpose)
|
|
const resolvedPath = path.resolve(resolvedBase, raw)
|
|
rejectUnsafePathSyntax(resolvedPath, purpose)
|
|
|
|
return resolvedPath
|
|
}
|
|
|
|
async function statForIpc(fsImpl: { promises: { stat: typeof fs.promises.stat } }, resolvedPath, purpose, typeLabel) {
|
|
try {
|
|
return await fsImpl.promises.stat(resolvedPath)
|
|
} catch (error) {
|
|
const code = error && typeof error === 'object' ? error.code : ''
|
|
|
|
if (code === 'ENOENT' || code === 'ENOTDIR') {
|
|
throw ipcPathError(code || 'ENOENT', `${purpose} failed: ${typeLabel} does not exist.`)
|
|
}
|
|
|
|
throw ipcPathError(
|
|
code || 'read-error',
|
|
`${purpose} failed: ${error instanceof Error ? error.message : String(error)}`
|
|
)
|
|
}
|
|
}
|
|
|
|
async function realpathForIpc(fsImpl, resolvedPath, purpose) {
|
|
if (typeof fsImpl.promises.realpath !== 'function') {
|
|
return resolvedPath
|
|
}
|
|
|
|
try {
|
|
const realPath = await fsImpl.promises.realpath(resolvedPath)
|
|
rejectUnsafePathSyntax(realPath, purpose)
|
|
|
|
return realPath
|
|
} catch (error) {
|
|
const code = error && typeof error === 'object' ? error.code : ''
|
|
throw ipcPathError(
|
|
code || 'read-error',
|
|
`${purpose} failed: ${error instanceof Error ? error.message : String(error)}`
|
|
)
|
|
}
|
|
}
|
|
|
|
function rejectSensitiveFilePath(filePath, purpose) {
|
|
const blockReason = sensitiveFileBlockReason(filePath)
|
|
|
|
if (blockReason) {
|
|
throw ipcPathError('sensitive-file', `${purpose} blocked for sensitive file: ${blockReason}`)
|
|
}
|
|
}
|
|
|
|
async function resolveDirectoryForIpc(
|
|
dirPath,
|
|
options: {
|
|
purpose?: string
|
|
baseDir?: fs.PathOrFileDescriptor
|
|
fs?: { promises: { stat: typeof fs.promises.stat } }
|
|
} = {}
|
|
) {
|
|
const purpose = String(options.purpose || 'Directory read')
|
|
const fsImpl = options.fs || fs
|
|
const resolvedPath = resolveRequestedPathForIpc(dirPath, { baseDir: options.baseDir, purpose })
|
|
const stat = await statForIpc(fsImpl, resolvedPath, purpose, 'directory')
|
|
|
|
if (!stat.isDirectory()) {
|
|
throw ipcPathError('ENOTDIR', `${purpose} failed: path is not a directory.`)
|
|
}
|
|
|
|
const realPath = await realpathForIpc(fsImpl, resolvedPath, purpose)
|
|
|
|
return { realPath, resolvedPath, stat }
|
|
}
|
|
|
|
async function resolveReadableFileForIpc(
|
|
filePath,
|
|
options: {
|
|
purpose?: string
|
|
baseDir?: fs.PathOrFileDescriptor
|
|
fs?: typeof fs
|
|
blockSensitive?: boolean
|
|
maxBytes?: number
|
|
} = {}
|
|
) {
|
|
const purpose = String(options.purpose || 'File read')
|
|
const fsImpl = options.fs || fs
|
|
const resolvedPath = resolveRequestedPathForIpc(filePath, { baseDir: options.baseDir, purpose })
|
|
|
|
if (options.blockSensitive !== false) {
|
|
rejectSensitiveFilePath(resolvedPath, purpose)
|
|
}
|
|
|
|
const stat = await statForIpc(fsImpl, resolvedPath, purpose, 'file')
|
|
|
|
if (stat.isDirectory()) {
|
|
throw ipcPathError('EISDIR', `${purpose} failed: path points to a directory.`)
|
|
}
|
|
|
|
if (!stat.isFile()) {
|
|
throw ipcPathError('EINVAL', `${purpose} failed: only regular files can be read.`)
|
|
}
|
|
|
|
const realPath = await realpathForIpc(fsImpl, resolvedPath, purpose)
|
|
|
|
if (options.blockSensitive !== false) {
|
|
rejectSensitiveFilePath(realPath, purpose)
|
|
}
|
|
|
|
const maxBytes = Number.isFinite(options.maxBytes) && Number(options.maxBytes) > 0 ? Number(options.maxBytes) : null
|
|
|
|
if (maxBytes && stat.size > maxBytes) {
|
|
throw ipcPathError('EFBIG', `${purpose} failed: file is too large (${stat.size} bytes; limit ${maxBytes} bytes).`)
|
|
}
|
|
|
|
try {
|
|
await fsImpl.promises.access(resolvedPath, fs.constants.R_OK)
|
|
} catch {
|
|
throw ipcPathError('EACCES', `${purpose} failed: file is not readable.`)
|
|
}
|
|
|
|
return { realPath, resolvedPath, stat }
|
|
}
|
|
|
|
async function readFileDataUrlForIpc(
|
|
filePath,
|
|
options: {
|
|
purpose?: string
|
|
baseDir?: fs.PathOrFileDescriptor
|
|
fs?: typeof fs
|
|
blockSensitive?: boolean
|
|
maxBytes?: number
|
|
mimeType: string
|
|
}
|
|
): Promise<string> {
|
|
const fsImpl = options.fs || fs
|
|
const { resolvedPath } = await resolveReadableFileForIpc(filePath, options)
|
|
const data = await fsImpl.promises.readFile(resolvedPath)
|
|
|
|
return `data:${options.mimeType};base64,${data.toString('base64')}`
|
|
}
|
|
|
|
export {
|
|
ATTACHMENT_UPLOAD_DEFAULT_MAX_BYTES,
|
|
clampDataUrlReadMaxMb,
|
|
DATA_URL_READ_DEFAULT_MAX_MB,
|
|
DATA_URL_READ_MAX_MAX_MB,
|
|
DATA_URL_READ_MIN_MAX_MB,
|
|
dataUrlReadMaxBytesFromMb,
|
|
DEFAULT_FETCH_TIMEOUT_MS,
|
|
enableBasicPasswordStoreEncryption,
|
|
encryptDesktopSecret,
|
|
readFileDataUrlForIpc,
|
|
rejectUnsafePathSyntax,
|
|
resolveDirectoryForIpc,
|
|
resolvePersistedRemoteToken,
|
|
resolveReadableFileForIpc,
|
|
resolveRequestedPathForIpc,
|
|
resolveTimeoutMs,
|
|
SAFE_STORAGE_ENCODING,
|
|
SECRET_FILE_MODE,
|
|
sensitiveFileBlockReason,
|
|
TEXT_PREVIEW_SOURCE_MAX_BYTES,
|
|
tightenSecretFileMode,
|
|
writeSecretFileAtomic
|
|
}
|