Skip to content

Fix three High security findings: agent takeover, unsandboxed workflow scripts, llm-edge open proxy - #36

Open
zzkamzn wants to merge 3 commits into
aws-samples:mainfrom
zzkamzn:sec-patch/h1-h2-h5
Open

zzkamzn wants to merge 3 commits into
aws-samples:mainfrom
zzkamzn:sec-patch/h1-h2-h5

Conversation

@zzkamzn

@zzkamzn zzkamzn commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Summary

Fixes three High findings from a security review of the platform, one commit each. Each was reproduced with a proof-of-concept that drives the real target source and stubs only the external AWS/network edges (DynamoDB, Secrets Manager, STS, the LLM gateway). All three were confirmed exploitable before the fix; the PoC results are quoted under each finding.

  1. Agent takeover by republishing a name (agent_service.publish). Publishing resolved an existing agent by global name, reused its id and overwrote the record including created_by, for any authenticated user. Channels, schedules, evals, API callers and the MCP-hub Actor identity all bind to the id, so a user could take over another user's published agent and then delete it. Republishing now requires the original publisher or an administrator (403 otherwise, from both POST /api/v1/agents and /publish-from-session). Ownership stays with the original publisher when an administrator pushes a new version; the actual publisher is recorded in updated_by and the version history.

  2. Pipeline workflow scripts inherit the backend's IRSA role (workflow_engine.py). Scripts ran in a plain Node subprocess with the pod's full environment, as root, with unrestricted fs and child_process. A script could read the projected IRSA token and assume the backend role. The child now gets an allow-listed environment (PATH, LANG, LC_ALL), runs under Node's permission model (--permission, or --experimental-permission on Node 20) with read access to the runner and the script only, and is switched to a new unprivileged workflow account when the backend runs as root. The engine fails closed on a Node without the permission model. The base image (python:3.13-slim, Debian trixie) installs Node 20.19, which supports it.

  3. llm-edge forwards any path with the gateway key attached (services/llm-edge). Any method and path reached the gateway under the platform key, and the model check ran only when a body was present, so GET /key/info, /spend/logs or POST /key/generate with a decoy model field were reachable from a session shell. Routes are now authorized before the body is read from a fixed table: POST /v1/messages, POST /v1/messages/count_tokens (body required) and GET /v1/models (body forbidden). Everything else is 404, non-canonical paths are 400, and the upstream URL is built from the allow-listed pathname. Request headers are forwarded from an allowlist instead of a drop list.

Docs updated where they described the old behaviour (architecture.md, user-guide.md, deployment.md, security-explainer.zh.md).

Confirmed exploitable before the fix

PoCs against the pre-fix source (Node 20, boto3, local mocks for the AWS/network edges) reproduced each finding:

1. Agent takeover. Attacker mallory, an ordinary user, re-published victim admin's agent prod-support by name. The agent id was unchanged (7a77f43202ee before and after), so everything bound to agent:7a77f43202ee kept running but now served Mallory's config: the system prompt was replaced with an injection, memory_id was rebound to the attacker, and created_by flipped to mallory — locking the original owner out of delete. With an mcp-hub attachment the reused Actor credentials would let the attacker sign as the victim agent.

2. Workflow script → backend role. A plain Workflow-dialect pipeline script ran shell in-process, read the environment the docstring claims is stripped (AWS_ROLE_ARN, the web-identity token path, PLATFORM_API_TOKEN), read the projected web-identity JWT off disk, exchanged it at STS via AssumeRoleWithWebIdentity for the backend task role's credentials, and exfiltrated everything to a mock C2 endpoint. Full RCE as the backend plus theft of the agent-platform-backend-task role.

3. llm-edge open proxy. Holding only a session-scoped token limited to one model, against a mock gateway keyed on the master key:

Request (session token only) Result
POST /v1/messages allowed model 200
POST /v1/messages forbidden model 403 (the only working control)
GET /key/info (bodyless) 200 — master-key metadata, org spend leaked
GET /spend/logs 200 — whole-org spend + per-user totals
GET /user/info 200 — user enumeration
POST /key/generate (decoy allowed model in body) 200 — minted an unrestricted 365-day key
GET /../key/info 200 — new URL() normalized .., escaping the base-path prefix

