-
Notifications
You must be signed in to change notification settings - Fork 405
fix(a2a): fail closed when no auth is configured on unrecognized runtimes #2450
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
9201e11
40f5481
d41303d
f544849
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| "@agent-native/core": patch | ||
| --- | ||
|
|
||
| Harden unsigned A2A access on self-hosted deployments. When neither `A2A_SECRET` nor `apiKeyEnv` is configured, both the JSON-RPC endpoint and the `_process-task` processor route now require BOTH a loopback socket peer AND a positive dev signal (`NODE_ENV=development` or `NODE_ENV=test`) — or the explicit `A2A_ALLOW_UNSIGNED_INTERNAL=1` opt-in. Loopback alone is no longer sufficient: a public request forwarded by a local reverse proxy (Nginx/Caddy) to an app bound on `127.0.0.1` on a bare Docker/VPS/K8s host with unset or unrecognized `NODE_ENV` now fails closed instead of being accepted as anonymous. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -23,6 +23,7 @@ vi.mock("h3", () => ({ | |
| getMethod: (event: any) => event.method ?? "POST", | ||
| getRequestHeader: (event: any, name: string) => | ||
| event.headers?.[name.toLowerCase()] ?? event.headers?.[name], | ||
| getRequestIP: (event: any) => event.ip ?? undefined, | ||
| setResponseHeader: setResponseHeaderMock, | ||
| setResponseStatus: setResponseStatusMock, | ||
| })); | ||
|
|
@@ -446,6 +447,160 @@ describe("mountA2A auth", () => { | |
| "A2A processor not configured — set A2A_SECRET on this deployment to enable async A2A.", | ||
| }); | ||
| }); | ||
|
|
||
| describe("unsigned loopback trust (no A2A_SECRET / no apiKeyEnv)", () => { | ||
| // These regression tests pin the reverse-proxy fix: on an unrecognized | ||
| // self-hosted runtime, an anonymous request must fail closed unless the | ||
| // request arrives on loopback AND NODE_ENV is explicitly development/test | ||
| // (or the operator explicitly opts in with A2A_ALLOW_UNSIGNED_INTERNAL=1). | ||
| // The pre-fix bug was that loopback alone was sufficient, so Nginx/Caddy | ||
| // -> 127.0.0.1 forwarded a public request straight through as anonymous. | ||
|
|
||
| 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; | ||
| }); | ||
|
Comment on lines
+459
to
+465
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Clear all production signals in the new auth integration testsThe nested Additional Info |
||
|
|
||
| it("JSON-RPC: rejects loopback caller when NODE_ENV is unset (reverse-proxy relay case)", async () => { | ||
| delete process.env.NODE_ENV; | ||
| const handler = await mountedA2AHandler(config); | ||
| const event = { ...postEvent({}), ip: "127.0.0.1" }; | ||
| const response = await handler(event); | ||
|
|
||
| expect(event._status).toBe(503); | ||
| expect(response).toMatchObject({ | ||
| jsonrpc: "2.0", | ||
| error: { code: -32001 }, | ||
| }); | ||
| expect(handleJsonRpcH3Mock).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it("JSON-RPC: rejects loopback caller when NODE_ENV is unrecognized (e.g. 'staging')", async () => { | ||
| process.env.NODE_ENV = "staging"; | ||
| const handler = await mountedA2AHandler(config); | ||
| const event = { ...postEvent({}), ip: "127.0.0.1" }; | ||
| const response = await handler(event); | ||
|
|
||
| expect(event._status).toBe(503); | ||
| expect(handleJsonRpcH3Mock).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it("JSON-RPC: rejects non-loopback caller even with NODE_ENV=development", async () => { | ||
| process.env.NODE_ENV = "development"; | ||
| const handler = await mountedA2AHandler(config); | ||
| const event = { ...postEvent({}), ip: "203.0.113.42" }; | ||
| const response = await handler(event); | ||
|
|
||
| expect(event._status).toBe(503); | ||
| expect(handleJsonRpcH3Mock).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it("JSON-RPC: accepts loopback caller when NODE_ENV=development", async () => { | ||
| process.env.NODE_ENV = "development"; | ||
| const handler = await mountedA2AHandler(config); | ||
| const event = { ...postEvent({}), ip: "127.0.0.1" }; | ||
| const response = await handler(event); | ||
|
|
||
| expect(handleJsonRpcH3Mock).toHaveBeenCalledTimes(1); | ||
| expect(response).toEqual({ jsonrpc: "2.0", id: 1, result: { ok: true } }); | ||
| }); | ||
|
|
||
| it("JSON-RPC: accepts loopback caller when NODE_ENV=test", async () => { | ||
| process.env.NODE_ENV = "test"; | ||
| const handler = await mountedA2AHandler(config); | ||
| const event = { ...postEvent({}), ip: "::1" }; | ||
| const response = await handler(event); | ||
|
|
||
| expect(handleJsonRpcH3Mock).toHaveBeenCalledTimes(1); | ||
| expect(response).toEqual({ jsonrpc: "2.0", id: 1, result: { ok: true } }); | ||
| }); | ||
|
|
||
| it("JSON-RPC: accepts non-loopback caller when A2A_ALLOW_UNSIGNED_INTERNAL=1", async () => { | ||
| delete process.env.NODE_ENV; | ||
| process.env.A2A_ALLOW_UNSIGNED_INTERNAL = "1"; | ||
| const handler = await mountedA2AHandler(config); | ||
| const event = { ...postEvent({}), ip: "10.0.0.5" }; | ||
| await handler(event); | ||
|
|
||
| expect(handleJsonRpcH3Mock).toHaveBeenCalledTimes(1); | ||
| }); | ||
|
|
||
| it("processor: rejects loopback caller when NODE_ENV is unset (reverse-proxy relay case)", async () => { | ||
| delete process.env.NODE_ENV; | ||
| const handler = await mountedA2AProcessorHandler(config); | ||
| const event = { | ||
| method: "POST", | ||
| headers: {}, | ||
| path: "/", | ||
| context: {}, | ||
| body: { taskId: "task-1" }, | ||
| ip: "127.0.0.1", | ||
| }; | ||
| const response = await handler(event); | ||
|
|
||
| expect(event._status).toBe(503); | ||
| expect(response).toEqual({ | ||
| error: | ||
| "A2A processor not configured — set A2A_SECRET on this deployment to enable async A2A.", | ||
| }); | ||
| }); | ||
|
|
||
| it("processor: rejects loopback caller when NODE_ENV is unrecognized", async () => { | ||
| process.env.NODE_ENV = "staging"; | ||
| const handler = await mountedA2AProcessorHandler(config); | ||
| const event = { | ||
| method: "POST", | ||
| headers: {}, | ||
| path: "/", | ||
| context: {}, | ||
| body: { taskId: "task-1" }, | ||
| ip: "127.0.0.1", | ||
| }; | ||
| const response = await handler(event); | ||
|
|
||
| expect(event._status).toBe(503); | ||
| }); | ||
|
|
||
| it("processor: rejects non-loopback caller even with NODE_ENV=development", async () => { | ||
| process.env.NODE_ENV = "development"; | ||
| const handler = await mountedA2AProcessorHandler(config); | ||
| const event = { | ||
| method: "POST", | ||
| headers: {}, | ||
| path: "/", | ||
| context: {}, | ||
| body: { taskId: "task-1" }, | ||
| ip: "203.0.113.42", | ||
| }; | ||
| const response = await handler(event); | ||
|
|
||
| expect(event._status).toBe(503); | ||
| }); | ||
|
|
||
| it("processor: accepts loopback caller when NODE_ENV=test", async () => { | ||
| process.env.NODE_ENV = "test"; | ||
| const handler = await mountedA2AProcessorHandler(config); | ||
| const event = { | ||
| method: "POST", | ||
| headers: {}, | ||
| path: "/", | ||
| context: {}, | ||
| body: { taskId: "task-1" }, | ||
| ip: "127.0.0.1", | ||
| }; | ||
| const response = await handler(event); | ||
|
|
||
| // Not the 503 fail-closed shape; processor proceeded. | ||
| expect(event._status).not.toBe(503); | ||
| expect(response).not.toEqual({ | ||
| error: | ||
| "A2A processor not configured — set A2A_SECRET on this deployment to enable async A2A.", | ||
| }); | ||
| }); | ||
| }); | ||
| }); | ||
|
|
||
| describe("verifyA2AToken (exported)", () => { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -4,6 +4,7 @@ import { | |
| setResponseStatus, | ||
| getMethod, | ||
| getRequestHeader, | ||
| getRequestIP, | ||
| } from "h3"; | ||
| import * as jose from "jose"; | ||
|
|
||
|
|
@@ -19,6 +20,8 @@ import { generateAgentCard } from "./agent-card.js"; | |
| import { | ||
| hasConfiguredA2ASecret, | ||
| isA2AProductionRuntime, | ||
| isLoopbackAddress, | ||
| isTrustedLocalRuntime, | ||
| } from "./auth-policy.js"; | ||
| import { handleJsonRpcH3, processA2ATaskFromQueue } from "./handlers.js"; | ||
| import { | ||
|
|
@@ -40,8 +43,8 @@ function warnA2AUnauthOnce(): void { | |
| _warnedUnauthA2A = true; | ||
| // eslint-disable-next-line no-console | ||
| console.warn( | ||
| "[a2a] No A2A_SECRET or apiKeyEnv configured — A2A endpoint runs unauthenticated. " + | ||
| "This is allowed in development but blocked in production. Set A2A_SECRET before deploying.", | ||
| "[a2a] No A2A_SECRET or apiKeyEnv configured — A2A endpoint accepting an unauthenticated loopback request from a NODE_ENV=development/test runtime. " + | ||
| "Non-loopback callers, unrecognized runtimes, and self-hosted deployments behind a local reverse proxy are rejected; set A2A_SECRET (or A2A_ALLOW_UNSIGNED_INTERNAL=1 for trusted local dev) before deploying.", | ||
| ); | ||
| } | ||
|
|
||
|
|
@@ -398,13 +401,25 @@ export function mountA2A( | |
| setResponseStatus(event, 401); | ||
| return { error: "Invalid or expired processor token" }; | ||
| } | ||
| } else if (isA2AProductionRuntime()) { | ||
| setResponseStatus(event, 503); | ||
| return { | ||
| error: | ||
| "A2A processor not configured — set A2A_SECRET on this deployment to enable async A2A.", | ||
| }; | ||
| } else { | ||
| // No A2A_SECRET is configured. Unsigned processor dispatches are only | ||
| // acceptable when we can positively identify a local/dev runtime — | ||
| // either the request arrived over loopback (127.0.0.1/::1) or the | ||
| // operator explicitly set A2A_ALLOW_UNSIGNED_INTERNAL=1. Any other | ||
| // caller (a non-loopback peer on a bare Docker/VPS/K8s host that | ||
| // happens to be missing NODE_ENV=production) must be rejected — an | ||
| // attacker who fishes a taskId out of logs / a share link could | ||
| // otherwise force-replay it. | ||
| const loopback = isLoopbackAddress( | ||
| getRequestIP(event, { xForwardedFor: false }), | ||
| ); | ||
|
builder-io-integration[bot] marked this conversation as resolved.
|
||
| if (!isTrustedLocalRuntime({ loopback })) { | ||
|
Comment on lines
+413
to
+416
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Prevent API-key-only async tasks from being strandedThe main JSON-RPC route accepts a configured legacy Additional Info |
||
| setResponseStatus(event, 503); | ||
| return { | ||
| error: | ||
| "A2A processor not configured — set A2A_SECRET on this deployment to enable async A2A.", | ||
| }; | ||
| } | ||
| warnA2AUnauthOnce(); | ||
| } | ||
|
|
||
|
|
@@ -510,7 +525,19 @@ export function mountA2A( | |
| } | ||
|
|
||
| if (!hasA2ASecret && !hasApiKey) { | ||
| if (isA2AProductionRuntime()) { | ||
| // No auth is configured at all. Only allow the request through | ||
| // when we can positively identify a local/dev runtime — either | ||
| // the request arrived over loopback (127.0.0.1/::1) or the | ||
| // operator explicitly opted in with A2A_ALLOW_UNSIGNED_INTERNAL=1. | ||
| // NODE_ENV alone is NOT a trust grant: a self-hosted deployment | ||
| // that doesn't set NODE_ENV=production and isn't recognized by | ||
| // isA2AProductionRuntime() (a bare Docker/VPS/K8s pod) must | ||
| // still fail closed. Otherwise any anonymous caller could reach | ||
| // the JSON-RPC handler and trigger message/send / agent runs. | ||
| const loopback = isLoopbackAddress( | ||
| getRequestIP(event, { xForwardedFor: false }), | ||
| ); | ||
|
builder-io-integration[bot] marked this conversation as resolved.
|
||
| if (!isTrustedLocalRuntime({ loopback })) { | ||
| setResponseStatus(event, 503); | ||
| return { | ||
| jsonrpc: "2.0", | ||
|
|
||
There was a problem hiding this comment.
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 globalA2A_SECRETand 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 whereverifyA2AToken()can use an org-scoped secret). Align agent-card metadata with the same access policy and add coverage for an unset/stagingNODE_ENVdeployment.Additional Info