Skip to content

Commit d41303d

Browse files
committed
address review: require explicit dev signal for loopback A2A trust
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.
1 parent 40f5481 commit d41303d

3 files changed

Lines changed: 58 additions & 15 deletions

File tree

packages/core/src/a2a/auth-policy.spec.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -161,11 +161,16 @@ describe("a2a auth-policy", () => {
161161
expect(isTrustedLocalRuntime({ loopback: true })).toBe(false);
162162
});
163163

164-
it("is true with no secret, loopback true, dev", () => {
164+
it("is true with no secret, loopback true, NODE_ENV=development", () => {
165165
process.env.NODE_ENV = "development";
166166
expect(isTrustedLocalRuntime({ loopback: true })).toBe(true);
167167
});
168168

169+
it("is true with no secret, loopback true, NODE_ENV=test", () => {
170+
process.env.NODE_ENV = "test";
171+
expect(isTrustedLocalRuntime({ loopback: true })).toBe(true);
172+
});
173+
169174
it("is false with no secret, loopback false, no flag, dev (the VPS hole is now closed)", () => {
170175
process.env.NODE_ENV = "development";
171176
expect(isTrustedLocalRuntime({ loopback: false })).toBe(false);
@@ -180,6 +185,24 @@ describe("a2a auth-policy", () => {
180185
process.env.NETLIFY = "true";
181186
expect(isTrustedLocalRuntime({ loopback: true })).toBe(false);
182187
});
188+
189+
// Regression: a bare self-hosted VPS/Docker/K8s host where the operator
190+
// runs the app bound to 127.0.0.1 behind Nginx/Caddy and forgets to set
191+
// NODE_ENV. Every public request appears loopback-peered from the app's
192+
// point of view. Without a positive dev signal we MUST fail closed so
193+
// that anonymous JSON-RPC / _process-task is not silently reachable
194+
// through the local reverse proxy.
195+
it("is false when loopback is true but NODE_ENV is unset (reverse-proxy relay case)", () => {
196+
// NODE_ENV was cleared in beforeEach — this simulates the bare
197+
// Docker/VPS runtime that isn't in isA2AProductionRuntime()'s list.
198+
expect(process.env.NODE_ENV).toBeUndefined();
199+
expect(isTrustedLocalRuntime({ loopback: true })).toBe(false);
200+
});
201+
202+
it("is false when loopback is true but NODE_ENV is an unrecognized value", () => {
203+
process.env.NODE_ENV = "staging";
204+
expect(isTrustedLocalRuntime({ loopback: true })).toBe(false);
205+
});
183206
});
184207

185208
describe("isLoopbackAddress", () => {

packages/core/src/a2a/auth-policy.ts

Lines changed: 32 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -33,23 +33,43 @@ export function shouldAdvertiseJwtA2AAuth(): boolean {
3333
}
3434

3535
/**
36-
* True only when unsigned internal self-dispatch is acceptable: no A2A_SECRET
37-
* is configured AND we can positively identify a local/dev runtime. Everything
38-
* else — production, or any UNRECOGNIZED deployed/networked host — must fail
39-
* closed and require A2A_SECRET. `loopback` should be whether the inbound
40-
* request arrived over the loopback interface (127.0.0.1/::1); callers that
41-
* cannot determine the peer address pass `false`.
36+
* True when the process is a positively-identified developer/test runtime
37+
* (NODE_ENV explicitly `development` or `test`). We deliberately do NOT treat
38+
* an *unset* NODE_ENV as dev: a bare self-hosted Docker/VPS/K8s deployment
39+
* that forgot to set NODE_ENV must fall through to the fail-closed path so
40+
* that a local reverse proxy (Nginx/Caddy) forwarding public traffic to an
41+
* app bound on 127.0.0.1 cannot silently satisfy the loopback gate.
42+
*/
43+
function isExplicitDevRuntime(): boolean {
44+
const env = process.env.NODE_ENV;
45+
return env === "development" || env === "test";
46+
}
47+
48+
/**
49+
* True only when unsigned internal self-dispatch is acceptable. This is the
50+
* gate for anonymous A2A/JSON-RPC when no `A2A_SECRET` and no `apiKeyEnv` is
51+
* configured. To pass:
52+
*
53+
* - the runtime must NOT look like production/a recognized cloud host, AND
54+
* - EITHER the operator explicitly opted in with
55+
* `A2A_ALLOW_UNSIGNED_INTERNAL=1` (documented, deliberate),
56+
* - OR the request arrived over the loopback interface AND `NODE_ENV` is
57+
* explicitly `development` / `test` (positive dev signal, not just
58+
* "unrecognized runtime").
4259
*
43-
* NODE_ENV alone is deliberately NOT a trust grant: a self-hosted deployment
44-
* that doesn't set NODE_ENV=production and isn't recognized by
45-
* `isA2AProductionRuntime()` (a bare Docker/VPS/K8s pod) must still fail
46-
* closed unless the request actually came from loopback or the explicit
47-
* opt-in flag is set.
60+
* The `NODE_ENV=development|test` requirement is what closes the
61+
* reverse-proxy hole: on a self-hosted VPS/Docker box where the operator
62+
* runs the app bound to 127.0.0.1 behind Nginx/Caddy WITHOUT setting
63+
* NODE_ENV, every public request would otherwise appear as a loopback peer
64+
* and satisfy an "is-local" check. Requiring an explicit dev signal makes
65+
* the operator opt in either through NODE_ENV or through
66+
* A2A_ALLOW_UNSIGNED_INTERNAL — an unset NODE_ENV alone is not a trust
67+
* grant.
4868
*/
4969
export function isTrustedLocalRuntime(opts: { loopback: boolean }): boolean {
5070
if (isA2AProductionRuntime()) return false;
5171
if (process.env.A2A_ALLOW_UNSIGNED_INTERNAL === "1") return true;
52-
return opts.loopback === true;
72+
return opts.loopback === true && isExplicitDevRuntime();
5373
}
5474

5575
/** True if a socket peer address is a loopback/local address. */

packages/core/src/a2a/server.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,8 @@ function warnA2AUnauthOnce(): void {
4343
_warnedUnauthA2A = true;
4444
// eslint-disable-next-line no-console
4545
console.warn(
46-
"[a2a] No A2A_SECRET or apiKeyEnv configured — A2A endpoint accepting an unauthenticated loopback request. " +
47-
"Non-loopback callers are rejected; set A2A_SECRET (or A2A_ALLOW_UNSIGNED_INTERNAL=1 for trusted local dev) before deploying.",
46+
"[a2a] No A2A_SECRET or apiKeyEnv configured — A2A endpoint accepting an unauthenticated loopback request from a NODE_ENV=development/test runtime. " +
47+
"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.",
4848
);
4949
}
5050

0 commit comments

Comments
 (0)