Fix three High security findings: agent takeover, unsandboxed workflow scripts, llm-edge open proxy - #36
Open
zzkamzn wants to merge 3 commits into
Open
Fix three High security findings: agent takeover, unsandboxed workflow scripts, llm-edge open proxy#36zzkamzn wants to merge 3 commits into
zzkamzn wants to merge 3 commits into
Conversation
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>
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
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.
Agent takeover by republishing a name (
agent_service.publish). Publishing resolved an existing agent by global name, reused its id and overwrote the record includingcreated_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 bothPOST /api/v1/agentsand/publish-from-session). Ownership stays with the original publisher when an administrator pushes a new version; the actual publisher is recorded inupdated_byand the version history.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 andchild_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-permissionon Node 20) with read access to the runner and the script only, and is switched to a new unprivilegedworkflowaccount 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.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, soGET /key/info,/spend/logsorPOST /key/generatewith a decoymodelfield 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) andGET /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 victimadmin's agentprod-supportby name. The agent id was unchanged (7a77f43202eebefore and after), so everything bound toagent:7a77f43202eekept running but now served Mallory's config: the system prompt was replaced with an injection,memory_idwas rebound to the attacker, andcreated_byflipped tomallory— 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 viaAssumeRoleWithWebIdentityfor the backend task role's credentials, and exfiltrated everything to a mock C2 endpoint. Full RCE as the backend plus theft of theagent-platform-backend-taskrole.3. llm-edge open proxy. Holding only a session-scoped token limited to one model, against a mock gateway keyed on the master key:
POST /v1/messagesallowed modelPOST /v1/messagesforbidden modelGET /key/info(bodyless)GET /spend/logsGET /user/infoPOST /key/generate(decoy allowed model in body)GET /../key/infonew URL()normalized.., escaping the base-path prefixTest plan
Post-fix verification with throwaway local scripts (no test harness exists in the repo, so these are not committed):
PATH/LANG;/etc/passwd,/proc/1/environandexecSync("id")all returnERR_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./key/generate,/key/info,/spend/logs, path-routed model endpoints,..traversal, encoded slashes, bodylessPOST /v1/messagesandGET /v1/modelswith a body are all refused; bad token still 401.created_by.python -m py_compileon touched Python files,node --checkonserver.js.workflowuser and--permissionflags are new to the container)./v1/messages,/v1/messages/count_tokensand/v1/modelsagainst the edge in your gateway setup; any other client route needs adding toroutes.js.🤖 Generated with Claude Code