a2a-exposed 0.8.13 → 0.8.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "a2a-exposed",
3
- "version": "0.8.13",
3
+ "version": "0.8.14",
4
4
  "description": "Give any AI agent a public A2A (Agent2Agent) endpoint: a Cloudflare Worker, a wake webhook, or scheduled checks",
5
5
  "type": "module",
6
6
  "bin": {
package/worker/src/mcp.ts CHANGED
@@ -80,8 +80,58 @@ export function cimdDocument(id: string, doc: any): { client_name: string; redir
80
80
  }
81
81
  export const CIMD = { maxBytes: 5120, timeoutMs: 5000, defaultTtlS: 3600, minTtlS: 60, maxTtlS: 86400, fetchesPerIpPerHour: 30, dohUrl: "https://cloudflare-dns.com/dns-query" };
82
82
 
83
- /** True for an IP address that is fine to fetch from: not private, loopback, link-local, CGNAT, unspecified, benchmark,
84
- * documentation, multicast or reserved (IPv4 or IPv6, including IPv4-mapped / NAT64 forms). Not an IP: false. */
83
+ /** More-specific allocations inside 2001::/23 whose IANA "Globally Reachable" flag is True (footnote 1). */
84
+ const V6_REACHABLE = ["2001:1::1/128", "2001:1::2/128", "2001:1::3/128", "2001:3::/32", "2001:4:112::/48", "2001:20::/28", "2001:30::/28"];
85
+ /** Blocks inside 2000::/3 that are not globally reachable. 2001::/23 covers Teredo, benchmarking, and deprecated ORCHID. */
86
+ const V6_UNREACHABLE = ["2001::/23", "2001:db8::/32", "2002::/16", "3fff::/20"];
87
+
88
+ /** Sixteen bytes, or null when `h` is not one IPv6 address (dotted tail allowed). */
89
+ function ipv6Bytes(h: string): Uint8Array | null {
90
+ let s = h;
91
+ const tail = /^(.*:)(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(s);
92
+ if (tail) {
93
+ const o = tail.slice(2).map(Number);
94
+ if (o.some((n) => n > 255)) return null;
95
+ s = `${tail[1]}${((o[0] << 8) | o[1]).toString(16)}:${((o[2] << 8) | o[3]).toString(16)}`;
96
+ }
97
+ if (s.includes(":::") || (s.includes("::") && s.indexOf("::") !== s.lastIndexOf("::"))) return null;
98
+ const compressed = s.includes("::");
99
+ const [head, rest] = compressed ? s.split("::") : [s, ""];
100
+ const left = head ? head.split(":") : [];
101
+ const right = compressed && rest ? rest.split(":") : [];
102
+ if (!compressed && left.length !== 8) return null;
103
+ if (compressed && left.length + right.length > 7) return null;
104
+ const groups = compressed ? [...left, ...Array(8 - left.length - right.length).fill("0"), ...right] : left;
105
+ if (groups.length !== 8 || groups.some((g) => !/^[0-9a-f]{1,4}$/.test(g))) return null;
106
+ const out = new Uint8Array(16);
107
+ groups.forEach((g, i) => {
108
+ const n = parseInt(g, 16);
109
+ out[i * 2] = n >> 8;
110
+ out[i * 2 + 1] = n & 0xff;
111
+ });
112
+ return out;
113
+ }
114
+
115
+ /** True when `addr` is inside `block` (`addr/bits`). */
116
+ function v6Prefix(addr: Uint8Array, block: string): boolean {
117
+ const slash = block.lastIndexOf("/");
118
+ const net = ipv6Bytes(block.slice(0, slash));
119
+ const bits = Number(block.slice(slash + 1));
120
+ if (!net || !Number.isInteger(bits) || bits < 0 || bits > 128) return false;
121
+ let left = bits;
122
+ for (let i = 0; i < 16 && left > 0; i++) {
123
+ const take = Math.min(8, left);
124
+ const mask = take === 8 ? 0xff : (0xff << (8 - take)) & 0xff;
125
+ if ((addr[i] & mask) !== (net[i] & mask)) return false;
126
+ left -= take;
127
+ }
128
+ return true;
129
+ }
130
+
131
+ /** True for an IP address that is fine to fetch from: a public IPv4 address, or a parsed IPv6 address in global
132
+ * unicast (2000::/3) outside the IANA special-purpose blocks that are not globally reachable. Not an IP: false.
133
+ * Private, loopback, link-local, CGNAT, and unspecified addresses are refused, including IPv4 embedded in IPv6.
134
+ * IPv4-mapped and NAT64 sit outside 2000::/3, so they are refused too. */
85
135
  export function isPublicIp(ip: string): boolean {
86
136
  const h = ip.toLowerCase().replace(/^\[|\]$/g, "");
87
137
  const v4 = h.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
@@ -92,11 +142,12 @@ export function isPublicIp(ip: string): boolean {
92
142
  return !(a >= 224 || (a === 192 && b === 0 && (c === 0 || c === 2)) || (a === 198 && (b === 18 || b === 19)) ||
93
143
  (a === 198 && b === 51 && c === 100) || (a === 203 && b === 0 && c === 113) || (a === 192 && b === 88 && c === 99));
94
144
  }
95
- if (!h.includes(":") || !/^[0-9a-f:.]+$/.test(h)) return false;
145
+ if (!h.includes(":")) return false;
96
146
  if (isPrivateHost(h)) return false;
97
- // only global unicast (2000::/3); within it, documentation and 6to4/Teredo (embed arbitrary IPv4) are refused
98
- if (!/^[23][0-9a-f]{0,3}:/.test(h)) return false;
99
- return !/^(2001:db8:|2001:0?db8:|2002:|2001:0{0,4}:|3fff:)/.test(h);
147
+ const bytes = ipv6Bytes(h);
148
+ if (!bytes || (bytes[0] & 0xe0) !== 0x20) return false;
149
+ if (V6_REACHABLE.some((b) => v6Prefix(bytes, b))) return true;
150
+ return !V6_UNREACHABLE.some((b) => v6Prefix(bytes, b));
100
151
  }
101
152
 
102
153
  export const ACCESS_TTL_S = 3600;
@@ -1,2 +1,2 @@
1
1
  /** Inbox build. Keep this equal to `version` in cli/package.json. */
2
- export const VERSION = "0.8.13";
2
+ export const VERSION = "0.8.14";
@@ -87,7 +87,7 @@ export async function lookupAddresses(host: string, signal?: AbortSignal): Promi
87
87
  /**
88
88
  * Why this URL must not be requested under public-https. Empty means it may be requested.
89
89
  * Names are resolved. Every returned address must be a public unicast address: private, loopback,
90
- * link-local (including metadata), CGNAT, multicast, and documentation ranges are refused.
90
+ * link-local (including metadata), CGNAT, multicast, benchmarking, Teredo, 6to4, and documentation ranges are refused.
91
91
  * A lookup that cannot be completed is refused.
92
92
  */
93
93
  export async function publicWakeProblem(url: string, lookup: AddressLookup = lookupAddresses): Promise<string> {
@@ -799,10 +799,16 @@ test("mcpLimitMessage names the A2A endpoint and the revoke command", () => {
799
799
  });
800
800
 
801
801
  test("isPublicIp: only global unicast addresses pass", () => {
802
- for (const ip of ["93.184.216.34", "1.1.1.1", "2606:2800:220:1::1", "[2a00:1450::1]"]) assert.equal(M.isPublicIp(ip), true, ip);
802
+ for (const ip of ["93.184.216.34", "1.1.1.1", "2606:2800:220:1::1", "[2a00:1450::1]",
803
+ "2000::1", "3ffe::1", "2620:4f:8000::1", "3fff:1000::1",
804
+ "2001:1::1", "2001:1::2", "2001:1::3", "2001:3::1", "2001:4:112::1", "2001:20::1", "2001:30::1"]) assert.equal(M.isPublicIp(ip), true, ip);
803
805
  for (const ip of ["10.1.2.3", "127.0.0.1", "172.16.0.1", "192.168.1.1", "169.254.169.254", "100.64.0.1", "0.0.0.0", "224.0.0.1", "255.255.255.255",
804
- "192.0.2.1", "198.18.0.1", "203.0.113.5", "300.1.1.1", "::1", "::", "fd00::1", "fe80::1", "::ffff:10.0.0.1", "::ffff:7f00:1",
805
- "64:ff9b::a00:1", "2001:db8::1", "2002:a00:1::", "2001:0:4136:e378::1", "ff02::1", "example.org"]) assert.equal(M.isPublicIp(ip), false, ip);
806
+ "192.0.2.1", "198.18.0.1", "203.0.113.5", "300.1.1.1", "::1", "::", "fd00::1", "fe80::1", "::ffff:10.0.0.1", "::ffff:7f00:1", "::ffff:8.8.8.8",
807
+ "64:ff9b::a00:1", "64:ff9b::8.8.8.8", "64:ff9b:1::8.8.8.8", "100::1", "5f00::1",
808
+ "2001:db8::1", "2001:DB8::1", "2001:0db8::1", "2002:a00:1::", "2002:0808:0808::", "2001:0:4136:e378::1", "2001::1",
809
+ "2001:2::1", "2001:0002::1", "2001:1::4", "2001:4:113::1", "2001:10::1",
810
+ "3fff::1", "3fff:0fff::1", "2::1", "20::1", "200::1",
811
+ "ff02::1", "example.org", "", "2001:::1", "1:2:3:4:5:6:7:8:9", "gggg::1"]) assert.equal(M.isPublicIp(ip), false, ip);
806
812
  });
807
813
 
808
814
  test("parseHttpResponse: status, headers, content-length and chunked bodies; malformed input refused", () => {
@@ -15,6 +15,7 @@ const publicLookup: AddressLookup = async (host) => {
15
15
  if (host === "v6.example") return ["2606:4700:4700::1111"];
16
16
  if (host === "split.example") return ["1.2.3.4", "10.0.0.1"];
17
17
  if (host === "meta.example") return ["169.254.169.254"];
18
+ if (host === "bench.example") return ["2001:2::1"];
18
19
  if (host === "missing.example") return [];
19
20
  return null;
20
21
  };
@@ -32,6 +33,9 @@ test("public-https refuses private, metadata, and non-https URLs before any requ
32
33
  "https://169.254.169.254/latest/meta-data",
33
34
  "https://[::1]/wake",
34
35
  "https://[fe80::1]/wake",
36
+ "https://[2001:2::1]/wake",
37
+ "https://[2001::1]/wake",
38
+ "https://[3fff::1]/wake",
35
39
  "https://localhost/wake",
36
40
  "https://metadata.google.internal/wake",
37
41
  "https://agent.ts.net/wake",
@@ -42,10 +46,14 @@ test("public-https refuses private, metadata, and non-https URLs before any requ
42
46
  const why = await publicWakeProblem(url, publicLookup);
43
47
  assert.notEqual(why, "", url);
44
48
  }
49
+ assert.equal(await publicWakeProblem("https://[2001:2::1]/wake", publicLookup), "private or reserved address");
50
+ assert.equal(await publicWakeProblem("https://[2001::1]/wake", publicLookup), "private or reserved address");
51
+ assert.equal(await publicWakeProblem("https://[3fff::1]/wake", publicLookup), "private or reserved address");
45
52
  assert.equal(await publicWakeProblem("https://hook.example/wake", publicLookup), "");
46
53
  assert.equal(await publicWakeProblem("https://1.2.3.4/wake", publicLookup), "");
47
54
  assert.equal(await publicWakeProblem("https://split.example/wake", publicLookup), "resolves to a private or reserved address");
48
55
  assert.equal(await publicWakeProblem("https://meta.example/wake", publicLookup), "resolves to a private or reserved address");
56
+ assert.equal(await publicWakeProblem("https://bench.example/wake", publicLookup), "resolves to a private or reserved address");
49
57
  assert.equal(await publicWakeProblem("https://missing.example/wake", publicLookup), "the host has no address");
50
58
  assert.equal(await publicWakeProblem("https://unknown.example/wake", publicLookup), "its address could not be checked");
51
59
  });