a2a-exposed 0.6.0 → 0.7.1
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
|
@@ -48,7 +48,8 @@ const plain: Record<string, string> = {
|
|
|
48
48
|
UPSTREAM_CARD_URL: v("A2A_UPSTREAM_CARD_URL"), // default <upstream origin>/.well-known/agent-card.json
|
|
49
49
|
// Hosted multi-tenant gates. All empty by default, and empty is omitted, so a self-host deploy is unchanged.
|
|
50
50
|
// TENANCY=host switches the entrypoint to src/hosted.ts and adds TENANT_DO + TENANT_DIRECTORY below.
|
|
51
|
-
// QUOTAS, USAGE_SINK,
|
|
51
|
+
// QUOTAS, USAGE_SINK, SIGNUP_URL and BRANDING are parsed and reserved.
|
|
52
|
+
// WAKE_TARGET_POLICY=public-https refuses a wake that is not public https (see wake-target.ts).
|
|
52
53
|
// APPROVAL_OIDC_* turn on OpenID Connect approval when issuer, client id, secret and allowlist are all set.
|
|
53
54
|
TENANCY: v("TENANCY"),
|
|
54
55
|
TENANT_DOMAIN: v("TENANT_DOMAIN"),
|
|
@@ -35,7 +35,11 @@ WAKE_PRESET=generic
|
|
|
35
35
|
# Optional multi-tenant gates. Leave them unset for a self-hosted inbox (`npx -y a2a-exposed@latest init`
|
|
36
36
|
# never sets them). Unset means today's single-tenant Worker on D1, and the build does not export a Durable Object.
|
|
37
37
|
# TENANCY=host switches the entry to the multi-tenant router and adds the TENANT_DO and TENANT_DIRECTORY bindings.
|
|
38
|
-
#
|
|
38
|
+
# QUOTAS, USAGE_SINK, SIGNUP_URL and BRANDING are reserved (parsed, not enforced yet).
|
|
39
|
+
# WAKE_TARGET_POLICY=public-https is enforced: a wake must be https to a public address. The Worker
|
|
40
|
+
# connects to an address from that check. A redirect is followed only on the same origin. Leave it
|
|
41
|
+
# unset for a self-hosted inbox.
|
|
42
|
+
# Do not set TENANCY for a self-hosted inbox.
|
|
39
43
|
# TENANCY=
|
|
40
44
|
# TENANT_DOMAIN=example.com
|
|
41
45
|
# DATA_REGION=eu
|
package/worker/src/index.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import * as A from "./a2a.ts";
|
|
8
8
|
import * as F from "./facade.ts";
|
|
9
9
|
import { renderWake, redact, cloudflareErrorHint, defaultDebounceSeconds, defaultMaxPerHour, type WakeConfig, type WakeEvent } from "./wake.ts";
|
|
10
|
+
import { fetchCheckedWake } from "./wake-target.ts";
|
|
10
11
|
import * as P from "./pairing.ts";
|
|
11
12
|
import * as M from "./mcp.ts";
|
|
12
13
|
import { resolveTenant, type TenantContext, type WorkerBindings } from "./tenancy.ts";
|
|
@@ -114,7 +115,17 @@ async function sendWake(ctx: TenantContext, ectx: ExecutionContext, payload: Jso
|
|
|
114
115
|
}
|
|
115
116
|
if (!req) { log("wake_skipped", { contextId: ev.contextId, reason: "WAKE_WEBHOOK_URL unset" }); return { status: null, info: "WAKE_WEBHOOK_URL unset" }; }
|
|
116
117
|
try {
|
|
117
|
-
|
|
118
|
+
let r: Response;
|
|
119
|
+
if (ctx.gates.wakeTargetPolicy === "public-https") {
|
|
120
|
+
const checked = await fetchCheckedWake(req.url, { method: "POST", headers: req.headers, body: req.body });
|
|
121
|
+
if ("refused" in checked) {
|
|
122
|
+
log("wake_refused", { preset: preset(ctx), contextId: ev.contextId, reason: checked.refused });
|
|
123
|
+
return { status: null, info: `refused: ${checked.refused}` };
|
|
124
|
+
}
|
|
125
|
+
r = checked.response;
|
|
126
|
+
} else {
|
|
127
|
+
r = await doFetch(ctx, ectx, req.url, { method: "POST", headers: req.headers, body: req.body });
|
|
128
|
+
}
|
|
118
129
|
// explain Cloudflare edge errors (tunnel connector down, Access block) without echoing the body
|
|
119
130
|
const hint = r.ok ? "" : cloudflareErrorHint(r.status, (await r.text().catch(() => "")).slice(0, 4096));
|
|
120
131
|
log("wake_sent", { preset: preset(ctx), contextId: ev.contextId, taskId: ev.taskId, status: r.status, ...(hint ? { hint } : {}) });
|
package/worker/src/tenancy.ts
CHANGED
|
@@ -84,7 +84,7 @@ export interface WorkerBindings extends ConfigFields {
|
|
|
84
84
|
QUOTAS?: string;
|
|
85
85
|
/** "d1" reserves usage metering (not applied yet). */
|
|
86
86
|
USAGE_SINK?: string;
|
|
87
|
-
/** "public-https"
|
|
87
|
+
/** "public-https" refuses non-https, private, and metadata wake targets. Anything else is unset. */
|
|
88
88
|
WAKE_TARGET_POLICY?: string;
|
|
89
89
|
/** https URL shown later on the landing page and over-quota errors. Parsed, not rendered yet. */
|
|
90
90
|
SIGNUP_URL?: string;
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
// Wake URL checks for WAKE_TARGET_POLICY=public-https. Unset stays the previous fetch
|
|
2
|
+
// (any URL the operator configured, redirects followed by the platform).
|
|
3
|
+
import { isPrivateHost } from "./a2a.ts";
|
|
4
|
+
import { isPublicIp, net, parseHttpResponse, type PinnedSocket } from "./mcp.ts";
|
|
5
|
+
|
|
6
|
+
/** DNS-over-HTTPS resolver. Same endpoint the client-metadata fetch uses. */
|
|
7
|
+
export const DOH_URL = "https://cloudflare-dns.com/dns-query";
|
|
8
|
+
|
|
9
|
+
/** Original request plus this many redirects. Each hop is checked before it is requested. */
|
|
10
|
+
const MAX_REDIRECTS = 3;
|
|
11
|
+
|
|
12
|
+
/** Host to addresses. `null` means the lookup failed and the caller must fail closed. */
|
|
13
|
+
export type AddressLookup = (host: string) => Promise<string[] | null>;
|
|
14
|
+
|
|
15
|
+
type Connect = NonNullable<typeof net.connect>;
|
|
16
|
+
|
|
17
|
+
export type WakeDeps = {
|
|
18
|
+
lookup?: AddressLookup;
|
|
19
|
+
connect?: Connect;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
function bareHost(host: string): string {
|
|
23
|
+
return host.toLowerCase().replace(/^\[|\]$/g, "").replace(/\.$/, "");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** An address literal (IPv4, or IPv6 with or without brackets). Names go through DNS. */
|
|
27
|
+
export function isIpLiteral(host: string): boolean {
|
|
28
|
+
const h = bareHost(host);
|
|
29
|
+
if (/^(\d{1,3}\.){3}\d{1,3}$/.test(h)) return true;
|
|
30
|
+
return h.includes(":") && /^[0-9a-f:.]+$/.test(h);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* A and AAAA for `host`, CNAMEs followed by the resolver. `null` on any DNS or transport failure.
|
|
35
|
+
* An empty list is a real answer with no address (NXDOMAIN or no A/AAAA).
|
|
36
|
+
*/
|
|
37
|
+
export async function lookupAddresses(host: string, signal?: AbortSignal): Promise<string[] | null> {
|
|
38
|
+
const ips: string[] = [];
|
|
39
|
+
for (const type of ["A", "AAAA"]) {
|
|
40
|
+
let body: { Status?: number; Answer?: { type?: number; data?: string }[] };
|
|
41
|
+
try {
|
|
42
|
+
const res = await fetch(`${DOH_URL}?name=${encodeURIComponent(host)}&type=${type}`, {
|
|
43
|
+
headers: { accept: "application/dns-json" },
|
|
44
|
+
redirect: "manual",
|
|
45
|
+
signal,
|
|
46
|
+
});
|
|
47
|
+
if (!res.ok) return null;
|
|
48
|
+
body = await res.json();
|
|
49
|
+
} catch {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
// Status 3 is NXDOMAIN. AAAA is often NXDOMAIN when A exists; that is not a failure.
|
|
53
|
+
if (body?.Status !== 0 && !(body?.Status === 3 && type === "AAAA")) return null;
|
|
54
|
+
for (const answer of Array.isArray(body.Answer) ? body.Answer : []) {
|
|
55
|
+
if (answer?.type === 1 || answer?.type === 28) ips.push(String(answer.data));
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return ips;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Why this URL must not be requested under public-https. Empty means it may be requested.
|
|
63
|
+
* Names are resolved. Every returned address must be a public unicast address: private, loopback,
|
|
64
|
+
* link-local (including metadata), CGNAT, multicast, and documentation ranges are refused.
|
|
65
|
+
* A lookup that cannot be completed is refused.
|
|
66
|
+
*/
|
|
67
|
+
export async function publicWakeProblem(url: string, lookup: AddressLookup = lookupAddresses): Promise<string> {
|
|
68
|
+
const checked = await checkedWakeAddress(url, lookup);
|
|
69
|
+
return "refused" in checked ? checked.refused : "";
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The address the next request must use. A name is resolved once; the returned IP is from that answer. */
|
|
73
|
+
export async function checkedWakeAddress(url: string, lookup: AddressLookup = lookupAddresses): Promise<{ ip: string } | { refused: string }> {
|
|
74
|
+
let parsed: URL;
|
|
75
|
+
try { parsed = new URL(url); } catch { return { refused: "not a URL" }; }
|
|
76
|
+
if (parsed.protocol !== "https:") return { refused: "must be https" };
|
|
77
|
+
if (parsed.username || parsed.password) return { refused: "must not contain credentials" };
|
|
78
|
+
const host = parsed.hostname;
|
|
79
|
+
if (!host || isPrivateHost(host)) return { refused: "private or internal host" };
|
|
80
|
+
const bare = bareHost(host);
|
|
81
|
+
if (isIpLiteral(host)) return isPublicIp(bare) ? { ip: bare } : { refused: "private or reserved address" };
|
|
82
|
+
let ips: string[] | null;
|
|
83
|
+
try { ips = await lookup(host); } catch { return { refused: "its address could not be checked" }; }
|
|
84
|
+
if (ips == null) return { refused: "its address could not be checked" };
|
|
85
|
+
if (!ips.length) return { refused: "the host has no address" };
|
|
86
|
+
if (!ips.every((ip) => isPublicIp(ip))) return { refused: "resolves to a private or reserved address" };
|
|
87
|
+
return { ip: ips[0] };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function headerEntries(headers: HeadersInit | undefined): [string, string][] {
|
|
91
|
+
const out: [string, string][] = [];
|
|
92
|
+
const list = new Headers(headers);
|
|
93
|
+
list.forEach((value, key) => out.push([key, value]));
|
|
94
|
+
return out;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function requestBytes(method: string, url: URL, headers: HeadersInit | undefined, body: Uint8Array): Uint8Array {
|
|
98
|
+
const lines = [`${method} ${url.pathname}${url.search} HTTP/1.1`, `host: ${url.host}`];
|
|
99
|
+
for (const [key, value] of headerEntries(headers)) {
|
|
100
|
+
if (key === "host" || key === "content-length" || key === "connection" || key === "transfer-encoding") continue;
|
|
101
|
+
if (/[\r\n]/.test(key) || /[\r\n]/.test(value)) throw new Error("invalid header");
|
|
102
|
+
lines.push(`${key}: ${value}`);
|
|
103
|
+
}
|
|
104
|
+
if (body.byteLength) lines.push(`content-length: ${body.byteLength}`);
|
|
105
|
+
lines.push("connection: close");
|
|
106
|
+
const head = new TextEncoder().encode(lines.join("\r\n") + "\r\n\r\n");
|
|
107
|
+
const raw = new Uint8Array(head.byteLength + body.byteLength);
|
|
108
|
+
raw.set(head);
|
|
109
|
+
raw.set(body, head.byteLength);
|
|
110
|
+
return raw;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function readSocket(sock: PinnedSocket, cap: number): Promise<Uint8Array> {
|
|
114
|
+
const reader = sock.readable.getReader();
|
|
115
|
+
const chunks: Uint8Array[] = [];
|
|
116
|
+
let n = 0;
|
|
117
|
+
for (;;) {
|
|
118
|
+
const { done, value } = await reader.read();
|
|
119
|
+
if (done) break;
|
|
120
|
+
n += value.byteLength;
|
|
121
|
+
if (n > cap) break;
|
|
122
|
+
chunks.push(value);
|
|
123
|
+
}
|
|
124
|
+
const raw = new Uint8Array(Math.min(n, cap));
|
|
125
|
+
let o = 0;
|
|
126
|
+
for (const chunk of chunks) {
|
|
127
|
+
const take = Math.min(chunk.byteLength, raw.byteLength - o);
|
|
128
|
+
raw.set(chunk.subarray(0, take), o);
|
|
129
|
+
o += take;
|
|
130
|
+
}
|
|
131
|
+
return raw;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* HTTPS to `ip`, with TLS checked for the URL's hostname. The platform does not resolve the name again.
|
|
136
|
+
*/
|
|
137
|
+
async function pinnedRequest(ip: string, url: string, method: string, headers: HeadersInit | undefined, body: Uint8Array, connect: Connect): Promise<Response> {
|
|
138
|
+
const parsed = new URL(url);
|
|
139
|
+
const sock = connect({ hostname: ip, port: Number(parsed.port || 443) }, { secureTransport: "starttls" }).startTls({ expectedServerHostname: parsed.hostname });
|
|
140
|
+
try {
|
|
141
|
+
const writer = sock.writable.getWriter();
|
|
142
|
+
await writer.write(requestBytes(method, parsed, headers, body));
|
|
143
|
+
writer.releaseLock();
|
|
144
|
+
const raw = await readSocket(sock, 65536);
|
|
145
|
+
const message = parseHttpResponse(raw);
|
|
146
|
+
if (typeof message === "string") throw new Error(message);
|
|
147
|
+
const responseHeaders = new Headers();
|
|
148
|
+
for (const [key, value] of Object.entries(message.headers)) responseHeaders.set(key, value);
|
|
149
|
+
const payload = message.status === 204 || message.status === 304 ? null : message.body;
|
|
150
|
+
return new Response(payload, { status: message.status, headers: responseHeaders });
|
|
151
|
+
} finally {
|
|
152
|
+
await sock.close().catch(() => {});
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function bodyBytes(body: BodyInit | null | undefined): Uint8Array {
|
|
157
|
+
if (body == null) return new Uint8Array();
|
|
158
|
+
if (typeof body === "string") return new TextEncoder().encode(body);
|
|
159
|
+
if (body instanceof Uint8Array) return body;
|
|
160
|
+
throw new Error("unsupported wake body");
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* POST (or whatever `init` says) to `url` on the address that passed the public-https check.
|
|
165
|
+
* A redirect is followed only when it stays on the same origin, and that hop is checked and pinned again.
|
|
166
|
+
* A cross-origin redirect is refused, so wake credentials are not sent to another host.
|
|
167
|
+
* 301, 302, and 303 drop the body. 307 and 308 keep it.
|
|
168
|
+
*/
|
|
169
|
+
export async function fetchCheckedWake(
|
|
170
|
+
url: string,
|
|
171
|
+
init: RequestInit,
|
|
172
|
+
deps: WakeDeps = {},
|
|
173
|
+
): Promise<{ response: Response } | { refused: string }> {
|
|
174
|
+
const lookup = deps.lookup ?? lookupAddresses;
|
|
175
|
+
const connect = async (): Promise<Connect> => {
|
|
176
|
+
if (deps.connect) return deps.connect;
|
|
177
|
+
if (net.connect) return net.connect;
|
|
178
|
+
return (await import("cloudflare:sockets")).connect as unknown as Connect;
|
|
179
|
+
};
|
|
180
|
+
let current = url;
|
|
181
|
+
let method = (init.method || "GET").toUpperCase();
|
|
182
|
+
let body = bodyBytes(method === "GET" || method === "HEAD" ? undefined : init.body);
|
|
183
|
+
for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
|
|
184
|
+
const checked = await checkedWakeAddress(current, lookup);
|
|
185
|
+
if ("refused" in checked) return checked;
|
|
186
|
+
const res = await pinnedRequest(checked.ip, current, method, init.headers, method === "GET" || method === "HEAD" ? new Uint8Array() : body, await connect());
|
|
187
|
+
const location = res.headers.get("location");
|
|
188
|
+
if (res.status < 300 || res.status >= 400 || !location) return { response: res };
|
|
189
|
+
if (hop === MAX_REDIRECTS) return { refused: "too many redirects" };
|
|
190
|
+
let next: URL;
|
|
191
|
+
try { next = new URL(location, current); } catch { return { refused: "not a URL" }; }
|
|
192
|
+
const ahead = await checkedWakeAddress(next.href, lookup);
|
|
193
|
+
if ("refused" in ahead) return ahead;
|
|
194
|
+
if (next.origin !== new URL(current).origin) return { refused: "cross-origin redirect" };
|
|
195
|
+
if (res.status === 303 || ((res.status === 301 || res.status === 302) && method !== "GET" && method !== "HEAD")) {
|
|
196
|
+
method = "GET";
|
|
197
|
+
body = new Uint8Array();
|
|
198
|
+
}
|
|
199
|
+
current = next.href;
|
|
200
|
+
}
|
|
201
|
+
return { refused: "too many redirects" };
|
|
202
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
// WAKE_TARGET_POLICY=public-https refuses private, metadata, and redirect-to-private wake targets.
|
|
2
|
+
// Unset keeps today's fetch. Run: npm test
|
|
3
|
+
import { test } from "node:test";
|
|
4
|
+
import assert from "node:assert/strict";
|
|
5
|
+
import worker from "../src/index.ts";
|
|
6
|
+
import { d1 } from "./d1.ts";
|
|
7
|
+
import * as M from "../src/mcp.ts";
|
|
8
|
+
import { fetchCheckedWake, publicWakeProblem, type AddressLookup, type WakeDeps } from "../src/wake-target.ts";
|
|
9
|
+
|
|
10
|
+
const publicLookup: AddressLookup = async (host) => {
|
|
11
|
+
if (host === "hook.example" || host === "also.example") return ["1.2.3.4"];
|
|
12
|
+
if (host === "split.example") return ["1.2.3.4", "10.0.0.1"];
|
|
13
|
+
if (host === "meta.example") return ["169.254.169.254"];
|
|
14
|
+
if (host === "missing.example") return [];
|
|
15
|
+
return null;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
test("public-https refuses private, metadata, and non-https URLs before any request", async () => {
|
|
19
|
+
const refused = [
|
|
20
|
+
"http://hook.example/wake",
|
|
21
|
+
"https://user:secret@hook.example/wake",
|
|
22
|
+
"https://127.0.0.1/wake",
|
|
23
|
+
"https://10.1.2.3/wake",
|
|
24
|
+
"https://192.168.1.1/wake",
|
|
25
|
+
"https://172.16.0.1/wake",
|
|
26
|
+
"https://100.64.0.1/wake",
|
|
27
|
+
"https://100.100.100.200/wake",
|
|
28
|
+
"https://169.254.169.254/latest/meta-data",
|
|
29
|
+
"https://[::1]/wake",
|
|
30
|
+
"https://[fe80::1]/wake",
|
|
31
|
+
"https://localhost/wake",
|
|
32
|
+
"https://metadata.google.internal/wake",
|
|
33
|
+
"https://agent.ts.net/wake",
|
|
34
|
+
"https://metadata/wake",
|
|
35
|
+
"not a url",
|
|
36
|
+
];
|
|
37
|
+
for (const url of refused) {
|
|
38
|
+
const why = await publicWakeProblem(url, publicLookup);
|
|
39
|
+
assert.notEqual(why, "", url);
|
|
40
|
+
}
|
|
41
|
+
assert.equal(await publicWakeProblem("https://hook.example/wake", publicLookup), "");
|
|
42
|
+
assert.equal(await publicWakeProblem("https://1.2.3.4/wake", publicLookup), "");
|
|
43
|
+
assert.equal(await publicWakeProblem("https://split.example/wake", publicLookup), "resolves to a private or reserved address");
|
|
44
|
+
assert.equal(await publicWakeProblem("https://meta.example/wake", publicLookup), "resolves to a private or reserved address");
|
|
45
|
+
assert.equal(await publicWakeProblem("https://missing.example/wake", publicLookup), "the host has no address");
|
|
46
|
+
assert.equal(await publicWakeProblem("https://unknown.example/wake", publicLookup), "its address could not be checked");
|
|
47
|
+
});
|
|
48
|
+
|
|
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 };
|
|
78
|
+
}
|
|
79
|
+
|
|
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);
|
|
88
|
+
assert.equal("refused" in result && result.refused, "must be https");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
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");
|
|
96
|
+
assert.equal("refused" in result && result.refused, "cross-origin redirect");
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("a same-origin redirect is pinned again and keeps the wake headers", async () => {
|
|
100
|
+
const socket = pinned((request) => request.startsWith("POST /wake ")
|
|
101
|
+
? { status: 307, location: "https://hook.example/next" }
|
|
102
|
+
: { 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);
|
|
108
|
+
assert.equal("response" in result && result.response.status, 200);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
const BASE = "https://agent.example.com";
|
|
112
|
+
|
|
113
|
+
async function wakeTest(env: Record<string, unknown>, fetchImpl: typeof fetch) {
|
|
114
|
+
const orig = globalThis.fetch;
|
|
115
|
+
globalThis.fetch = fetchImpl;
|
|
116
|
+
try {
|
|
117
|
+
const res = await worker.fetch(new Request(BASE + "/owner/wake/test", {
|
|
118
|
+
method: "POST",
|
|
119
|
+
headers: { authorization: "Bearer owner-secret" },
|
|
120
|
+
}), env as any, { waitUntil() {}, passThroughOnException() {} } as any);
|
|
121
|
+
return res.json() as Promise<{ status: number | null; info: string }>;
|
|
122
|
+
} finally {
|
|
123
|
+
globalThis.fetch = orig;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
test("the policy is off unless WAKE_TARGET_POLICY is exactly public-https", async () => {
|
|
128
|
+
const env = {
|
|
129
|
+
DB: d1(new URL("../migrations/", import.meta.url)),
|
|
130
|
+
PUBLIC_URL: BASE,
|
|
131
|
+
OWNER_TOKEN: "owner-secret",
|
|
132
|
+
WAKE_PRESET: "generic",
|
|
133
|
+
WAKE_WEBHOOK_URL: "http://127.0.0.1/hook",
|
|
134
|
+
};
|
|
135
|
+
const requested: string[] = [];
|
|
136
|
+
const body = await wakeTest(env, (async (url: string) => {
|
|
137
|
+
requested.push(String(url));
|
|
138
|
+
return new Response("ok", { status: 200 });
|
|
139
|
+
}) as typeof fetch);
|
|
140
|
+
assert.equal(body.status, 200);
|
|
141
|
+
assert.equal(requested.length, 1);
|
|
142
|
+
assert.match(requested[0], /^http:\/\/127\.0\.0\.1\/hook/);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("public-https does not request a metadata address, and does post to a checked public host", async () => {
|
|
146
|
+
const db = () => d1(new URL("../migrations/", import.meta.url));
|
|
147
|
+
const requested: string[] = [];
|
|
148
|
+
const fetchImpl = (async (url: string | URL) => {
|
|
149
|
+
const href = String(url);
|
|
150
|
+
requested.push(href);
|
|
151
|
+
if (href.startsWith("https://cloudflare-dns.com/dns-query")) {
|
|
152
|
+
const name = new URL(href).searchParams.get("name");
|
|
153
|
+
const type = new URL(href).searchParams.get("type");
|
|
154
|
+
if (name === "hook.example" && type === "A") {
|
|
155
|
+
return new Response(JSON.stringify({ Status: 0, Answer: [{ type: 1, data: "1.2.3.4" }] }), { status: 200 });
|
|
156
|
+
}
|
|
157
|
+
return new Response(JSON.stringify({ Status: 0, Answer: [] }), { status: 200 });
|
|
158
|
+
}
|
|
159
|
+
return new Response("ok", { status: 200 });
|
|
160
|
+
}) as typeof fetch;
|
|
161
|
+
|
|
162
|
+
const blocked = await wakeTest({
|
|
163
|
+
DB: db(), PUBLIC_URL: BASE, OWNER_TOKEN: "owner-secret", WAKE_PRESET: "generic",
|
|
164
|
+
WAKE_WEBHOOK_URL: "https://169.254.169.254/latest/meta-data", WAKE_TARGET_POLICY: "public-https",
|
|
165
|
+
}, fetchImpl);
|
|
166
|
+
assert.equal(blocked.status, null);
|
|
167
|
+
assert.match(blocked.info, /refused: private or internal host/);
|
|
168
|
+
assert.equal(requested.some((url) => url.includes("169.254.169.254")), false);
|
|
169
|
+
|
|
170
|
+
requested.length = 0;
|
|
171
|
+
const socket = pinned(() => ({ status: 200 }));
|
|
172
|
+
M.net.connect = socket.connect;
|
|
173
|
+
try {
|
|
174
|
+
const allowed = await wakeTest({
|
|
175
|
+
DB: db(), PUBLIC_URL: BASE, OWNER_TOKEN: "owner-secret", WAKE_PRESET: "generic",
|
|
176
|
+
WAKE_WEBHOOK_URL: "https://hook.example/wake", WAKE_WEBHOOK_KEY: "hook-key", WAKE_TARGET_POLICY: "public-https",
|
|
177
|
+
}, fetchImpl);
|
|
178
|
+
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);
|
|
184
|
+
} finally {
|
|
185
|
+
M.net.connect = null;
|
|
186
|
+
}
|
|
187
|
+
});
|