Test plan

Post-fix verification with throwaway local scripts (no test harness exists in the repo, so these are not committed):

  • Workflow engine on Node 20.19 and 22.20: the same hostile script now sees only PATH/LANG; /etc/passwd, /proc/1/environ and execSync("id") all return ERR_ACCESS_DENIED; agent()/phase()/log()/parallel() and a syntax-error script behave as before. The engine refuses to run on a Node without the permission model.
  • llm-edge with stubbed DynamoDB/Secrets Manager and a fake upstream: inference routes forward with only allow-listed headers and the base-URL prefix intact; /key/generate, /key/info, /spend/logs, path-routed model endpoints, .. traversal, encoded slashes, bodyless POST /v1/messages and GET /v1/models with a body are all refused; bad token still 401.
  • Agent publish: a non-owner re-publish now returns 403 from both publish endpoints; an admin re-publish keeps the original created_by.
  • python -m py_compile on touched Python files, node --check on server.js.
  • Deploy to a dev stack and run an existing pipeline end to end (the workflow user and --permission flags are new to the container).
  • Confirm Claude Code and the SDK kernel only use /v1/messages, /v1/messages/count_tokens and /v1/models against the edge in your gateway setup; any other client route needs adding to routes.js.

🤖 Generated with Claude Code

zzkamzn and others added 3 commits September 7, 2026 09:48
AgentService.publish() resolved an existing agent by global name, reused
its id and overwrote the record — including created_by — for any
authenticated caller. Because channels, schedules, evals and API callers
bind to the id, a user could republish another user's agent name with
their own system prompt, tools and memory binding, take over the MCP-hub
Actor identity that is keyed on the id, and then delete the agent.

Republishing now requires the caller to be the original publisher or an
administrator (403 otherwise, surfaced from both POST /api/v1/agents and
/publish-from-session). Ownership stays with the original publisher when
an administrator pushes a new version; the actual publisher of each
version is recorded in updated_by and the version history.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
WorkflowEngine ran each admin-registered script in a plain Node subprocess
that inherited the backend pod's full environment (AWS_ROLE_ARN,
AWS_WEB_IDENTITY_TOKEN_FILE, PLATFORM_*) and ran as root with unrestricted
fs and child_process access. A script could read the projected IRSA token
and call sts:AssumeRoleWithWebIdentity, becoming the backend role, while
the docstring and Dockerfile claimed scripts got no credentials.

The child now gets an allow-listed environment (PATH, LANG, LC_ALL), runs
under Node's permission model (--permission, or --experimental-permission
on Node 20) with fs read limited to the runner and the script and no
child_process/workers/addons, and is switched to a new unprivileged
`workflow` account when the backend runs as root. The engine fails closed
when Node lacks the permission model. Verified locally on Node 20.19 and
22.20: a hostile script sees only PATH/LANG and gets ERR_ACCESS_DENIED
on /etc/passwd, /proc/1/environ and execSync; the bridge primitives
(agent/phase/log/parallel) keep working.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
llm-edge forwarded any method and path to the gateway with the platform
key attached, and the per-session model check ran only when a body was
present. From a session shell that meant GET /key/info, /spend/logs or
POST /key/generate (with a decoy "model" field) reached the gateway
under the platform's credential; if that credential was a LiteLLM master
key the session could mint itself a long-lived key that bypassed the edge
entirely. new URL(baseUrl + req.url) also let ".." segments escape a
base-URL path prefix.

Route authorization now happens before the body is read, from a fixed
table: POST /v1/messages and POST /v1/messages/count_tokens (body
required, so the model check always runs) and GET /v1/models (body
forbidden). Everything else is 404; non-canonical paths (dot segments,
encoded slashes, absolute-form targets) are 400. The upstream URL is
built from the allow-listed pathname, never the raw request. Request
headers are forwarded from an allowlist (content-type, accept,
user-agent, anthropic-*, x-stainless-*, x-app) instead of a drop list.
Verified with a stubbed session store and fake upstream on Node 20.

Co-Authored-By: Claude Fable 5.1 <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