Skip to content

Close HTTP-to-shell bridge in replay command execution#30

Open
haasonsaas wants to merge 1 commit into
raindrop-ai:mainfrom
haasonsaas:security/replay-http-shell-bridge
Open

Close HTTP-to-shell bridge in replay command execution#30
haasonsaas wants to merge 1 commit into
raindrop-ai:mainfrom
haasonsaas:security/replay-http-shell-bridge

Conversation

@haasonsaas

Copy link
Copy Markdown
Contributor

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:

  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, fired as an unconditional side effect of a workspace change.
  2. POST /api/import-run created a run whose event_name matched the registered entry.
  3. POST /api/replay resolved that event through ensureAgentEndpointDetailed(), found the command-bearing config, and called spawnReplayCommand(config)spawn(config.command, { shell: true }).

#25 sanitized the legacy agents.json path and deliberately left command-bearing registrations to replay-projects.json. But loadAgentsConfig() merges both registries into the same spawnReplayCommand sink, and the replay-projects.json path was reachable over HTTP via the workspace/active side 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:

  • Sink — HTTP replay never spawns. ensureAgentEndpointDetailed() is reached only from the HTTP /api/replay path (replay.ts). It no longer spawns on demand: when a command-bearing config exists but no agent is healthy, it returns reason: "not_running", and replay surfaces a replay_agent_not_running error telling the user to start it via the CLI. Spawning stays in the explicit raindrop replay register flow (validateReplayAgentStartup), under direct user intent. This closes the bridge regardless of how the command reached the registry.
  • Surface — no auto-registration over HTTP. POST /api/workspace/active no longer registers replay projects as a side effect, and the unvalidated registerReplayProjectIfPresent helper is removed. Replay registration is now exclusively the explicit raindrop replay register CLI action.

Validation

  • Confirmed the chain before the fix in an isolated temp HOME: a replay-projects.json command flowed through ensureAgentEndpointDetailed() and wrote a marker file (attemptedStart: true, reason: "start_timeout").
  • Re-ran after the fix: the resolver returns 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 --noEmit
  • bun x eslint on changed files — clean

Notes

  • Behavior change: replay no longer auto-starts a registered agent from the HTTP path. After raindrop replay register (which starts the agent detached), /api/replay connects to it; if the agent has stopped, replay returns replay_agent_not_running instead of silently re-spawning.
  • Current main binds 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.
  • Independent of Deny framing for Workshop UI #28.

🤖 Generated with Claude Code

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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant