Fix cross-tenant session hijack via caller-supplied AgentCore session ids (H6) - #39
Conversation
An AgentCore runtimeSessionId is not a label: reusing one routes to the same warm microVM and the same kernel process, so two callers that share a session id share /tmp/agent-work, the process secret cache and any in-process model-gateway grant. Both invoke routes took the caller's session_id straight from the request body, so that shared state was reachable across tenants with nothing more than an ordinary authenticated account. The id did not even have to be guessed. It is echoed back in InvokeResponse.runtime_session_id and recorded in the invocation ledger, and the channel path derived it as an unkeyed sha256 of channel_id + conversation_id — the channel id being public, since it sits in the webhook URL handed to the third party. So a target session id was either observable or computable offline. The same file already treats the neighbouring memory actor id as an authorization boundary and namespaces it per caller (resolve_memory_actor). The session id is the equivalent key and now gets the equivalent treatment: - session_binding.resolve_session_id namespaces a request-supplied id under the authenticated caller (HMAC), so two tenants submitting the same id can never land on one microVM, and an id lifted from another tenant re-namespaces under whoever replays it. The mapping is idempotent because the resolved id is echoed to the client and resent for conversation continuity: a caller's own id round-trips unchanged. - session_binding.derive_channel_session_id keys the channel digest with the platform binding secret, so it is no longer computable from the public channel id plus a guessed conversation id. - llm_credentials_service.mint writes conditionally (create, or re-mint by the same owner) instead of unconditionally overwriting. Ids are caller-bound upstream now, so a collision should be unreachable; this makes overwriting a live grant held by another principal — and the targeted denial of service it buys, since the victim's kernel token stops matching the stored digest — impossible even if that breaks. PLATFORM_SESSION_BINDING_SECRET keys the binding. Unset, it falls back to deployment identifiers that do not appear in any public webhook URL; set it explicitly in production. It only has to be stable across replicas and restarts. Tests: scripts/check_session_binding_authz.py covers the boundary with no dependencies or AWS calls and asserts the routes actually apply the mapping, since a correct function is worthless if a call site passes the request value through verbatim. scripts/e2e_session_isolation.py drives the real routes and services against mocked AWS with a recording fake kernel, asserting both the cross-tenant landing and the grant-overwrite denial of service are blocked while continuity and owner re-mint still work. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Heads-up on a latent conflict with #36 — not an issue with this PR, purely a merge-ordering note. Both PRs insert a new field into
Each is individually clean against The two fields are semantically unrelated, so the resolution is to keep both, in either order. Happy to rebase whichever one lands second. |
# Conflicts: # backend/app/config.py
The finding
An AgentCore
runtimeSessionIdis not a label. Reusing one routes to the same warm microVM and the same kernel process, so two callers that share a session id share/tmp/agent-work, the process secret cache and any in-process model-gateway grant.Both invoke routes took the caller's
session_idverbatim out of the request body and handed it to the runtime with no ownership check. That made another tenant's process state reachable with nothing more than an ordinary authenticated account — no admin, no IAM change.The target id did not even have to be guessed:
InvokeResponse.runtime_session_idand is recorded on invocation-ledger entries.sha256(channel_id + ":" + conversation_id). The channel id is public — it sits in the webhook URL handed to the third party — so any conversation's session id could be computed offline.Impact was cross-tenant read of leftover artifacts and cached secrets, reuse of another tenant's still-valid gateway grant, and a one-request targeted denial of service:
mintwrote the grant item unconditionally, so overwriting the token digest on a victim's live session left the victim's kernel holding a token that no longer verified.What makes this a clear miss rather than a debatable one: the neighbouring argument on the same call was already recognised as an authorization boundary.
resolve_memory_actornamespaces the request-supplied memory actor per caller precisely because whoever picks it picks whose records get read. The session id is the equivalent key and was passed through with no protection at all.The fix
Same shape as the control that was already there, applied to the key that was missed.
backend/app/services/session_binding.py(new).resolve_session_idnamespaces a request-supplied id under the authenticated caller via HMAC. Two tenants submitting the same id can never land on one microVM, and an id lifted from someone else's response re-namespaces under whoever replays it. The mapping is idempotent, which matters because the resolved id is echoed to the client and the Debug console resends it as the nextsession_id: a caller's own id round-trips unchanged, so conversation continuity is preserved.resolve_memory_actoris applied. Internal callers still reachinvokeunmodified, since they derive their session server-side from values a caller cannot forge.derive_channel_session_idkeys the channel digest with the platform binding secret, so it is no longer derivable from the public channel id plus a guessed conversation id.llm_credentials_service.mintnow writes withConditionExpression="attribute_not_exists(PK) OR #u = :user"— create, or re-mint by the same owner. Ids are caller-bound upstream now, so a collision should be unreachable; this makes overwriting another principal's live grant impossible even if that ever breaks.New setting
PLATFORM_SESSION_BINDING_SECRETkeys the binding. Unset, it falls back to deployment identifiers that appear in no public webhook URL; set it explicitly in production. It only needs to be stable across replicas and restarts, not rotated.Tests
Both suites pass on this branch.
scripts/check_session_binding_authz.py— no third-party dependencies and no AWS calls, so it is safe in CI. Covers cross-tenant collision, replay, idempotency, AgentCore id shape, channel-digest keying, and that the digest is no longer the precomputable unkeyed one. It also asserts the routes actually apply the mapping, since a correct function is worthless if a call site passes the request value through verbatim — the regression this guards against. Mirrors the existingcheck_memory_actor_authz.py.scripts/e2e_session_isolation.py— drives the real FastAPI routes through the real invocation, kernel and credential services against mocked DynamoDB, with a recording fake kernel standing in for the runtime. Asserts two tenants submitting the same id reach different runtime sessions, that replaying another tenant's echoed id does not land on it, that each tenant keeps continuity on its own id, and that the grant-overwrite denial of service is refused while the owner can still re-mint.Only authentication and the undeployed runtime are stubbed; the authorization path under test is the real one.
Review notes
mainrather than onto Fix three High security findings: agent takeover, unsandboxed workflow scripts, llm-edge open proxy #36, so that PR keeps its stated scope. Both touchconfig.pyandapi/agents.pyin different places, so whichever merges second may need a trivial context resolution.terraform/.terraform.lock.hclwas modified in the working tree beforehand and is deliberately left out of this branch.🤖 Generated with Claude Code