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
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
|
-
/**
|
|
84
|
-
|
|
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(":")
|
|
145
|
+
if (!h.includes(":")) return false;
|
|
96
146
|
if (isPrivateHost(h)) return false;
|
|
97
|
-
|
|
98
|
-
if (
|
|
99
|
-
|
|
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;
|
package/worker/src/version.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** Inbox build. Keep this equal to `version` in cli/package.json. */
|
|
2
|
-
export const VERSION = "0.8.
|
|
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> {
|
package/worker/test/mcp.test.ts
CHANGED
|
@@ -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]"
|
|
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", "
|
|
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
|
});
|