a2a-exposed 0.7.11 → 0.7.13

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.
@@ -2,13 +2,17 @@
2
2
  // Unset keeps today's fetch. Run: npm test
3
3
  import { test } from "node:test";
4
4
  import assert from "node:assert/strict";
5
+ import net from "node:net";
6
+ import { Duplex } from "node:stream";
5
7
  import worker from "../src/index.ts";
6
8
  import { d1 } from "./d1.ts";
7
9
  import * as M from "../src/mcp.ts";
8
- import { fetchCheckedWake, publicWakeProblem, type AddressLookup, type WakeDeps } from "../src/wake-target.ts";
10
+ import { dialChecked, fetchCheckedWake, publicWakeProblem, wakeNet, type AddressLookup, type CheckedDeliver, type WakeConnect } from "../src/wake-target.ts";
9
11
 
10
12
  const publicLookup: AddressLookup = async (host) => {
11
13
  if (host === "hook.example" || host === "also.example") return ["1.2.3.4"];
14
+ if (host === "round.example") return ["1.2.3.4", "1.2.3.5"];
15
+ if (host === "v6.example") return ["2606:4700:4700::1111"];
12
16
  if (host === "split.example") return ["1.2.3.4", "10.0.0.1"];
13
17
  if (host === "meta.example") return ["169.254.169.254"];
14
18
  if (host === "missing.example") return [];
@@ -46,65 +50,155 @@ test("public-https refuses private, metadata, and non-https URLs before any requ
46
50
  assert.equal(await publicWakeProblem("https://unknown.example/wake", publicLookup), "its address could not be checked");
47
51
  });
48
52
 
