Close HTTP-to-shell bridge in replay command execution#30
Open
haasonsaas wants to merge 1 commit into
Open
Conversation
An attacker who can write one file to a local directory and make loopback
HTTP requests could execute arbitrary commands as the Workshop user:
1. POST /api/workspace/active {"cwd": "<attacker dir>"} read an
attacker-writable .raindrop/agents.yaml and persisted its `command`
into ~/.raindrop/replay-projects.json (registration with validate:false,
as an unconditional side effect of a workspace change).
2. POST /api/import-run created a run whose event_name matched the entry.
3. POST /api/replay resolved that event, found the command-bearing config,
and called spawnReplayCommand(config) -> spawn(command, { shell: true }).
The legacy agents.json path was already sanitized, but the replay-projects.json
path that loadAgentsConfig() merges into the same sink was not. The command
also runs if the attacker writes replay-projects.json directly.
Two layers, matching the two recommended fixes:
- Sink: ensureAgentEndpointDetailed() (reached only from the HTTP /api/replay
path) no longer spawns on demand. When a command-bearing config exists but
no agent is healthy it returns reason "not_running"; replay surfaces a
clear "register it via the CLI" error. Spawning stays in the explicit
`raindrop replay register` flow (validateReplayAgentStartup), under direct
user intent. This closes the bridge regardless of how the command was
registered.
- Surface: POST /api/workspace/active no longer auto-registers replay projects,
and the unvalidated registerReplayProjectIfPresent helper is removed.
Adds a regression test proving the HTTP resolver does not execute a registry
command, and still connects to an already-running agent.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
An attacker who can write one file to a local directory and make loopback HTTP requests to Workshop could execute arbitrary commands as the Workshop process owner:
POST /api/workspace/active {"cwd": "<attacker dir>"}read an attacker-writable.raindrop/agents.yamland persisted itscommandinto~/.raindrop/replay-projects.json— registration withvalidate: false, fired as an unconditional side effect of a workspace change.POST /api/import-runcreated a run whoseevent_namematched the registered entry.POST /api/replayresolved that event throughensureAgentEndpointDetailed(), found the command-bearing config, and calledspawnReplayCommand(config)→spawn(config.command, { shell: true }).#25 sanitized the legacy
agents.jsonpath and deliberately left command-bearing registrations toreplay-projects.json. ButloadAgentsConfig()merges both registries into the samespawnReplayCommandsink, and thereplay-projects.jsonpath was reachable over HTTP via theworkspace/activeside effect (and by writing the file directly). So the command-execution bridge #25 closed on one path remained open on the other.Fix
Two layers, matching the two recommended mitigations:
ensureAgentEndpointDetailed()is reached only from the HTTP/api/replaypath (replay.ts). It no longer spawns on demand: when a command-bearing config exists but no agent is healthy, it returnsreason: "not_running", and replay surfaces areplay_agent_not_runningerror telling the user to start it via the CLI. Spawning stays in the explicitraindrop replay registerflow (validateReplayAgentStartup), under direct user intent. This closes the bridge regardless of how the command reached the registry.POST /api/workspace/activeno longer registers replay projects as a side effect, and the unvalidatedregisterReplayProjectIfPresenthelper is removed. Replay registration is now exclusively the explicitraindrop replay registerCLI action.Validation
HOME: areplay-projects.jsoncommand flowed throughensureAgentEndpointDetailed()and wrote a marker file (attemptedStart: true,reason: "start_timeout").reason: "not_running",attemptedStart: false, and no marker file is created. A healthy already-running agent is still returned (no replay regression).bun test tests/replay-command-injection.test.ts— 2 pass (no-spawn + still-connects)bun test tests/bun x tsc --noEmitbun x eslinton changed files — cleanNotes
raindrop replay register(which starts the agent detached),/api/replayconnects to it; if the agent has stopped, replay returnsreplay_agent_not_runninginstead of silently re-spawning.mainbinds the daemon to loopback, so the practical attacker is a local process, malicious dev tool, or compromised dependency that can write the payload file and call the local API.🤖 Generated with Claude Code