Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .agents/skills/a2a-protocol/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
5 changes: 5 additions & 0 deletions .changeset/a2a-loopback-requires-explicit-dev.md
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.
25 changes: 24 additions & 1 deletion packages/core/src/a2a/auth-policy.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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", () => {
Expand Down
44 changes: 32 additions & 12 deletions packages/core/src/a2a/auth-policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Comment on lines 69 to +72

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

}

/** True if a socket peer address is a loopback/local address. */
Expand Down
155 changes: 155 additions & 0 deletions packages/core/src/a2a/server.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}));
Expand Down Expand Up @@ -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

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


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)", () => {
Expand Down
45 changes: 36 additions & 9 deletions packages/core/src/a2a/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
setResponseStatus,
getMethod,
getRequestHeader,
getRequestIP,
} from "h3";
import * as jose from "jose";

Expand All @@ -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 {
Expand All @@ -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.",
);
}

Expand Down Expand Up @@ -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 }),
);
Comment thread
builder-io-integration[bot] marked this conversation as resolved.
if (!isTrustedLocalRuntime({ loopback })) {
Comment on lines +413 to +416

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

setResponseStatus(event, 503);
return {
error:
"A2A processor not configured — set A2A_SECRET on this deployment to enable async A2A.",
};
}
warnA2AUnauthOnce();
}

Expand Down Expand Up @@ -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 }),
);
Comment thread
builder-io-integration[bot] marked this conversation as resolved.
if (!isTrustedLocalRuntime({ loopback })) {
setResponseStatus(event, 503);
return {
jsonrpc: "2.0",
Expand Down
Loading