a2a-exposed 0.7.12 → 0.7.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/lib/main.mjs +16 -4
- package/package.json +1 -1
- package/worker/cloudflare.config.ts +1 -1
- package/worker/package.json +3 -0
- package/worker/src/export.ts +67 -2
- package/worker/src/index.ts +87 -12
- package/worker/src/owner-rpc.ts +53 -5
- package/worker/src/wake-target.ts +124 -70
- package/worker/src/wake.ts +15 -2
- package/worker/test/alarms.test.ts +17 -0
- package/worker/test/export.test.ts +117 -1
- package/worker/test/hosted.test.ts +143 -4
- package/worker/test/owner-rpc.test.ts +46 -4
- package/worker/test/pairing.test.ts +75 -2
- package/worker/test/wake-target.test.ts +163 -56
- package/worker/test/wake.test.ts +6 -4
|
@@ -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
|
|
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
|
|
50
|
-
const calls: { ip: string;
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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("
|
|
81
|
-
const
|
|
82
|
-
const
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
|
93
|
-
const result = await fetchCheckedWake("https://hook.example/wake", { method: "POST", headers: { authorization: "Bearer secret" }, body: "{}" }, { lookup: publicLookup,
|
|
94
|
-
assert.equal(
|
|
95
|
-
assert.equal(
|
|
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
|
|
100
|
-
const
|
|
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,
|
|
104
|
-
assert.
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
|
172
|
-
|
|
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(
|
|
180
|
-
assert.equal(
|
|
181
|
-
assert.equal(
|
|
182
|
-
assert.equal(
|
|
183
|
-
assert.
|
|
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
|
});
|
package/worker/test/wake.test.ts
CHANGED
|
@@ -120,14 +120,15 @@ test("a key header the preview shows in full is not used for the key", async ()
|
|
|
120
120
|
const req = (await renderWake({ preset: "generic", url: "https://hooks.example.com/abc", key: "supersecretvalue", keyHeader: "Content-Type", keyPrefix: "" }, ev))!;
|
|
121
121
|
assert.equal(req.headers["authorization"], "supersecretvalue");
|
|
122
122
|
assert.equal(req.headers["content-type"], "application/json");
|
|
123
|
-
assert.equal(redact(req).headers["authorization"], "
|
|
123
|
+
assert.equal(redact(req).headers["authorization"], "••••");
|
|
124
|
+
assert.equal(JSON.stringify(redact(req)).includes("supe"), false);
|
|
124
125
|
});
|
|
125
126
|
|
|
126
127
|
test("generic key header is redacted even when its name is not a secret word", async () => {
|
|
127
128
|
const req = (await renderWake({ preset: "generic", url: "https://hooks.example.com/abc", key: "supersecretvalue", keyHeader: "X-Credential", keyPrefix: "" }, ev))!;
|
|
128
129
|
assert.equal(req.headers["x-credential"], "supersecretvalue");
|
|
129
130
|
const red = redact(req);
|
|
130
|
-
assert.equal(red.headers["x-credential"], "
|
|
131
|
+
assert.equal(red.headers["x-credential"], "••••");
|
|
131
132
|
assert.equal(red.headers["content-type"], "application/json");
|
|
132
133
|
assert.ok(!JSON.stringify(red).includes("supersecretvalue"));
|
|
133
134
|
});
|
|
@@ -160,8 +161,9 @@ test("hermes HMAC is computed over the body only, unaffected by Access headers",
|
|
|
160
161
|
|
|
161
162
|
test("redact masks both Access headers", async () => {
|
|
162
163
|
const r = redact((await renderWake({ preset: "openclaw-wake", url: "https://wake-x.example.com/hooks/wake", key: "hooks-token", ...ACCESS }, ev))!);
|
|
163
|
-
assert.equal(r.headers["CF-Access-Client-Id"], "
|
|
164
|
-
assert.equal(r.headers["CF-Access-Client-Secret"], "
|
|
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 ••••");
|
|
165
167
|
assert.ok(!JSON.stringify(r).includes("s3cr3t-value"));
|
|
166
168
|
});
|
|
167
169
|
|