49
- function pinned(answer: (request: string) => { status: number; location?: string }) {
50
- const calls: { ip: string; sni: string; request: string }[] = [];
51
- const connect: NonNullable<WakeDeps["connect"]> = (addr) => ({
52
- startTls: ({ expectedServerHostname: sni }) => {
53
- let req = "";
54
- let respond!: (bytes: Uint8Array) => void;
55
- const ready = new Promise<Uint8Array>((resolve) => { respond = resolve; });
56
- const writable = new WritableStream<Uint8Array>({
57
- write(chunk) {
58
- req += new TextDecoder().decode(chunk);
59
- if (!req.includes("\r\n\r\n")) return;
60
- calls.push({ ip: addr.hostname, sni, request: req });
61
- const made = answer(req);
62
- const headers = [`HTTP/1.1 ${made.status} X`, "content-length: 0"];
63
- if (made.location) headers.push(`location: ${made.location}`);
64
- headers.push("connection: close", "", "");
65
- respond(new TextEncoder().encode(headers.join("\r\n")));
66
- },
67
- });
68
- const readable = new ReadableStream<Uint8Array>({
69
- async start(controller) {
70
- controller.enqueue(await ready);
71
- controller.close();
72
- },
73
- });
74
- return { readable, writable, close: async () => {} };
75
- },
76
- });
77
- return { connect, calls };
53
+ function recorded(answer: (call: { url: string }) => { status: number; location?: string }) {
54
+ const calls: { url: string; ip: string; hostname: string; authorization: string; body: string; redirect: string }[] = [];
55
+ const deliver: CheckedDeliver = async (url, init, target) => {
56
+ const headers = new Headers(init.headers);
57
+ const call = {
58
+ url,
59
+ ip: target.ip,
60
+ hostname: target.hostname,
61
+ authorization: headers.get("authorization") ?? "",
62
+ body: typeof init.body === "string" ? init.body : init.body instanceof Uint8Array ? new TextDecoder().decode(init.body) : "",
63
+ redirect: init.redirect ?? "",
64
+ };
65
+ calls.push(call);
66
+ const made = answer(call);
67
+ return new Response(null, { status: made.status, headers: made.location ? { location: made.location } : {} });
68
+ };
69
+ return { deliver, calls };
70
+ }
71
+
72
+ test("the dial uses the checked address and leaves TLS to name the host", () => {
73
+ const seen: { hostname: string; secure?: string }[] = [];
74
+ const base: WakeConnect = (addr, options) => {
75
+ seen.push({ hostname: addr.hostname, secure: options?.secureTransport });
76
+ return { readable: new ReadableStream(), writable: new WritableStream(), async close() {} };
77
+ };
78
+ dialChecked("1.2.3.4", base)({ hostname: "hook.example", port: 443 }, { secureTransport: "on" });
79
+ assert.deepEqual(seen, [{ hostname: "1.2.3.4", secure: "off" }]);
80
+ });
81
+
82
+ /** Server names from a TLS ClientHello. Empty when the bytes are not one. */
83
+ function clientHelloNames(buf: Buffer): string[] {
84
+ if (buf.length < 5 || buf[0] !== 0x16) return [];
85
+ const recordLen = buf.readUInt16BE(3);
86
+ if (buf.length < 5 + recordLen) return [];
87
+ const hs = buf.subarray(5, 5 + recordLen);
88
+ if (hs.length < 4 || hs[0] !== 1) return [];
89
+ let i = 4 + 2 + 32;
90
+ if (i >= hs.length) return [];
91
+ const sidLen = hs[i]!;
92
+ i += 1 + sidLen;
93
+ if (i + 2 > hs.length) return [];
94
+ const csLen = hs.readUInt16BE(i);
95
+ i += 2 + csLen;
96
+ if (i >= hs.length) return [];
97
+ const compLen = hs[i]!;
98
+ i += 1 + compLen;
99
+ if (i + 2 > hs.length) return [];
100
+ const extEnd = i + 2 + hs.readUInt16BE(i);
101
+ i += 2;
102
+ const names: string[] = [];
103
+ while (i + 4 <= extEnd && i + 4 <= hs.length) {
104
+ const type = hs.readUInt16BE(i);
105
+ const len = hs.readUInt16BE(i + 2);
106
+ i += 4;
107
+ if (i + len > hs.length) break;
108
+ if (type === 0 && len >= 2) {
109
+ const list = hs.subarray(i, i + len);
110
+ let j = 2;
111
+ while (j + 3 <= list.length) {
112
+ const nameType = list[j]!;
113
+ const nlen = list.readUInt16BE(j + 1);
114
+ j += 3;
115
+ if (j + nlen > list.length) break;
116
+ if (nameType === 0) names.push(list.subarray(j, j + nlen).toString());
117
+ j += nlen;
118
+ }
119
+ }
120
+ i += len;
121
+ }
122
+ return names;
78
123
  }
79
124
 
80
- test("a redirect to a private address is not connected, and the first hop uses the checked IP", async () => {
81
- const socket = pinned(() => ({ status: 302, location: "http://169.254.169.254/latest/meta-data" }));
82
- const result = await fetchCheckedWake("https://hook.example/wake", { method: "POST", headers: { authorization: "Bearer secret" }, body: "{}" }, { lookup: publicLookup, connect: socket.connect });
83
- assert.equal(socket.calls.length, 1);
84
- assert.equal(socket.calls[0].ip, "1.2.3.4");
85
- assert.equal(socket.calls[0].sni, "hook.example");
86
- assert.match(socket.calls[0].request, /^POST \/wake HTTP\/1\.1/);
87
- assert.match(socket.calls[0].request, /authorization: Bearer secret/i);
125
+ test("delivery sends the hostname in the ClientHello on a socket to the checked address", async () => {
126
+ const chunks: Buffer[] = [];
127
+ const server = net.createServer((socket) => {
128
+ socket.on("data", (data) => {
129
+ chunks.push(data);
130
+ const hello = Buffer.concat(chunks);
131
+ if (hello.length >= 5 && hello[0] === 0x16 && hello.length >= 5 + hello.readUInt16BE(3)) socket.destroy();
132
+ });
133
+ });
134
+ await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
135
+ const address = server.address();
136
+ if (!address || typeof address === "string") throw new Error("no port");
137
+ const dialed: { hostname: string; secure?: string }[] = [];
138
+ const connect: WakeConnect = (addr, options) => {
139
+ dialed.push({ hostname: addr.hostname, secure: options?.secureTransport });
140
+ const socket = net.connect({ host: "127.0.0.1", port: address.port });
141
+ const web = Duplex.toWeb(socket);
142
+ return {
143
+ readable: web.readable,
144
+ writable: web.writable,
145
+ opened: new Promise((resolve, reject) => {
146
+ socket.once("connect", () => resolve({ remoteAddress: "127.0.0.1" }));
147
+ socket.once("error", reject);
148
+ }),
149
+ async close() { socket.destroy(); },
150
+ };
151
+ };
152
+ try {
153
+ await assert.rejects(
154
+ () => fetchCheckedWake("https://hook.example/wake", {
155
+ method: "POST",
156
+ headers: { authorization: "Bearer secret" },
157
+ body: "{}",
158
+ }, { lookup: publicLookup, connect }),
159
+ (err: unknown) => err instanceof Error && err.message === "TLS Handshake Failed.",
160
+ );
161
+ } finally {
162
+ server.close();
163
+ }
164
+ const wire = Buffer.concat(chunks);
165
+ assert.deepEqual(dialed, [{ hostname: "1.2.3.4", secure: "off" }]);
166
+ assert.deepEqual(clientHelloNames(wire), ["hook.example"]);
167
+ assert.equal(wire.includes(Buffer.from("POST /wake")), false);
168
+ assert.equal(wire.includes(Buffer.from("secret")), false);
169
+ });
170
+
171
+ test("a redirect to a private address is not requested, and the first hop is pinned to the checked address", async () => {
172
+ const hop = recorded(() => ({ status: 302, location: "http://169.254.169.254/latest/meta-data" }));
173
+ const result = await fetchCheckedWake("https://hook.example/wake", { method: "POST", headers: { authorization: "Bearer secret" }, body: "{}" }, { lookup: publicLookup, deliver: hop.deliver });
174
+ assert.equal(hop.calls.length, 1);
175
+ assert.equal(hop.calls[0].url, "https://hook.example/wake");
176
+ assert.equal(hop.calls[0].ip, "1.2.3.4");
177
+ assert.equal(hop.calls[0].hostname, "hook.example");
178
+ assert.equal(hop.calls[0].redirect, "manual");
179
+ assert.equal(hop.calls[0].authorization, "Bearer secret");
88
180
  assert.equal("refused" in result && result.refused, "must be https");
89
181
  });
90
182
 
91
183
  test("a cross-origin redirect is not followed, so credentials stay on the first host", async () => {
92
- const socket = pinned(() => ({ status: 307, location: "https://also.example/wake" }));
93
- const result = await fetchCheckedWake("https://hook.example/wake", { method: "POST", headers: { authorization: "Bearer secret" }, body: "{}" }, { lookup: publicLookup, connect: socket.connect });
94
- assert.equal(socket.calls.length, 1);
95
- assert.equal(socket.calls[0].ip, "1.2.3.4");
184
+ const hop = recorded(() => ({ status: 307, location: "https://also.example/wake" }));
185
+ const result = await fetchCheckedWake("https://hook.example/wake", { method: "POST", headers: { authorization: "Bearer secret" }, body: "{}" }, { lookup: publicLookup, deliver: hop.deliver });
186
+ assert.equal(hop.calls.length, 1);
187
+ assert.equal(hop.calls[0].ip, "1.2.3.4");
96
188
  assert.equal("refused" in result && result.refused, "cross-origin redirect");
97
189
  });
98
190
 
99
- test("a same-origin redirect is pinned again and keeps the wake headers", async () => {
100
- const socket = pinned((request) => request.startsWith("POST /wake ")
191
+ test("a same-origin redirect is checked again and keeps the wake headers", async () => {
192
+ const hop = recorded((call) => call.url.endsWith("/wake")
101
193
  ? { status: 307, location: "https://hook.example/next" }
102
194
  : { status: 200 });
103
- const result = await fetchCheckedWake("https://hook.example/wake", { method: "POST", headers: { authorization: "Bearer secret" }, body: "{}" }, { lookup: publicLookup, connect: socket.connect });
104
- assert.equal(socket.calls.length, 2);
105
- assert.deepEqual(socket.calls.map((call) => call.ip), ["1.2.3.4", "1.2.3.4"]);
106
- assert.match(socket.calls[1].request, /^POST \/next HTTP\/1\.1/);
107
- assert.match(socket.calls[1].request, /authorization: Bearer secret/i);
195
+ const result = await fetchCheckedWake("https://hook.example/wake", { method: "POST", headers: { authorization: "Bearer secret" }, body: "{}" }, { lookup: publicLookup, deliver: hop.deliver });
196
+ assert.deepEqual(hop.calls.map((call) => [call.url, call.ip]), [
197
+ ["https://hook.example/wake", "1.2.3.4"],
198
+ ["https://hook.example/next", "1.2.3.4"],
199
+ ]);
200
+ assert.equal(hop.calls[1].authorization, "Bearer secret");
201
+ assert.equal(hop.calls[1].body, "{}");
108
202
  assert.equal("response" in result && result.response.status, 200);
109
203
  });
110
204
 
@@ -168,20 +262,33 @@ test("public-https does not request a metadata address, and does post to a check
168
262
  assert.equal(requested.some((url) => url.includes("169.254.169.254")), false);
169
263
 
170
264
  requested.length = 0;
171
- const socket = pinned(() => ({ status: 200 }));
172
- M.net.connect = socket.connect;
265
+ const hop = recorded(() => ({ status: 200 }));
266
+ // The runtime startTls pin sends no SNI. Delivery must dial the checked IP and name the host itself.
267
+ M.net.connect = () => { throw new Error("wake delivery must not use startTls on the resolved IP"); };
268
+ wakeNet.deliver = hop.deliver;
173
269
  try {
174
270
  const allowed = await wakeTest({
175
271
  DB: db(), PUBLIC_URL: BASE, OWNER_TOKEN: "owner-secret", WAKE_PRESET: "generic",
176
272
  WAKE_WEBHOOK_URL: "https://hook.example/wake", WAKE_WEBHOOK_KEY: "hook-key", WAKE_TARGET_POLICY: "public-https",
177
273
  }, fetchImpl);
178
274
  assert.equal(allowed.status, 200);
179
- assert.equal(requested.every((url) => url.startsWith("https://cloudflare-dns.com/")), true);
180
- assert.equal(socket.calls.length, 1);
181
- assert.equal(socket.calls[0].ip, "1.2.3.4");
182
- assert.equal(socket.calls[0].sni, "hook.example");
183
- assert.match(socket.calls[0].request, /authorization: Bearer hook-key/i);
275
+ assert.equal(hop.calls.length, 1);
276
+ assert.equal(hop.calls[0].ip, "1.2.3.4");
277
+ assert.equal(hop.calls[0].hostname, "hook.example");
278
+ assert.equal(hop.calls[0].authorization, "Bearer hook-key");
279
+ assert.equal(requested.some((url) => new URL(url).hostname === "hook.example"), false);
280
+ assert.equal(requested.some((url) => url.startsWith("https://cloudflare-dns.com/dns-query")), true);
184
281
  } finally {
185
282
  M.net.connect = null;
283
+ wakeNet.deliver = null;
186
284
  }
285
+
286
+ wakeNet.deliver = async () => { throw new Error("TLS Handshake Failed."); };
287
+ const tls = await wakeTest({
288
+ DB: db(), PUBLIC_URL: BASE, OWNER_TOKEN: "owner-secret", WAKE_PRESET: "generic",
289
+ WAKE_WEBHOOK_URL: "https://hook.example/wake", WAKE_WEBHOOK_KEY: "hook-key", WAKE_TARGET_POLICY: "public-https",
290
+ }, fetchImpl);
291
+ wakeNet.deliver = null;
292
+ assert.equal(tls.status, null);
293
+ assert.equal(tls.info, "Error: TLS Handshake Failed.");
187
294
  });
@@ -116,6 +116,23 @@ test("generic custom key header is rendered with prefix and redacted in previews
116
116
  assert.ok(!JSON.stringify(red).includes("supersecretvalue"));
117
117
  });
118
118
 
119
+ test("a key header the preview shows in full is not used for the key", async () => {
120
+ const req = (await renderWake({ preset: "generic", url: "https://hooks.example.com/abc", key: "supersecretvalue", keyHeader: "Content-Type", keyPrefix: "" }, ev))!;
121
+ assert.equal(req.headers["authorization"], "supersecretvalue");
122
+ assert.equal(req.headers["content-type"], "application/json");
123
+ assert.equal(redact(req).headers["authorization"], "••••");
124
+ assert.equal(JSON.stringify(redact(req)).includes("supe"), false);
125
+ });
126
+
127
+ test("generic key header is redacted even when its name is not a secret word", async () => {
128
+ const req = (await renderWake({ preset: "generic", url: "https://hooks.example.com/abc", key: "supersecretvalue", keyHeader: "X-Credential", keyPrefix: "" }, ev))!;
129
+ assert.equal(req.headers["x-credential"], "supersecretvalue");
130
+ const red = redact(req);
131
+ assert.equal(red.headers["x-credential"], "••••");
132
+ assert.equal(red.headers["content-type"], "application/json");
133
+ assert.ok(!JSON.stringify(red).includes("supersecretvalue"));
134
+ });
135
+
119
136
  // ---------------------------------------------------------------- Cloudflare Access service token (tunnel wakes)
120
137
  const ACCESS = { accessClientId: "abc123.access", accessClientSecret: "s3cr3t-value-0123456789" };
121
138
 
@@ -144,8 +161,9 @@ test("hermes HMAC is computed over the body only, unaffected by Access headers",
144
161
 
145
162
  test("redact masks both Access headers", async () => {
146
163
  const r = redact((await renderWake({ preset: "openclaw-wake", url: "https://wake-x.example.com/hooks/wake", key: "hooks-token", ...ACCESS }, ev))!);
147
- assert.equal(r.headers["CF-Access-Client-Id"], "abc1…");
148
- assert.equal(r.headers["CF-Access-Client-Secret"], "s3cr…");
164
+ assert.equal(r.headers["CF-Access-Client-Id"], "••••");
165
+ assert.equal(r.headers["CF-Access-Client-Secret"], "••••");
166
+ assert.equal(r.headers["authorization"], "Bearer ••••");
149
167
  assert.ok(!JSON.stringify(r).includes("s3cr3t-value"));
150
168
  });
151
169