DiskPush runs a program that copies files between machines, with credentials, sometimes with a delete flag. This is the reasoning behind how it does that.
spawn('rsync', validatedArgs, { shell: false })Never:
exec(`rsync ${userInput}`)Every argument comes from a typed option, a parsed endpoint, or the user's
explicit pass-through list. Nothing is interpolated into a string that a shell
will read back. A directory named weird $(touch /tmp/pwned); \id` 'q' & dir`
is copied, with that name, and nothing executes. There is a test that asserts
exactly this against the real rsync binary.
ssh host command... always runs the command through the remote login shell,
joining argv with spaces first. Server-to-server orchestration cannot avoid
that shell, so DiskPush quotes for it: every token of the remote rsync command
is POSIX single-quoted (' becomes '\'') and the whole command is passed as
one argv element. This is the only place in the codebase permitted to build a
shell string, it is one small function, and it is tested with hostile paths.
This one is easy to miss. When rsync talks to a remote host, it historically
passed the remote path on the remote command line, where the remote login
shell expanded it. A path containing $(...) was therefore remote code
execution, in rsync itself, with no help from the client.
rsync 3.0.0 added --protect-args to send arguments through the protocol
instead. rsync 3.2.4 made that the default and renamed it --secluded-args.
DiskPush gates on the version:
| Local rsync | Behaviour |
|---|---|
| 3.2.4 or newer | Already the default. No flag added. |
| 3.0.0 to 3.2.3 | --protect-args passed explicitly. |
| Older than 3.0.0 | Not possible. DiskPush warns that paths with shell metacharacters are unsafe against that host. |
| Unknown | --protect-args passed anyway, so an old rsync fails loudly instead of silently handing the path to a shell. |
- Host keys are verified on every connection.
- A new host prompts once, showing the
SHA256:fingerprint, and is recorded on acceptance. - A changed host key blocks the connection. It is not a prompt. If the change was expected, the old entry has to be removed deliberately.
- A key marked
@revokedinknown_hostsis refused. - Both plain and HMAC-SHA1 hashed
known_hostsentries are understood, so a hashed file does not read as empty and make every host look new. StrictHostKeyChecking=accept-newis the weakest position DiskPush takes. There is no setting that disables host key checking globally.
- No passwords or key passphrases are stored in the local database. Look at
packages/database/src/migrations.ts: there is no column for them. - SSH agent authentication is preferred, and stores nothing at all.
- Where a secret must persist, it belongs in OS-backed secure storage, not in the SQLite file that a backup would sweep up.
- Exception, for now: plugin sign-ins. A plugin's token or API key (the
MediaAnalyzer refresh token, say) is kept in the settings table, because
that is the one store the CLI and the desktop app can both read, and a
sign-in made in one has to work in the other. It is scoped to that one
service, rotates on every use, is revoked by
logout, and is never sent to the desktop renderer. DiskPush keeps the database file owner-only (0600). Moving it into OS secure storage both surfaces can reach is an open item in plugins.md. - Credentials are never written to logs. "Copy command" redacts.
- No delete flag is generated by default, by any command except
mirror. - A delete-enabled job cannot be constructed as a live job until its dry run has been reviewed and confirmed. This is enforced in the argument builder, not in the UI, so every surface inherits it.
--deleteand friends in the pass-through section are refused. Passing them through would let asyncdelete files without ever showing the preview whose entire purpose is to show you that.--remove-source-filesis refused. DiskPush copies; it does not move.- Unattended mirroring is possible, per profile, and off by default.
For fleet commands, which reach many servers at once:
- A script matching a known way to lose a machine (recursive deletes of
/,mkfs, raw block-device writes, power commands, changes to SSH or the firewall, permission resets from the root down, account deletion, fork bombs, piping a URL into a shell,DROP DATABASE) is refused until it is confirmed. This is a tripwire against the accident, not a sandbox against an adversary; a shell is Turing-complete and it can be written around. - In the desktop app that check runs in the main process, so a renderer that skipped the confirmation dialog cannot skip the check with it.
- Script text is never interpolated into a command line. It reaches the remote interpreter on stdin, and the command line only names the interpreter, so a quote or a backtick in a script cannot become a different command.
- A selector term matching no server is an error rather than a smaller fleet: a typo must not silently reduce which machines got a security update.
- Unknown host keys fail during a fan-out rather than prompting, unless
--accept-newis passed for that run. A changed key is never accepted. --sudousessudo -n, which fails rather than hanging on an invisible prompt. A sudo password, where one is used, lives in memory for the length of the run: never a column, never a log line, and never on the command line wherepson the server would show it.
Off by default, opt-in per connection.
While a forwarded session is open, anyone with root on the intermediate host can use the agent to authenticate as you, anywhere the agent's keys are accepted. That is a real and specific cost. For unattended server-to-server work, a dedicated restricted key on the source host is the better answer.
nodeIntegration: false,contextIsolation: true, sandboxed renderer.- The renderer gets no filesystem or process APIs, only narrowly scoped IPC
operations over
contextBridge. - Every IPC input is validated with Zod in the main process. Renderer-supplied paths, flags and IDs are treated as untrusted input, because a renderer compromise should not become a shell.
- Plugins run in the main process, never the renderer. The renderer names a plugin action by id and its files as a local directory plus bare entry names. A plugin itself runs with the user's full privileges; see plugins.md.
- Strict CSP, no
eval, no remote content, navigation restricted to app pages.
- It does not upload your files anywhere. A plugin you run can, and says so: MediaAnalyzer sends the photos you select (and contact sheets of videos) to mediaanalyzer.pro, only when you run one of its actions.
- It does not relay server-to-server payloads through the desktop or through any hosted service, and will not silently start.
- It does not phone home. Analytics on the website never receives connection names, hostnames, paths, filenames, or anything about a transfer.
Security issues: security@profullstack.com. Please do not open a public issue for anything exploitable.