Skip to content

fix(a2a): fail closed when no auth is configured on unrecognized runtimes - #2450

Open
sebastiondev wants to merge 4 commits into
BuilderIO:mainfrom
sebastiondev:fix/cwe287-server-authentica-06df
Open

fix(a2a): fail closed when no auth is configured on unrecognized runtimes#2450
sebastiondev wants to merge 4 commits into
BuilderIO:mainfrom
sebastiondev:fix/cwe287-server-authentica-06df

Conversation

@sebastiondev

Copy link
Copy Markdown

Summary

The A2A JSON-RPC endpoint (/a2a, mounted by mountA2A in packages/core/src/a2a/server.ts) fails open on any host that isA2AProductionRuntime() does not recognize. That helper only returns true when it sees one of a fixed set of hosting signals — NETLIFY, VERCEL, AWS_LAMBDA_FUNCTION_NAME, CF_PAGES, RENDER, FLY_APP_NAME, K_SERVICE — or NODE_ENV=production. A self-hosted deployment on bare Docker, a VPS, or a plain Kubernetes pod that ships without NODE_ENV=production (a common misconfiguration) therefore takes the "development" branch and accepts anonymous JSON-RPC calls — including message/send, agent runs, and the internal processor-dispatch path — from any network peer that can reach the port.

  • CWE-287 (Improper Authentication) / CWE-1188 (Insecure Default Initialization of Resource)
  • Affected: mountA2A in packages/core/src/a2a/server.ts — both the main JSON-RPC gate and the processor-dispatch path.
  • Data flow: unauthenticated HTTP request → mountA2A handler → !hasA2ASecret && !hasApiKey branch → isA2AProductionRuntime() returns false on unrecognized hosts → request is treated as trusted local dev and forwarded to handleJsonRpcH3 / 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:

  1. The peer IP is loopback (127.0.0.1 / ::1), or
  2. The operator has explicitly opted in with A2A_ALLOW_UNSIGNED_INTERNAL=1.

