fix(a2a): fail closed when no auth is configured on unrecognized runtimes - #2450
fix(a2a): fail closed when no auth is configured on unrecognized runtimes#2450sebastiondev wants to merge 4 commits into
Conversation
…imes The A2A JSON-RPC endpoint and async /_process-task route previously fell through to unauthenticated processing whenever isA2AProductionRuntime() returned false. That check relies on NODE_ENV or a known cloud-provider flag; a self-hosted deployment (bare Docker/VPS/K8s pod) that omits NODE_ENV=production would accept anonymous JSON-RPC message/send calls and trigger LLM agent runs. Align both routes with the same loopback / A2A_ALLOW_UNSIGNED_INTERNAL gate that agent-chat-plugin.ts and core-routes-plugin.ts already use for their sibling unsigned-internal dispatch paths (via isTrustedLocalRuntime). Unsigned callers are now only accepted when the request actually arrived over loopback (127.0.0.1/::1) or the operator explicitly opted in with A2A_ALLOW_UNSIGNED_INTERNAL=1 — everything else fails closed with 503.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
Visual recap — skippedThe visual recap job did not run for this pull request. This is informational only and does not block the PR. Recap skipped for |
The Builder review correctly pointed out that xForwardedFor:false only returns the immediate socket peer, so on a self-hosted VPS/Docker/K8s box running the app bound to 127.0.0.1 behind Nginx/Caddy every public request appears loopback. The prior gate would then treat those unrecognized runtimes as trusted local dev and accept anonymous JSON-RPC and _process-task dispatches. isTrustedLocalRuntime() now additionally requires a positive dev signal (NODE_ENV=development or NODE_ENV=test) before honoring the loopback bit. NODE_ENV alone unset is no longer a trust grant — bare self-hosted deployments that forgot to set NODE_ENV=production still fail closed. The A2A_ALLOW_UNSIGNED_INTERNAL=1 explicit opt-in remains for operators who intentionally run unsigned internal dispatch. Regression tests cover both the reverse-proxy relay case (NODE_ENV unset, loopback true) and an unrecognized NODE_ENV value.
|
Thanks for the careful review — both findings are correct. Pushed export function isTrustedLocalRuntime(opts: { loopback: boolean }): boolean {
if (isA2AProductionRuntime()) return false;
if (process.env.A2A_ALLOW_UNSIGNED_INTERNAL === "1") return true;
return opts.loopback === true && isExplicitDevRuntime();
}Key point: loopback is no longer sufficient on its own. It must be paired with a positive dev signal ( This applies to both routes flagged ( Regression coverage added in
I considered "trusted proxy config + validated forwarding" as an alternative, but the safer default for this PR is to keep the unsigned path narrow (explicit dev only) and steer operators to |
|
thanks @sebastiondev! Before merging, could you:
With those follow-ups, this looks good to me. |
|
Thanks @steve8708 — all three follow-ups are in
|
There was a problem hiding this comment.
Builder reviewed your changes and found 3 potential issues 🟡
Review Details
Incremental Code Review Summary
This update closes the previously identified reverse-proxy loopback bypass by requiring an explicit NODE_ENV=development|test signal in addition to a loopback peer, and adds broad regression coverage for both JSON-RPC and processor routes. The core security direction is sound, and the previous blocking findings were fixed in the prior increment.
Risk level: High, because this PR changes authentication and asynchronous task execution boundaries.
New findings
- 🟡 MEDIUM — Agent cards can still omit JWT authentication metadata on unrecognized runtimes even though remote unsigned requests now receive 503, creating a discovery/protocol mismatch.
- 🟡 MEDIUM — API-key-only authenticated async requests can enqueue tasks whose processor dispatch is rejected because
_process-taskonly acceptsA2A_SECRET-signed or trusted-local requests. - 🟡 MEDIUM — The new server integration tests do not clear every production-runtime environment signal, so CI provider variables can make supposedly non-production cases fail nondeterministically.
Focused incremental reviews found no further security bypasses. Browser testing is not applicable to these backend/authentication and test changes.
🧪 Browser testing: Skipped — PR only modifies backend authentication, documentation, changeset metadata, and tests, with no user-facing UI impact.
| export function isTrustedLocalRuntime(opts: { loopback: boolean }): boolean { | ||
| if (isA2AProductionRuntime()) return false; | ||
| if (process.env.A2A_ALLOW_UNSIGNED_INTERNAL === "1") return true; | ||
| return opts.loopback === true; | ||
| return opts.loopback === true && isExplicitDevRuntime(); |
There was a problem hiding this comment.
🟡 Advertise authentication when unrecognized runtimes reject unsigned callers
shouldAdvertiseJwtA2AAuth() still returns false when there is no global A2A_SECRET and the runtime is unrecognized. The updated gate rejects every remote unsigned request in that case, so the generated agent card omits bearer authentication even though clients must authenticate (including deployments where verifyA2AToken() can use an org-scoped secret). Align agent-card metadata with the same access policy and add coverage for an unset/staging NODE_ENV deployment.
Additional Info
New finding from one incremental review; the endpoint policy and agent-card policy now disagree for unrecognized runtimes.
| const loopback = isLoopbackAddress( | ||
| getRequestIP(event, { xForwardedFor: false }), | ||
| ); | ||
| if (!isTrustedLocalRuntime({ loopback })) { |
There was a problem hiding this comment.
🟡 Prevent API-key-only async tasks from being stranded
The main JSON-RPC route accepts a configured legacy apiKeyEnv without A2A_SECRET, and the async handler can enqueue and dispatch such requests on an unrecognized runtime. This processor gate then treats the dispatch as unsigned and returns 503 because it only has the HMAC path for A2A_SECRET or the local/explicit opt-in path, leaving the task in working after the original request returns. Reject async mode up front unless the processor has a supported authentication path, or add a safe processor auth mechanism for API-key-only deployments.
Additional Info
Confirmed cross-path behavior by tracing handlers.ts async dispatch and server.ts processor authentication.
| beforeEach(() => { | ||
| delete process.env.A2A_SECRET; | ||
| delete process.env.A2A_ALLOW_UNSIGNED_INTERNAL; | ||
| delete process.env.NETLIFY; | ||
| delete process.env.VERCEL; | ||
| delete process.env.VERCEL_ENV; | ||
| }); |
There was a problem hiding this comment.
🟡 Clear all production signals in the new auth integration tests
The nested beforeEach clears only NETLIFY, VERCEL, and VERCEL_ENV, while isA2AProductionRuntime() also checks AWS_LAMBDA_FUNCTION_NAME, CF_PAGES, RENDER, FLY_APP_NAME, K_SERVICE, NETLIFY_LOCAL, and the global __cf_env marker. Because the parent setup starts each test with NODE_ENV=production and the acceptance tests later set it to development/test, any inherited CI provider signal can make those tests fail unexpectedly. Clear the full production-signal set (as auth-policy.spec.ts already does) before these cases.
Additional Info
Confirmed environment-isolation gap in the newly added nested test setup; this is a CI reliability issue, not a production runtime bypass.
Summary
The A2A JSON-RPC endpoint (
/a2a, mounted bymountA2Ainpackages/core/src/a2a/server.ts) fails open on any host thatisA2AProductionRuntime()does not recognize. That helper only returnstruewhen it sees one of a fixed set of hosting signals —NETLIFY,VERCEL,AWS_LAMBDA_FUNCTION_NAME,CF_PAGES,RENDER,FLY_APP_NAME,K_SERVICE— orNODE_ENV=production. A self-hosted deployment on bare Docker, a VPS, or a plain Kubernetes pod that ships withoutNODE_ENV=production(a common misconfiguration) therefore takes the "development" branch and accepts anonymous JSON-RPC calls — includingmessage/send, agent runs, and the internal processor-dispatch path — from any network peer that can reach the port.mountA2Ainpackages/core/src/a2a/server.ts— both the main JSON-RPC gate and the processor-dispatch path.mountA2Ahandler →!hasA2ASecret && !hasApiKeybranch →isA2AProductionRuntime()returnsfalseon unrecognized hosts → request is treated as trusted local dev and forwarded tohandleJsonRpcH3/processA2ATaskFromQueue.Fix
Fail closed. Instead of trusting any runtime that isn't on the allowlist, only accept unsigned requests when we can positively identify a local/dev context:
127.0.0.1/::1), orA2A_ALLOW_UNSIGNED_INTERNAL=1.Both gates are added in
packages/core/src/a2a/server.ts:isTrustedLocalRuntime({ loopback })before allowing the request through; otherwise it returns503with the same "set A2A_SECRET" message the production branch already used.else if (isA2AProductionRuntime())) is inverted to the same fail-closed check, so an unsigned dispatch from a non-loopback peer on an unrecognized runtime is rejected instead of silently accepted.The helpers
isLoopbackAddressandisTrustedLocalRuntimelive inpackages/core/src/a2a/auth-policy.ts(already present in the tree). The one-time warning message is updated to reflect the new behavior — non-loopback callers are rejected, andA2A_ALLOW_UNSIGNED_INTERNAL=1is documented as the trusted-local escape hatch for dev proxies.Peer IP is obtained via h3's
getRequestIP(event, { xForwardedFor: false })so an attacker cannot spoof loopback by settingX-Forwarded-For: 127.0.0.1through a reverse proxy.Testing
packages/core/src/a2a/server.spec.tsto includegetRequestIPin the h3 mock so the existing suite continues to exercise the gate.503).Security analysis
Preconditions for exploitation before the fix:
agent-nativeon a runtime not in the allowlist (bare Docker, a VPS, plain K8s) and does not setNODE_ENV=production— a realistic misconfig, not a contrived one.A2A_SECRETand noapiKeyEnvconfigured (the "I'll add auth later" state most self-hosters pass through)./a2aport.Under those conditions, any anonymous internet caller could invoke JSON-RPC
message/send, drive agent runs, and replay task IDs harvested from logs or share links. The fix removes the environmental guessing game: unsigned requests are only ever accepted from loopback or with an explicit opt-in env var, both of which are unambiguous local-dev signals.Adversarial review
Before submitting, we tried to disprove this. We checked whether
isA2AProductionRuntime()covered the realistic self-host surface — it doesn't; the allowlist is explicit and small. We checked whether a reverse proxy in front of the app would already enforce auth — that's a deployment assumption the framework can't rely on, and the framework's own warning message previously told operators the unauth path was "allowed in development", implicitly endorsing it on any unrecognized host. We checked whetherX-Forwarded-Forspoofing could bypass the new loopback check —getRequestIPis called withxForwardedFor: false, so the peer IP is taken from the socket, not a header. The fix is a strict tightening: previously-accepted requests from loopback still work, and operators who need a non-loopback dev proxy haveA2A_ALLOW_UNSIGNED_INTERNAL=1as a documented escape hatch.Proof of concept
On a host without
NODE_ENV=productionand withoutA2A_SECRET, mountmountA2Aand issue an unauthenticated JSON-RPC call from a non-loopback peer:handleJsonRpcH3runsmessage/send, and the attacker drives an agent turn as an anonymous caller.503 { "error": "A2A processor not configured — set A2A_SECRET on this deployment to enable async A2A." }unless the peer is loopback or the operator has opted in withA2A_ALLOW_UNSIGNED_INTERNAL=1.Discovered by the Sebastion AI GitHub App.