diff --git a/.agents/skills/a2a-protocol/SKILL.md b/.agents/skills/a2a-protocol/SKILL.md index a95e4e8e32..db0be80fb3 100644 --- a/.agents/skills/a2a-protocol/SKILL.md +++ b/.agents/skills/a2a-protocol/SKILL.md @@ -277,6 +277,20 @@ descriptions, client bundles, or examples. A2A tokens are deploy-level secrets unless a specific app designs a scoped credential flow; read them from secure runtime configuration and never log or return them. +### Unsigned local development + +If neither `A2A_SECRET` nor `apiKeyEnv` is configured, the JSON-RPC endpoint +and the internal `_process-task` route only accept requests when the runtime +is positively identified as local development — the request must arrive over +the loopback interface (`127.0.0.1`/`::1`) **and** `NODE_ENV` must be +explicitly `development` or `test`. Loopback alone is not enough: a +self-hosted Nginx/Caddy reverse proxy forwarding public traffic to an app +bound on `127.0.0.1` (bare Docker/VPS/K8s, no `NODE_ENV` set) will fail +closed. Operators who really want unsigned internal self-dispatch on a +non-recognized host must set `A2A_ALLOW_UNSIGNED_INTERNAL=1` explicitly; any +production or networked deployment should configure `A2A_SECRET` instead. + + ## Message Parts Messages contain typed parts: diff --git a/.changeset/a2a-loopback-requires-explicit-dev.md b/.changeset/a2a-loopback-requires-explicit-dev.md new file mode 100644 index 0000000000..2bff8d0a1c --- /dev/null +++ b/.changeset/a2a-loopback-requires-explicit-dev.md @@ -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. diff --git a/packages/core/src/a2a/auth-policy.spec.ts b/packages/core/src/a2a/auth-policy.spec.ts index 52e5a18c42..bb71345226 100644 --- a/packages/core/src/a2a/auth-policy.spec.ts +++ b/packages/core/src/a2a/auth-policy.spec.ts @@ -161,11 +161,16 @@ describe("a2a auth-policy", () => { expect(isTrustedLocalRuntime({ loopback: true })).toBe(false); }); - it("is true with no secret, loopback true, dev", () => { + it("is true with no secret, loopback true, NODE_ENV=development", () => { process.env.NODE_ENV = "development"; expect(isTrustedLocalRuntime({ loopback: true })).toBe(true); }); + it("is true with no secret, loopback true, NODE_ENV=test", () => { + process.env.NODE_ENV = "test"; + expect(isTrustedLocalRuntime({ loopback: true })).toBe(true); + }); + it("is false with no secret, loopback false, no flag, dev (the VPS hole is now closed)", () => { process.env.NODE_ENV = "development"; expect(isTrustedLocalRuntime({ loopback: false })).toBe(false); @@ -180,6 +185,24 @@ describe("a2a auth-policy", () => { process.env.NETLIFY = "true"; expect(isTrustedLocalRuntime({ loopback: true })).toBe(false); }); + + // Regression: a bare self-hosted VPS/Docker/K8s host where the operator + // runs the app bound to 127.0.0.1 behind Nginx/Caddy and forgets to set + // NODE_ENV. Every public request appears loopback-peered from the app's + // point of view. Without a positive dev signal we MUST fail closed so + // that anonymous JSON-RPC / _process-task is not silently reachable + // through the local reverse proxy. + it("is false when loopback is true but NODE_ENV is unset (reverse-proxy relay case)", () => { + // NODE_ENV was cleared in beforeEach — this simulates the bare + // Docker/VPS runtime that isn't in isA2AProductionRuntime()'s list. + expect(process.env.NODE_ENV).toBeUndefined(); + expect(isTrustedLocalRuntime({ loopback: true })).toBe(false); + }); + + it("is false when loopback is true but NODE_ENV is an unrecognized value", () => { + process.env.NODE_ENV = "staging"; + expect(isTrustedLocalRuntime({ loopback: true })).toBe(false); + }); }); describe("isLoopbackAddress", () => { diff --git a/packages/core/src/a2a/auth-policy.ts b/packages/core/src/a2a/auth-policy.ts index 3291d56f53..a237498a54 100644 --- a/packages/core/src/a2a/auth-policy.ts +++ b/packages/core/src/a2a/auth-policy.ts @@ -33,23 +33,43 @@ export function shouldAdvertiseJwtA2AAuth(): boolean { } /** - * True only when unsigned internal self-dispatch is acceptable: no A2A_SECRET - * is configured AND we can positively identify a local/dev runtime. Everything - * else — production, or any UNRECOGNIZED deployed/networked host — must fail - * closed and require A2A_SECRET. `loopback` should be whether the inbound - * request arrived over the loopback interface (127.0.0.1/::1); callers that - * cannot determine the peer address pass `false`. + * True when the process is a positively-identified developer/test runtime + * (NODE_ENV explicitly `development` or `test`). We deliberately do NOT treat + * an *unset* NODE_ENV as dev: a bare self-hosted Docker/VPS/K8s deployment + * that forgot to set NODE_ENV must fall through to the fail-closed path so + * that a local reverse proxy (Nginx/Caddy) forwarding public traffic to an + * app bound on 127.0.0.1 cannot silently satisfy the loopback gate. + */ +function isExplicitDevRuntime(): boolean { + const env = process.env.NODE_ENV; + return env === "development" || env === "test"; +} + +/** + * True only when unsigned internal self-dispatch is acceptable. This is the + * gate for anonymous A2A/JSON-RPC when no `A2A_SECRET` and no `apiKeyEnv` is + * configured. To pass: + * + * - the runtime must NOT look like production/a recognized cloud host, AND + * - EITHER the operator explicitly opted in with + * `A2A_ALLOW_UNSIGNED_INTERNAL=1` (documented, deliberate), + * - OR the request arrived over the loopback interface AND `NODE_ENV` is + * explicitly `development` / `test` (positive dev signal, not just + * "unrecognized runtime"). * - * NODE_ENV alone is deliberately 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 unless the request actually came from loopback or the explicit - * opt-in flag is set. + * The `NODE_ENV=development|test` requirement is what closes the + * reverse-proxy hole: on a self-hosted VPS/Docker box where the operator + * runs the app bound to 127.0.0.1 behind Nginx/Caddy WITHOUT setting + * NODE_ENV, every public request would otherwise appear as a loopback peer + * and satisfy an "is-local" check. Requiring an explicit dev signal makes + * the operator opt in either through NODE_ENV or through + * A2A_ALLOW_UNSIGNED_INTERNAL — an unset NODE_ENV alone is not a trust + * grant. */ 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(); } /** True if a socket peer address is a loopback/local address. */ diff --git a/packages/core/src/a2a/server.spec.ts b/packages/core/src/a2a/server.spec.ts index 3fe7c3e788..de92b9dce5 100644 --- a/packages/core/src/a2a/server.spec.ts +++ b/packages/core/src/a2a/server.spec.ts @@ -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; + }); + + 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)", () => { diff --git a/packages/core/src/a2a/server.ts b/packages/core/src/a2a/server.ts index 5dc8d41849..ba7eccd942 100644 --- a/packages/core/src/a2a/server.ts +++ b/packages/core/src/a2a/server.ts @@ -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 }), + ); + if (!isTrustedLocalRuntime({ loopback })) { + 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 }), + ); + if (!isTrustedLocalRuntime({ loopback })) { setResponseStatus(event, 503); return { jsonrpc: "2.0",