Both gates are added in packages/core/src/a2a/server.ts:

  • The main JSON-RPC auth branch now checks isTrustedLocalRuntime({ loopback }) before allowing the request through; otherwise it returns 503 with the same "set A2A_SECRET" message the production branch already used.
  • The processor-dispatch path (previously 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 isLoopbackAddress and isTrustedLocalRuntime live in packages/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, and A2A_ALLOW_UNSIGNED_INTERNAL=1 is 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 setting X-Forwarded-For: 127.0.0.1 through a reverse proxy.

Testing

  • Updated packages/core/src/a2a/server.spec.ts to include getRequestIP in the h3 mock so the existing suite continues to exercise the gate.
  • Existing tests for the authenticated and production-refusal paths still pass; the added behavior is a strict tightening (previously-accepted non-loopback unsigned requests on unrecognized runtimes now receive 503).

Security analysis

Preconditions for exploitation before the fix:

  • Target self-hosts agent-native on a runtime not in the allowlist (bare Docker, a VPS, plain K8s) and does not set NODE_ENV=production — a realistic misconfig, not a contrived one.
  • No A2A_SECRET and no apiKeyEnv configured (the "I'll add auth later" state most self-hosters pass through).
  • Network reachability to the /a2a port.

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 whether X-Forwarded-For spoofing could bypass the new loopback check — getRequestIP is called with xForwardedFor: 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 have A2A_ALLOW_UNSIGNED_INTERNAL=1 as a documented escape hatch.

Proof of concept

On a host without NODE_ENV=production and without A2A_SECRET, mount mountA2A and issue an unauthenticated JSON-RPC call from a non-loopback peer:

# Attacker machine, target reachable at http://target:3000
curl -s -X POST http://target:3000/a2a \
  -H 'content-type: application/json' \
  -d '{"jsonrpc":"2.0","id":1,"method":"message/send","params":{"message":{"role":"user","parts":[{"kind":"text","text":"hello"}]}}}'
  • Before the fix: the request is accepted, handleJsonRpcH3 runs message/send, and the attacker drives an agent turn as an anonymous caller.
  • After the fix: the request receives 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 with A2A_ALLOW_UNSIGNED_INTERNAL=1.

Discovered by the Sebastion AI GitHub App.

…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.
@netlify

This comment has been minimized.

@netlify

This comment has been minimized.

@netlify

This comment has been minimized.

@github-actions

github-actions Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Visual recap — skipped

The visual recap job did not run for this pull request. This is informational only and does not block the PR.

Recap skipped for f544849: external fork PR requires a maintainer to apply the recap label to the current head SHA.

builder-io-integration[bot]

This comment was marked as outdated.

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.
@sebastiondev

Copy link
Copy Markdown
Author

Thanks for the careful review — both findings are correct. getRequestIP(event, { xForwardedFor: false }) only tells us the immediate socket peer, and on a self‑hosted VPS/Docker/K8s box running the app bound to 127.0.0.1 behind Nginx/Caddy every public request arrives as loopback. The prior isTrustedLocalRuntime({ loopback }) gate would then accept anonymous JSON‑RPC and _process-task dispatches on any unrecognized runtime the operator hadn't set NODE_ENV=production on. That's the exact hole this PR was supposed to close.

Pushed d41303d with the following change to isTrustedLocalRuntime():

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 (NODE_ENV === "development" or "test"). An unset or unrecognized NODE_ENV — the typical bare‑Docker/VPS reverse‑proxy case — no longer satisfies the gate, so anonymous JSON‑RPC and _process-task both fail closed. Operators who intentionally want unsigned internal self‑dispatch still have the explicit A2A_ALLOW_UNSIGNED_INTERNAL=1 opt‑in.

This applies to both routes flagged (server.ts:415 processor and server.ts:539 JSON‑RPC) since they share the helper, and also to the two other consumers I audited (agent-chat-plugin.ts Agent Teams processor and core-routes-plugin.ts sandbox _process-execution).

Regression coverage added in auth-policy.spec.ts:

  • loopback true + NODE_ENV unset → false (reverse‑proxy relay case)
  • loopback true + NODE_ENV=staging (unrecognized) → false
  • loopback true + NODE_ENV=testtrue (CI/local test runtime)
  • existing dev/prod/opt‑in cases retained

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 A2A_SECRET for anything proxied — the loopback warning message now says that explicitly.

builder-io-integration[bot]

This comment was marked as outdated.

@steve8708

Copy link
Copy Markdown
Contributor

thanks @sebastiondev!

Before merging, could you:

  • Add the required .changeset entry for @agent-native/core.
  • Update the A2A skill/comments to reflect that loopback alone is no longer sufficient.
  • Add route-level regression tests for loopback, non-loopback, and unset/unrecognized NODE_ENV cases.

With those follow-ups, this looks good to me.

@sebastiondev

Copy link
Copy Markdown
Author

Thanks @steve8708 — all three follow-ups are in f544849:

  1. Changeset — added .changeset/a2a-loopback-requires-explicit-dev.md as a patch bump for @agent-native/core, summarizing the loopback-plus-explicit-dev gate.
  2. A2A skill/comments — updated .agents/skills/a2a-protocol/SKILL.md with a new "Unsigned local development" subsection under Security that spells out: loopback alone is no longer sufficient, NODE_ENV must be explicitly development or test, self-hosted reverse-proxy setups (bare Docker/VPS/K8s with unset NODE_ENV) fail closed, and A2A_ALLOW_UNSIGNED_INTERNAL=1 is the explicit opt-in. The inline comments at both call sites in server.ts and the JSDoc on isTrustedLocalRuntime already reflect the new policy from d41303d.
  3. Route-level regression tests — added a new describe("unsigned loopback trust (no A2A_SECRET / no apiKeyEnv)") block in packages/core/src/a2a/server.spec.ts covering both /_agent-native/a2a and /_agent-native/a2a/_process-task:
    • loopback + NODE_ENV unset → 503 (reverse-proxy relay case, the exact regression)
    • loopback + NODE_ENV=staging (unrecognized) → 503
    • non-loopback + NODE_ENV=development503
    • loopback + NODE_ENV=developmentaccepted
    • loopback + NODE_ENV=test (incl. ::1) → accepted
    • non-loopback + A2A_ALLOW_UNSIGNED_INTERNAL=1accepted

pnpm exec vitest run src/a2a/server.spec.ts src/a2a/auth-policy.spec.ts in packages/core is green: 72/72 tests pass (34 server + 38 auth-policy).

@builder-io-integration builder-io-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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-task only accepts A2A_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.

Comment on lines 69 to +72
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();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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.

Fix in Builder

Comment on lines +413 to +416
const loopback = isLoopbackAddress(
getRequestIP(event, { xForwardedFor: false }),
);
if (!isTrustedLocalRuntime({ loopback })) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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.

Fix in Builder

Comment on lines +459 to +465
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;
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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.

Fix in Builder

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.

2 participants