a2a-exposed 0.7.12 → 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.
- package/lib/main.mjs +5 -2
- package/package.json +1 -1
- package/worker/cloudflare.config.ts +1 -1
- package/worker/package.json +3 -0
- package/worker/src/index.ts +47 -10
- package/worker/src/owner-rpc.ts +38 -2
- 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/hosted.test.ts +100 -2
- package/worker/test/owner-rpc.test.ts +38 -3
- package/worker/test/pairing.test.ts +43 -2
- package/worker/test/wake-target.test.ts +163 -56
- package/worker/test/wake.test.ts +6 -4
package/lib/main.mjs
CHANGED
|
@@ -68,8 +68,11 @@ CLOUDFLARE_API_TOKEN instead: setup skill, Troubleshooting)
|
|
|
68
68
|
wake set [--preset P] [--agent-id A] [--key-header H] [--key-prefix X] [--body-template JSON]
|
|
69
69
|
[--cli-command CMD] [--debounce S] [--max-per-hour N]
|
|
70
70
|
save wake settings, upload wake secrets from env, and redeploy (one step)
|
|
71
|
-
|
|
72
|
-
|
|
71
|
+
--max-per-hour hourly cap; 0 or unset is unlimited, except Claude Code,
|
|
72
|
+
which defaults to 25 when this is unset
|
|
73
|
+
wake preview rendered wake request (a credential is Bearer •••• or ••••, none of the secret)
|
|
74
|
+
+ sha256 fingerprints of the uploaded URL/key/HMAC secret; compared with
|
|
75
|
+
local env values if exported
|
|
73
76
|
wake fingerprint fingerprints (first 12 hex of sha256) of WAKE_* in the local environment
|
|
74
77
|
wake test send a test wake now and print the HTTP status
|
|
75
78
|
wake unset remove wake secrets (fall back to polling)
|
package/package.json
CHANGED
|
@@ -49,7 +49,7 @@ const plain: Record<string, string> = {
|
|
|
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
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
|
+
// WAKE_TARGET_POLICY=public-https refuses a wake that is not public https. The socket opens to an address from that check, and TLS names the hostname (see wake-target.ts).
|
|
53
53
|
// APPROVAL_OIDC_* turn on OpenID Connect approval when issuer, client id, secret and allowlist are all set.
|
|
54
54
|
TENANCY: v("TENANCY"),
|
|
55
55
|
TENANT_DOMAIN: v("TENANT_DOMAIN"),
|
package/worker/package.json
CHANGED
package/worker/src/index.ts
CHANGED
|
@@ -10,7 +10,7 @@ import { renderWake, redact, cloudflareErrorHint, defaultDebounceSeconds, defaul
|
|
|
10
10
|
import { fetchCheckedWake } from "./wake-target.ts";
|
|
11
11
|
import * as P from "./pairing.ts";
|
|
12
12
|
import * as M from "./mcp.ts";
|
|
13
|
-
import { accountIdOf, matchOwnerRpc, ownerResponseLeaks, type OwnerRpcRequest, type OwnerRpcResult } from "./owner-rpc.ts";
|
|
13
|
+
import { accountIdOf, DASHBOARD_ACTOR_HEADER, matchOwnerRpc, ownerResponseLeaks, scrubOwnerClaims, type OwnerRpcRequest, type OwnerRpcResult } from "./owner-rpc.ts";
|
|
14
14
|
import { resolveTenant, type TenantContext, type WorkerBindings } from "./tenancy.ts";
|
|
15
15
|
import { exportRows, toSql } from "./export.ts";
|
|
16
16
|
import { backupDatabase, DAILY_CRON } from "./backup.ts";
|
|
@@ -83,7 +83,17 @@ function wakeConfig(ctx: TenantContext): WakeConfig {
|
|
|
83
83
|
}
|
|
84
84
|
|
|
85
85
|
const debounceMs = (ctx: TenantContext) => Number(ctx.WAKE_DEBOUNCE_SECONDS || defaultDebounceSeconds(preset(ctx))) * 1000;
|
|
86
|
-
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Hourly wake cap. 0 means unlimited, including an explicit "0".
|
|
89
|
+
* An empty or unset value uses the preset default (unlimited, or 25 for Claude Code).
|
|
90
|
+
*/
|
|
91
|
+
function maxPerHour(ctx: TenantContext): number {
|
|
92
|
+
const raw = (ctx.WAKE_MAX_PER_HOUR ?? "").trim();
|
|
93
|
+
const n = raw === "" ? defaultMaxPerHour(preset(ctx)) : Number(raw);
|
|
94
|
+
if (!Number.isFinite(n) || n <= 0) return 0;
|
|
95
|
+
return Math.floor(n);
|
|
96
|
+
}
|
|
87
97
|
|
|
88
98
|
async function wakeBudgetOk(ctx: TenantContext): Promise<boolean> {
|
|
89
99
|
const cap = maxPerHour(ctx);
|
|
@@ -118,7 +128,10 @@ async function sendWake(ctx: TenantContext, ectx: ExecutionContext, payload: Jso
|
|
|
118
128
|
try {
|
|
119
129
|
let r: Response;
|
|
120
130
|
if (ctx.gates.wakeTargetPolicy === "public-https") {
|
|
121
|
-
const checked = await fetchCheckedWake(req.url, { method: "POST", headers: req.headers, body: req.body }
|
|
131
|
+
const checked = await fetchCheckedWake(req.url, { method: "POST", headers: req.headers, body: req.body }, {
|
|
132
|
+
localHost: (() => { try { return new URL(ctx.PUBLIC_URL).host; } catch { return ""; } })(),
|
|
133
|
+
local: (url, init) => doFetch(ctx, ectx, url, init),
|
|
134
|
+
});
|
|
122
135
|
if ("refused" in checked) {
|
|
123
136
|
log("wake_refused", { preset: preset(ctx), contextId: ev.contextId, reason: checked.refused });
|
|
124
137
|
return { status: null, info: `refused: ${checked.refused}` };
|
|
@@ -906,6 +919,21 @@ async function handleOwner(req: Request, ctx: TenantContext, ectx: ExecutionCont
|
|
|
906
919
|
const seg = path.split("/").filter(Boolean).slice(1); // after "owner"
|
|
907
920
|
const body = async () => { const raw = await readBody(req, ctx); return raw ? JSON.parse(raw) : {}; };
|
|
908
921
|
|
|
922
|
+
if (seg[0] === "summary" && !seg[1] && m === "GET") {
|
|
923
|
+
// Counts only. Unlike GET /owner/inbox this does not return message text or clear a pending wake.
|
|
924
|
+
const known = ["submitted", "working", "input-required", "completed", "failed", "rejected", "canceled"];
|
|
925
|
+
const tasks: Record<string, number> = {};
|
|
926
|
+
for (const state of known) tasks[state] = 0;
|
|
927
|
+
const rows = await ctx.DB.prepare("SELECT state, COUNT(*) AS n FROM tasks GROUP BY state").all();
|
|
928
|
+
for (const r of rows.results as Json[]) {
|
|
929
|
+
if (typeof r.state !== "string" || !known.includes(r.state)) continue;
|
|
930
|
+
const n = Number(r.n);
|
|
931
|
+
if (Number.isSafeInteger(n) && n >= 0) tasks[r.state] = n;
|
|
932
|
+
}
|
|
933
|
+
const pending: Json = await ctx.DB.prepare("SELECT COUNT(*) AS n FROM device_requests WHERE status = 'pending' AND expires_ms > ?").bind(Date.now()).first();
|
|
934
|
+
const pairing = Number(pending?.n ?? 0);
|
|
935
|
+
return json({ tasks, pairingPending: Number.isSafeInteger(pairing) && pairing >= 0 ? pairing : 0 });
|
|
936
|
+
}
|
|
909
937
|
if (seg[0] === "inbox" && m === "GET") {
|
|
910
938
|
const contextFilter = url.searchParams.get("context");
|
|
911
939
|
const all = url.searchParams.get("all") === "1";
|
|
@@ -1019,7 +1047,7 @@ async function handleOwner(req: Request, ctx: TenantContext, ectx: ExecutionCont
|
|
|
1019
1047
|
return json({ revoked: true, label });
|
|
1020
1048
|
}
|
|
1021
1049
|
}
|
|
1022
|
-
if (seg[0] === "pairing") return await ownerPairing(req, ctx, m, seg.slice(1), body);
|
|
1050
|
+
if (seg[0] === "pairing") return await ownerPairing(req, ctx, m, seg.slice(1), body, trusted);
|
|
1023
1051
|
if (seg[0] === "mcp" && !seg[1] && m === "GET") {
|
|
1024
1052
|
const rows = await ctx.DB.prepare("SELECT label, client_id, client_name, redirect_host, scope, created_at, last_used_at, revoked_at, refresh_expires_ms FROM mcp_grants ORDER BY created_at").all();
|
|
1025
1053
|
return json({ enabled: mcpOn(ctx), url: oauthUrls(ctx).mcp, grants: (rows.results as Json[]).map((r) => ({ ...r, refresh_expires_ms: undefined,
|
|
@@ -1079,7 +1107,7 @@ async function handleOwner(req: Request, ctx: TenantContext, ectx: ExecutionCont
|
|
|
1079
1107
|
return json({ mode: "proxy", ...(await verifyUpstream(ctx)) });
|
|
1080
1108
|
}
|
|
1081
1109
|
if (seg[0] === "wake" && seg[1] === "preview" && m === "GET") {
|
|
1082
|
-
// rendered wake request for a sample event,
|
|
1110
|
+
// rendered wake request for a sample event, credentials shown as Bearer •••• or ••••, plus short sha256 fingerprints of the
|
|
1083
1111
|
// uploaded secrets so the owner can compare them with local values without revealing them
|
|
1084
1112
|
const ev: WakeEvent = { contextId: "ctx-preview", taskId: "task-preview", taskIds: ["task-preview"], from: "example-peer",
|
|
1085
1113
|
preview: "Example message preview", kind: "test", publicUrl: ctx.PUBLIC_URL };
|
|
@@ -1576,7 +1604,7 @@ async function setupPage(req: Request, ctx: TenantContext, url: URL): Promise<Re
|
|
|
1576
1604
|
}
|
|
1577
1605
|
|
|
1578
1606
|
/** Owner API: /owner/pairing (list, mode), /owner/pairing/password (set), /owner/pairing/<code>/approve|deny. */
|
|
1579
|
-
async function ownerPairing(req: Request, ctx: TenantContext, m: string, seg: string[], body: () => Promise<Json
|
|
1607
|
+
async function ownerPairing(req: Request, ctx: TenantContext, m: string, seg: string[], body: () => Promise<Json>, trusted = false): Promise<Response> {
|
|
1580
1608
|
const mode = pairingMode(ctx);
|
|
1581
1609
|
if (!seg[0] && m === "GET") {
|
|
1582
1610
|
const now = Date.now();
|
|
@@ -1627,9 +1655,12 @@ async function ownerPairing(req: Request, ctx: TenantContext, m: string, seg: st
|
|
|
1627
1655
|
const code = P.normUserCode(decodeURIComponent(seg[0]));
|
|
1628
1656
|
if (!code) return json({ error: "not a valid code (it looks like WDJB-4827)" }, 400);
|
|
1629
1657
|
const approve = seg[1] === "approve";
|
|
1630
|
-
|
|
1658
|
+
// The header is set only by runOwnerRpc for actor "human". A network request is not trusted, so the same
|
|
1659
|
+
// header on /owner/* does not approve and does not change who is logged.
|
|
1660
|
+
const dashboardHuman = trusted && req.headers.get(DASHBOARD_ACTOR_HEADER) === "human";
|
|
1661
|
+
if (approve && mode !== "agent" && !dashboardHuman)
|
|
1631
1662
|
return json({ error: `approval mode is human: the owner approves on ${oauthUrls(ctx).page}?user_code=${P.formatUserCode(code)} with the approval password. \`pair approve\` works only after \`${cliCommand(ctx)} deploy --pairing-approval agent\`, and only if your human wants the agent to approve. \`pair deny\` works in every mode.` }, 403);
|
|
1632
|
-
const d = await decide(ctx, code, approve, approve ? "agent" : "owner");
|
|
1663
|
+
const d = await decide(ctx, code, approve, dashboardHuman ? "human" : approve ? "agent" : "owner");
|
|
1633
1664
|
if (!d.ok) return json({ error: d.error }, 409);
|
|
1634
1665
|
return json({ ok: true, decision: approve ? "approved" : "denied", userCode: P.formatUserCode(code), label: d.label || null,
|
|
1635
1666
|
clientName: d.request.client_name || d.request.client_id || null, replaced: !!d.replaced });
|
|
@@ -2098,13 +2129,19 @@ export async function runOwnerRpc(ctx: TenantContext, ectx: ExecutionContext, in
|
|
|
2098
2129
|
const matched = matchOwnerRpc(raw?.method, raw?.path, raw?.query);
|
|
2099
2130
|
if (!matched.ok) return refuse(matched.reason === "query" ? 400 : 404, matched.reason === "query" ? "query is not allowed" : "not found", matched.reason);
|
|
2100
2131
|
if (raw?.body !== undefined && raw.body !== null) return refuse(400, "this call takes no body", "body");
|
|
2132
|
+
const actor = (raw as { actor?: unknown } | null)?.actor;
|
|
2133
|
+
if (actor !== undefined && actor !== "human") return refuse(400, "actor is invalid", "actor");
|
|
2134
|
+
const pairingDecision = matched.route.name === "POST /owner/pairing/:code/approve" || matched.route.name === "POST /owner/pairing/:code/deny";
|
|
2135
|
+
if (actor === "human" && !pairingDecision) return refuse(400, "actor is invalid", "actor");
|
|
2101
2136
|
let origin = "https://tenant.invalid";
|
|
2102
2137
|
try {
|
|
2103
2138
|
const u = new URL(ctx.PUBLIC_URL);
|
|
2104
2139
|
if (u.protocol === "https:" || u.protocol === "http:") origin = u.origin;
|
|
2105
2140
|
} catch { /* the handler reads the path, not this origin */ }
|
|
2106
2141
|
const url = new URL(origin + matched.route.path + matched.route.search);
|
|
2107
|
-
const
|
|
2142
|
+
const headers = new Headers({ "content-type": "application/json" });
|
|
2143
|
+
if (actor === "human") headers.set(DASHBOARD_ACTOR_HEADER, "human");
|
|
2144
|
+
const req = new Request(url, { method: matched.route.method, headers });
|
|
2108
2145
|
let res: Response;
|
|
2109
2146
|
try { res = await handleOwner(req, ctx, ectx, url.pathname, url, true); }
|
|
2110
2147
|
catch (e: any) {
|
|
@@ -2114,7 +2151,7 @@ export async function runOwnerRpc(ctx: TenantContext, ectx: ExecutionContext, in
|
|
|
2114
2151
|
log("owner_rpc", { tenant, accountId, method: matched.route.method, route: matched.route.name, status });
|
|
2115
2152
|
return { status, body: { error } };
|
|
2116
2153
|
}
|
|
2117
|
-
const data = await res.json().catch(() => null);
|
|
2154
|
+
const data = scrubOwnerClaims(await res.json().catch(() => null));
|
|
2118
2155
|
if (data === null || ownerResponseLeaks(data)) return refuse(500, "response withheld", "secret");
|
|
2119
2156
|
log("owner_rpc", { tenant, accountId, method: matched.route.method, route: matched.route.name, status: res.status });
|
|
2120
2157
|
return { status: res.status, body: data };
|
package/worker/src/owner-rpc.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// token, or dump credential material (export) are not here. Self-host never calls this.
|
|
5
5
|
|
|
6
6
|
import { ID_RE } from "./a2a.ts";
|
|
7
|
-
import { previewHeaderPlain } from "./wake.ts";
|
|
7
|
+
import { credentialIsMasked, previewHeaderPlain } from "./wake.ts";
|
|
8
8
|
|
|
9
9
|
/** Control-plane account id. Slashes and dots are refused so the value cannot be a path. */
|
|
10
10
|
export const ACCOUNT_ID_RE = /^[A-Za-z0-9_-]{1,64}$/;
|
|
@@ -25,6 +25,12 @@ const SECRET_KEYS = new Set([
|
|
|
25
25
|
"clientsecret", "client_secret", "pushtoken", "push_token", "authorization",
|
|
26
26
|
]);
|
|
27
27
|
|
|
28
|
+
/**
|
|
29
|
+
* Header `runOwnerRpc` sets when `actor` is `human`. `handleOwner` honors it only on an in-process call.
|
|
30
|
+
* A request from the network cannot become that decision by sending the header.
|
|
31
|
+
*/
|
|
32
|
+
export const DASHBOARD_ACTOR_HEADER = "x-a2a-dashboard-actor";
|
|
33
|
+
|
|
28
34
|
export interface OwnerRpcRequest {
|
|
29
35
|
accountId: string;
|
|
30
36
|
method: string;
|
|
@@ -33,6 +39,11 @@ export interface OwnerRpcRequest {
|
|
|
33
39
|
query?: Record<string, string>;
|
|
34
40
|
/** Refused. The owner HTTP API is where a body is accepted. */
|
|
35
41
|
body?: unknown;
|
|
42
|
+
/**
|
|
43
|
+
* Dashboard approve or deny after the control plane has checked a recent sign-in.
|
|
44
|
+
* Any other value is refused. Absent keeps the owner HTTP API's actor (`agent` or `owner`).
|
|
45
|
+
*/
|
|
46
|
+
actor?: "human";
|
|
36
47
|
}
|
|
37
48
|
|
|
38
49
|
export interface OwnerRpcResult {
|
|
@@ -109,6 +120,7 @@ interface RouteDef {
|
|
|
109
120
|
|
|
110
121
|
const ROUTES: RouteDef[] = [
|
|
111
122
|
{ method: "GET", name: "GET /owner/inbox", take: (s) => s.length === 2 && s[1] === "inbox", search: inboxQuery, path: () => "/owner/inbox" },
|
|
123
|
+
{ method: "GET", name: "GET /owner/summary", take: (s) => s.length === 2 && s[1] === "summary", search: noQuery, path: () => "/owner/summary" },
|
|
112
124
|
{ method: "GET", name: "GET /owner/tasks/:id", take: (s) => s.length === 3 && s[1] === "tasks" && idOk(s[2]!), search: noQuery, path: (s) => `/owner/tasks/${s[2]}` },
|
|
113
125
|
{ method: "GET", name: "GET /owner/history/:id", take: (s) => s.length === 3 && s[1] === "history" && idOk(s[2]!), search: historyQuery, path: (s) => `/owner/history/${s[2]}` },
|
|
114
126
|
{ method: "GET", name: "GET /owner/outbound/:id", take: (s) => s.length === 3 && s[1] === "outbound" && idOk(s[2]!), search: noQuery, path: (s) => `/owner/outbound/${s[2]}` },
|
|
@@ -147,12 +159,36 @@ function stringLeaks(value: string, key: string | null): boolean {
|
|
|
147
159
|
return key !== null && SECRET_KEYS.has(key.toLowerCase()) && value.length >= 16;
|
|
148
160
|
}
|
|
149
161
|
|
|
162
|
+
/** Claimed display fields. A token-shaped value is blanked so one request cannot withhold the list. */
|
|
163
|
+
const CLAIM_KEYS = new Set(["clientname", "client_name", "clientid", "client_id", "agentcardurl", "agent_card_url"]);
|
|
164
|
+
|
|
165
|
+
function claimLeaks(value: string): boolean {
|
|
166
|
+
return TOKEN_RE.test(value) || SETUP_RE.test(value) || USERINFO_RE.test(value);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Replace a claimed name, alias, or card that is shaped like a peer token, a setup secret, or URL
|
|
171
|
+
* userinfo with an empty string. Other keys are left for `ownerResponseLeaks`.
|
|
172
|
+
*/
|
|
173
|
+
export function scrubOwnerClaims(value: unknown, depth = 0): unknown {
|
|
174
|
+
if (value == null || depth > 20) return value;
|
|
175
|
+
if (Array.isArray(value)) return value.map((item) => scrubOwnerClaims(item, depth + 1));
|
|
176
|
+
if (typeof value !== "object") return value;
|
|
177
|
+
const out: Record<string, unknown> = {};
|
|
178
|
+
for (const [key, item] of Object.entries(value)) {
|
|
179
|
+
out[key] = typeof item === "string" && CLAIM_KEYS.has(key.toLowerCase()) && claimLeaks(item)
|
|
180
|
+
? ""
|
|
181
|
+
: scrubOwnerClaims(item, depth + 1);
|
|
182
|
+
}
|
|
183
|
+
return out;
|
|
184
|
+
}
|
|
185
|
+
|
|
150
186
|
/** A rendered wake whose credential header was not masked. The header name is the caller's choice. */
|
|
151
187
|
function wakePreviewLeaks(value: object): boolean {
|
|
152
188
|
const headers = (value as { method?: unknown; headers?: unknown }).headers;
|
|
153
189
|
if ((value as { method?: unknown }).method !== "POST" || !headers || typeof headers !== "object" || Array.isArray(headers)) return false;
|
|
154
190
|
for (const [k, v] of Object.entries(headers)) {
|
|
155
|
-
if (typeof v !== "string" || previewHeaderPlain(k) || v
|
|
191
|
+
if (typeof v !== "string" || previewHeaderPlain(k) || credentialIsMasked(v)) continue;
|
|
156
192
|
return true;
|
|
157
193
|
}
|
|
158
194
|
return false;
|
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
// Wake URL checks for WAKE_TARGET_POLICY=public-https. Unset stays the previous fetch
|
|
2
2
|
// (any URL the operator configured, redirects followed by the platform).
|
|
3
|
+
//
|
|
4
|
+
// The TCP connection is to an address from the DNS check. TLS is not the runtime's
|
|
5
|
+
// startTls({ expectedServerHostname }): on the Workers edge that sends no SNI when the
|
|
6
|
+
// socket was opened to an IP (cloudflare/workerd#6903), so the peer aborts before HTTP.
|
|
7
|
+
// The handshake runs in-process, with the URL hostname as SNI, over that pinned socket.
|
|
8
|
+
// A socket still cannot open a Cloudflare address, so a *.workers.dev target does not connect.
|
|
9
|
+
import { CertificateError, Client, TlsError } from "tunnelfetch";
|
|
3
10
|
import { isPrivateHost } from "./a2a.ts";
|
|
4
|
-
import { isPublicIp
|
|
11
|
+
import { isPublicIp } from "./mcp.ts";
|
|
5
12
|
|
|
6
13
|
/** DNS-over-HTTPS resolver. Same endpoint the client-metadata fetch uses. */
|
|
7
14
|
export const DOH_URL = "https://cloudflare-dns.com/dns-query";
|
|
@@ -9,14 +16,33 @@ export const DOH_URL = "https://cloudflare-dns.com/dns-query";
|
|
|
9
16
|
/** Original request plus this many redirects. Each hop is checked before it is requested. */
|
|
10
17
|
const MAX_REDIRECTS = 3;
|
|
11
18
|
|
|
19
|
+
const REQUEST_TIMEOUT_MS = 15_000;
|
|
20
|
+
|
|
12
21
|
/** Host to addresses. `null` means the lookup failed and the caller must fail closed. */
|
|
13
22
|
export type AddressLookup = (host: string) => Promise<string[] | null>;
|
|
14
23
|
|
|
15
|
-
type
|
|
24
|
+
type WakeSocket = {
|
|
25
|
+
readable: ReadableStream<Uint8Array>;
|
|
26
|
+
writable: WritableStream<Uint8Array>;
|
|
27
|
+
opened?: Promise<{ remoteAddress?: string | null }>;
|
|
28
|
+
close?: () => Promise<void>;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/** Raw TCP. Production uses `cloudflare:sockets`. Tests replace it. */
|
|
32
|
+
export type WakeConnect = (address: { hostname: string; port: number }, options?: { secureTransport?: "off" | "on" | "starttls"; allowHalfOpen?: boolean }) => WakeSocket;
|
|
33
|
+
|
|
34
|
+
/** One checked hop. `ip` is the address the socket must use. `hostname` is the TLS name. */
|
|
35
|
+
export type CheckedDeliver = (url: string, init: RequestInit, target: { ip: string; hostname: string }) => Promise<Response>;
|
|
36
|
+
|
|
37
|
+
export const wakeNet: { connect: null | WakeConnect; deliver: null | CheckedDeliver } = { connect: null, deliver: null };
|
|
16
38
|
|
|
17
39
|
export type WakeDeps = {
|
|
18
40
|
lookup?: AddressLookup;
|
|
19
|
-
connect?:
|
|
41
|
+
connect?: WakeConnect;
|
|
42
|
+
deliver?: CheckedDeliver;
|
|
43
|
+
/** Deliver a URL on this host in-process. Used for a wake aimed at this inbox. */
|
|
44
|
+
localHost?: string;
|
|
45
|
+
local?: (url: string, init: RequestInit) => Promise<Response>;
|
|
20
46
|
};
|
|
21
47
|
|
|
22
48
|
function bareHost(host: string): string {
|
|
@@ -69,8 +95,8 @@ export async function publicWakeProblem(url: string, lookup: AddressLookup = loo
|
|
|
69
95
|
return "refused" in checked ? checked.refused : "";
|
|
70
96
|
}
|
|
71
97
|
|
|
72
|
-
/**
|
|
73
|
-
|
|
98
|
+
/** Every public address from one lookup. The socket uses the first. The TLS name is the URL host. */
|
|
99
|
+
async function checkedWakeAddresses(url: string, lookup: AddressLookup): Promise<{ hostname: string; ips: string[] } | { refused: string }> {
|
|
74
100
|
let parsed: URL;
|
|
75
101
|
try { parsed = new URL(url); } catch { return { refused: "not a URL" }; }
|
|
76
102
|
if (parsed.protocol !== "https:") return { refused: "must be https" };
|
|
@@ -78,79 +104,99 @@ export async function checkedWakeAddress(url: string, lookup: AddressLookup = lo
|
|
|
78
104
|
const host = parsed.hostname;
|
|
79
105
|
if (!host || isPrivateHost(host)) return { refused: "private or internal host" };
|
|
80
106
|
const bare = bareHost(host);
|
|
81
|
-
if (isIpLiteral(host)) return isPublicIp(bare) ? {
|
|
107
|
+
if (isIpLiteral(host)) return isPublicIp(bare) ? { hostname: bare, ips: [bare] } : { refused: "private or reserved address" };
|
|
82
108
|
let ips: string[] | null;
|
|
83
109
|
try { ips = await lookup(host); } catch { return { refused: "its address could not be checked" }; }
|
|
84
110
|
if (ips == null) return { refused: "its address could not be checked" };
|
|
85
111
|
if (!ips.length) return { refused: "the host has no address" };
|
|
86
112
|
if (!ips.every((ip) => isPublicIp(ip))) return { refused: "resolves to a private or reserved address" };
|
|
87
|
-
return {
|
|
113
|
+
return { hostname: host, ips };
|
|
88
114
|
}
|
|
89
115
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
const
|
|
93
|
-
|
|
94
|
-
return
|
|
116
|
+
/** The address the next request must use. A name is resolved once; the returned IP is from that answer. */
|
|
117
|
+
export async function checkedWakeAddress(url: string, lookup: AddressLookup = lookupAddresses): Promise<{ ip: string } | { refused: string }> {
|
|
118
|
+
const checked = await checkedWakeAddresses(url, lookup);
|
|
119
|
+
if ("refused" in checked) return checked;
|
|
120
|
+
return { ip: checked.ips[0]! };
|
|
95
121
|
}
|
|
96
122
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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;
|
|
123
|
+
/**
|
|
124
|
+
* Dial `ip`, ignoring the hostname the TLS stack passes in. The handshake names that hostname
|
|
125
|
+
* itself; this function only chooses the TCP address.
|
|
126
|
+
*/
|
|
127
|
+
export function dialChecked(ip: string, connect: WakeConnect): WakeConnect {
|
|
128
|
+
return (addr) => connect({ hostname: ip, port: addr.port }, { secureTransport: "off", allowHalfOpen: false });
|
|
111
129
|
}
|
|
112
130
|
|
|
113
|
-
async function
|
|
114
|
-
|
|
115
|
-
const
|
|
116
|
-
|
|
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;
|
|
131
|
+
async function runtimeConnect(): Promise<WakeConnect> {
|
|
132
|
+
if (wakeNet.connect) return wakeNet.connect;
|
|
133
|
+
const sockets = await import("cloudflare:sockets");
|
|
134
|
+
return sockets.connect as unknown as WakeConnect;
|
|
132
135
|
}
|
|
133
136
|
|
|
137
|
+
/** Enough of an error body to recognise a Cloudflare status page. A success body is not kept. */
|
|
138
|
+
const HINT_BYTES = 4096;
|
|
139
|
+
|
|
134
140
|
/**
|
|
135
|
-
* HTTPS to
|
|
141
|
+
* HTTPS to the checked IP, with SNI and certificate verification for the URL host.
|
|
142
|
+
* `forceTunnel` keeps the platform from fetching the hostname again.
|
|
143
|
+
* The body is copied before the client closes, because closing drops the socket.
|
|
136
144
|
*/
|
|
137
|
-
async function
|
|
138
|
-
const
|
|
139
|
-
|
|
145
|
+
async function deliverPinned(url: string, init: RequestInit, target: { ip: string; hostname: string }, connect: WakeConnect): Promise<Response> {
|
|
146
|
+
const client = new Client({
|
|
147
|
+
connect: dialChecked(target.ip, connect),
|
|
148
|
+
forceTunnel: true,
|
|
149
|
+
maxRedirects: 0,
|
|
150
|
+
maxBodyBytes: Infinity,
|
|
151
|
+
http2: false,
|
|
152
|
+
keepAlive: false,
|
|
153
|
+
timeouts: { totalMs: REQUEST_TIMEOUT_MS },
|
|
154
|
+
});
|
|
140
155
|
try {
|
|
141
|
-
const
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
156
|
+
const res = await client.fetch(url, {
|
|
157
|
+
method: init.method,
|
|
158
|
+
headers: init.headers,
|
|
159
|
+
body: init.body,
|
|
160
|
+
redirect: "manual",
|
|
161
|
+
});
|
|
162
|
+
return await responseForCaller(res);
|
|
163
|
+
} catch (e) {
|
|
164
|
+
if (e instanceof TlsError || e instanceof CertificateError) throw new Error("TLS Handshake Failed.");
|
|
165
|
+
throw e;
|
|
151
166
|
} finally {
|
|
152
|
-
await
|
|
167
|
+
await client.close().catch(() => {});
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Status and headers, plus the start of an error body. The live socket is not returned. */
|
|
172
|
+
async function responseForCaller(res: Response): Promise<Response> {
|
|
173
|
+
const headers = new Headers(res.headers);
|
|
174
|
+
if (res.ok || !res.body) {
|
|
175
|
+
await res.body?.cancel().catch(() => {});
|
|
176
|
+
return new Response(null, { status: res.status, headers });
|
|
177
|
+
}
|
|
178
|
+
const reader = res.body.getReader();
|
|
179
|
+
const chunks: Uint8Array[] = [];
|
|
180
|
+
let total = 0;
|
|
181
|
+
try {
|
|
182
|
+
while (total < HINT_BYTES) {
|
|
183
|
+
const next = await reader.read();
|
|
184
|
+
if (next.done || !next.value) break;
|
|
185
|
+
chunks.push(next.value);
|
|
186
|
+
total += next.value.byteLength;
|
|
187
|
+
}
|
|
188
|
+
} finally {
|
|
189
|
+
await reader.cancel().catch(() => {});
|
|
190
|
+
}
|
|
191
|
+
const merged = new Uint8Array(Math.min(total, HINT_BYTES));
|
|
192
|
+
let offset = 0;
|
|
193
|
+
for (const chunk of chunks) {
|
|
194
|
+
const take = Math.min(chunk.byteLength, merged.byteLength - offset);
|
|
195
|
+
if (take <= 0) break;
|
|
196
|
+
merged.set(chunk.subarray(0, take), offset);
|
|
197
|
+
offset += take;
|
|
153
198
|
}
|
|
199
|
+
return new Response(merged, { status: res.status, headers });
|
|
154
200
|
}
|
|
155
201
|
|
|
156
202
|
function bodyBytes(body: BodyInit | null | undefined): Uint8Array {
|
|
@@ -161,8 +207,9 @@ function bodyBytes(body: BodyInit | null | undefined): Uint8Array {
|
|
|
161
207
|
}
|
|
162
208
|
|
|
163
209
|
/**
|
|
164
|
-
* POST (or whatever `init` says) to `url`
|
|
165
|
-
*
|
|
210
|
+
* POST (or whatever `init` says) to `url` after the public-https check.
|
|
211
|
+
* The socket opens to an address from that check. TLS uses the hostname.
|
|
212
|
+
* A redirect is followed only when it stays on the same origin, and that hop is checked again.
|
|
166
213
|
* A cross-origin redirect is refused, so wake credentials are not sent to another host.
|
|
167
214
|
* 301, 302, and 303 drop the body. 307 and 308 keep it.
|
|
168
215
|
*/
|
|
@@ -172,24 +219,31 @@ export async function fetchCheckedWake(
|
|
|
172
219
|
deps: WakeDeps = {},
|
|
173
220
|
): Promise<{ response: Response } | { refused: string }> {
|
|
174
221
|
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
222
|
let current = url;
|
|
181
223
|
let method = (init.method || "GET").toUpperCase();
|
|
182
224
|
let body = bodyBytes(method === "GET" || method === "HEAD" ? undefined : init.body);
|
|
183
225
|
for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
|
|
184
|
-
const checked = await
|
|
226
|
+
const checked = await checkedWakeAddresses(current, lookup);
|
|
185
227
|
if ("refused" in checked) return checked;
|
|
186
|
-
const
|
|
228
|
+
const nextInit: RequestInit = {
|
|
229
|
+
method,
|
|
230
|
+
headers: init.headers,
|
|
231
|
+
body: method === "GET" || method === "HEAD" || body.byteLength === 0 ? undefined : body,
|
|
232
|
+
redirect: "manual",
|
|
233
|
+
};
|
|
234
|
+
const target = { ip: checked.ips[0]!, hostname: checked.hostname };
|
|
235
|
+
let res: Response;
|
|
236
|
+
if (deps.local && deps.localHost && new URL(current).host === deps.localHost) res = await deps.local(current, nextInit);
|
|
237
|
+
else if (deps.deliver) res = await deps.deliver(current, nextInit, target);
|
|
238
|
+
else if (wakeNet.deliver) res = await wakeNet.deliver(current, nextInit, target);
|
|
239
|
+
else res = await deliverPinned(current, nextInit, target, deps.connect ?? wakeNet.connect ?? await runtimeConnect());
|
|
187
240
|
const location = res.headers.get("location");
|
|
188
241
|
if (res.status < 300 || res.status >= 400 || !location) return { response: res };
|
|
242
|
+
await res.body?.cancel().catch(() => {});
|
|
189
243
|
if (hop === MAX_REDIRECTS) return { refused: "too many redirects" };
|
|
190
244
|
let next: URL;
|
|
191
245
|
try { next = new URL(location, current); } catch { return { refused: "not a URL" }; }
|
|
192
|
-
const ahead = await
|
|
246
|
+
const ahead = await checkedWakeAddresses(next.href, lookup);
|
|
193
247
|
if ("refused" in ahead) return ahead;
|
|
194
248
|
if (next.origin !== new URL(current).origin) return { refused: "cross-origin redirect" };
|
|
195
249
|
if (res.status === 303 || ((res.status === 301 || res.status === 302) && method !== "GET" && method !== "HEAD")) {
|
package/worker/src/wake.ts
CHANGED
|
@@ -51,7 +51,10 @@ export function defaultDebounceSeconds(preset: string): number {
|
|
|
51
51
|
return preset === "claude-code" ? 20 : 10;
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
-
/**
|
|
54
|
+
/**
|
|
55
|
+
* Default hourly wake cap per preset. 0 means unlimited. Claude Code routines allow 30 fires/hour,
|
|
56
|
+
* so that preset defaults to 25 only when WAKE_MAX_PER_HOUR is unset. An explicit 0 stays unlimited.
|
|
57
|
+
*/
|
|
55
58
|
export function defaultMaxPerHour(preset: string): number {
|
|
56
59
|
return preset === "claude-code" ? 25 : 0;
|
|
57
60
|
}
|
|
@@ -256,11 +259,21 @@ export function previewHeaderPlain(name: string): boolean {
|
|
|
256
259
|
return PREVIEW_PLAIN_HEADERS.has(name.toLowerCase());
|
|
257
260
|
}
|
|
258
261
|
|
|
262
|
+
/** A credential header safe to show. No character of the secret is kept. */
|
|
263
|
+
export function maskedCredential(value: string): string {
|
|
264
|
+
return /^bearer\s/i.test(value) ? "Bearer ••••" : "••••";
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** True only for the exact value `maskedCredential` returns. A prefix plus an ellipsis is still a secret. */
|
|
268
|
+
export function credentialIsMasked(value: string): boolean {
|
|
269
|
+
return value === "••••" || value === "Bearer ••••";
|
|
270
|
+
}
|
|
271
|
+
|
|
259
272
|
/** Copy of a rendered request with credentials masked (for previews/logs). */
|
|
260
273
|
export function redact(req: WakeRequest): WakeRequest {
|
|
261
274
|
const h: Record<string, string> = {};
|
|
262
275
|
for (const [k, v] of Object.entries(req.headers))
|
|
263
|
-
h[k] = previewHeaderPlain(k) ? v : v
|
|
276
|
+
h[k] = previewHeaderPlain(k) ? v : maskedCredential(v);
|
|
264
277
|
let url = req.url;
|
|
265
278
|
try { const u = new URL(req.url); url = `${u.protocol}//${u.host}${u.pathname.replace(/\/[^/]{16,}/g, "/…")}`; } catch { /* keep */ }
|
|
266
279
|
return { ...req, url, headers: h };
|
|
@@ -41,6 +41,23 @@ test("nextCronAt is the earliest wake, rate row, or expiry", async () => {
|
|
|
41
41
|
assert.equal(await nextCronAt(tenant, now), now + 801);
|
|
42
42
|
});
|
|
43
43
|
|
|
44
|
+
test("zero and an unset generic cap stay unlimited; an unset Claude cap is 25", async () => {
|
|
45
|
+
const now = 1_700_000_000_000;
|
|
46
|
+
const hour = Math.floor(now / 3600000);
|
|
47
|
+
const due = now - 60_000;
|
|
48
|
+
const open = due + 10_000;
|
|
49
|
+
async function arm(over: Record<string, string>, count: number) {
|
|
50
|
+
const { DB, tenant } = ctx(over);
|
|
51
|
+
await DB.prepare("INSERT INTO wake_budget (hour, count) VALUES (?, ?)").bind(hour, count).run();
|
|
52
|
+
await DB.prepare("INSERT INTO wakes (context_id, last_sent_ms, pending_json, pending_since_ms) VALUES ('c', ?, '{}', ?)").bind(due, now).run();
|
|
53
|
+
return nextCronAt(tenant, now);
|
|
54
|
+
}
|
|
55
|
+
assert.equal(await arm({ WAKE_MAX_PER_HOUR: "0" }, 100), open);
|
|
56
|
+
assert.equal(await arm({ WAKE_MAX_PER_HOUR: "" }, 100), open);
|
|
57
|
+
assert.equal(await arm({ WAKE_PRESET: "claude-code", WAKE_MAX_PER_HOUR: "0" }, 100), open);
|
|
58
|
+
assert.equal(await arm({ WAKE_PRESET: "claude-code", WAKE_MAX_PER_HOUR: "" }, 25), (hour + 1) * 3600000);
|
|
59
|
+
});
|
|
60
|
+
|
|
44
61
|
test("an hourly cap holds an already-due wake until the next hour", async () => {
|
|
45
62
|
const now = 1_700_000_000_000;
|
|
46
63
|
const hour = Math.floor(now / 3600000);
|
|
@@ -214,6 +214,10 @@ test("per-tenant SQLite: directory, config push, and isolation", async (t) => {
|
|
|
214
214
|
const aliceInbox = await (await call("https://alice.example.com/owner/inbox?all=1", "GET", `Bearer ${tokenA}`)).json() as { text: string }[];
|
|
215
215
|
assert.equal(aliceInbox.length, 1);
|
|
216
216
|
assert.match(aliceInbox[0]!.text, /hello alice/);
|
|
217
|
+
const aliceSummary = await (await call("https://alice.example.com/owner/summary", "GET", `Bearer ${tokenA}`)).json() as { tasks: { submitted: number }; pairingPending: number };
|
|
218
|
+
assert.equal(aliceSummary.tasks.submitted, 1);
|
|
219
|
+
assert.equal(aliceSummary.pairingPending, 0);
|
|
220
|
+
assert.equal(JSON.stringify(aliceSummary).includes("hello alice"), false);
|
|
217
221
|
|
|
218
222
|
// workerd's local runtime throws "Jurisdiction restrictions are not implemented" on namespace.jurisdiction().
|
|
219
223
|
// namespaceForRegion() is covered in tenancy.test.ts; production Durable Objects do honour eu / fedramp.
|
|
@@ -500,7 +504,7 @@ test("TenantStore.owner runs an allowlisted owner call and does not return a pee
|
|
|
500
504
|
const ns = await mf.getDurableObjectNamespace("TENANT_DO");
|
|
501
505
|
const hash = await sha256("owner-carol");
|
|
502
506
|
type OwnerStub = {
|
|
503
|
-
owner(input: { accountId: string; method: string; path: string; query?: Record<string, string>; body?: unknown }): Promise<{ status: number; body: unknown }>;
|
|
507
|
+
owner(input: { accountId: string; method: string; path: string; query?: Record<string, string>; body?: unknown; actor?: string }): Promise<{ status: number; body: unknown }>;
|
|
504
508
|
storageStatus(): Promise<{ configured: boolean; tables: string[] }>;
|
|
505
509
|
pushConfig(body: Record<string, unknown>): Promise<{ applied: boolean }>;
|
|
506
510
|
};
|
|
@@ -521,6 +525,14 @@ test("TenantStore.owner runs an allowlisted owner call and does not return a pee
|
|
|
521
525
|
}
|
|
522
526
|
return true;
|
|
523
527
|
};
|
|
528
|
+
const sawAfter = async (from: number, part: string) => {
|
|
529
|
+
const start = Date.now();
|
|
530
|
+
while (!lines.slice(from).some((l) => l.includes(part))) {
|
|
531
|
+
if (Date.now() - start > 2000) return false;
|
|
532
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
533
|
+
}
|
|
534
|
+
return true;
|
|
535
|
+
};
|
|
524
536
|
const empty = ns.get(ns.idFromName("id-empty")) as unknown as OwnerStub;
|
|
525
537
|
const missing = await empty.owner({ accountId: "acct_carol", method: "GET", path: "/owner/peers" });
|
|
526
538
|
assert.equal(missing.status, 404);
|
|
@@ -542,6 +554,18 @@ test("TenantStore.owner runs an allowlisted owner call and does not return a pee
|
|
|
542
554
|
assert.equal((await http("POST", "/owner/peers", "Bearer platform-owner", { label: "nope" })).status, 401, "self-host owner token still does not unlock a tenant");
|
|
543
555
|
const issued = await (await http("POST", "/owner/peers", "Bearer owner-carol", { label: "ada" })).json() as { token?: string };
|
|
544
556
|
assert.match(issued.token ?? "", /^a2aow_/, "the owner HTTP API still returns the new token");
|
|
557
|
+
const sentCarol = await http("POST", "/", `Bearer ${issued.token}`, {
|
|
558
|
+
jsonrpc: "2.0", id: 1, method: "message/send",
|
|
559
|
+
params: { message: { kind: "message", role: "user", messageId: "m-carol", parts: [{ kind: "text", text: "hello from carol peer" }] } },
|
|
560
|
+
});
|
|
561
|
+
assert.equal(sentCarol.status, 200, await sentCarol.clone().text());
|
|
562
|
+
const summary = await stub.owner({ accountId: "acct_carol", method: "GET", path: "/owner/summary" });
|
|
563
|
+
assert.equal(summary.status, 200);
|
|
564
|
+
const summaryBody = summary.body as { tasks?: { submitted?: number }; pairingPending?: number };
|
|
565
|
+
assert.equal(summaryBody.tasks?.submitted, 1);
|
|
566
|
+
assert.equal(summaryBody.pairingPending, 0);
|
|
567
|
+
assert.equal(JSON.stringify(summary.body).includes("hello from carol peer"), false);
|
|
568
|
+
assert.equal(await saw('"route":"GET /owner/summary"'), true);
|
|
545
569
|
|
|
546
570
|
const listed = await stub.owner({ accountId: "acct_carol", method: "GET", path: "/owner/peers" });
|
|
547
571
|
assert.equal(listed.status, 200);
|
|
@@ -597,11 +621,85 @@ test("TenantStore.owner runs an allowlisted owner call and does not return a pee
|
|
|
597
621
|
assert.equal(wokenText.includes("platform-secret"), false);
|
|
598
622
|
const wokenBody = woken.body as { hasKey?: boolean; request?: { headers?: Record<string, string> } };
|
|
599
623
|
assert.equal(wokenBody.hasKey, true);
|
|
600
|
-
assert.equal(wokenBody.request?.headers?.["x-credential"], "Bearer
|
|
624
|
+
assert.equal(wokenBody.request?.headers?.["x-credential"], "Bearer ••••");
|
|
625
|
+
assert.equal(wokenText.includes("z9Qx"), false);
|
|
601
626
|
const httpPreview = await (await http("GET", "/owner/wake/preview", "Bearer owner-carol")).json() as { hasKey?: boolean };
|
|
602
627
|
assert.equal(httpPreview.hasKey, true);
|
|
603
628
|
assert.equal(JSON.stringify(httpPreview).includes(wakeKey), false);
|
|
604
629
|
|
|
630
|
+
const started = await mf.dispatchFetch("https://carol.example.com/oauth/device_authorization", {
|
|
631
|
+
method: "POST",
|
|
632
|
+
headers: { "content-type": "application/x-www-form-urlencoded", "cf-connecting-ip": "198.51.100.20" },
|
|
633
|
+
body: "client_name=Barry+Bot&client_id=barry-alias&agent_card_url=https://barry.example/.well-known/agent-card.json",
|
|
634
|
+
});
|
|
635
|
+
const device = await started.json() as { user_code?: string };
|
|
636
|
+
assert.equal(started.status, 200, JSON.stringify(device));
|
|
637
|
+
const code = device.user_code ?? "";
|
|
638
|
+
assert.match(code, /^[BCDFGHJKLMNPQRSTVWXZ]{4}-[2-9]{4}$/);
|
|
639
|
+
const shapedName = "a2aow_" + "a".repeat(20);
|
|
640
|
+
const shapedCard = "https://card.example/card.json?t=" + "c".repeat(16);
|
|
641
|
+
const shapedStart = await mf.dispatchFetch("https://carol.example.com/oauth/device_authorization", {
|
|
642
|
+
method: "POST",
|
|
643
|
+
headers: { "content-type": "application/x-www-form-urlencoded", "cf-connecting-ip": "198.51.100.23" },
|
|
644
|
+
body: `client_name=${encodeURIComponent(shapedName)}&client_id=shaped-alias&agent_card_url=${encodeURIComponent(shapedCard)}`,
|
|
645
|
+
});
|
|
646
|
+
assert.equal(shapedStart.status, 200);
|
|
647
|
+
const shapedList = await stub.owner({ accountId: "acct_carol", method: "GET", path: "/owner/pairing" });
|
|
648
|
+
assert.equal(shapedList.status, 200);
|
|
649
|
+
const shapedText = JSON.stringify(shapedList.body);
|
|
650
|
+
assert.equal(shapedText.includes(shapedName), false);
|
|
651
|
+
assert.equal(shapedText.includes("t=" + "c".repeat(16)), false);
|
|
652
|
+
assert.match(shapedText, /Barry Bot/);
|
|
653
|
+
const pendingPairing = await stub.owner({ accountId: "acct_carol", method: "GET", path: "/owner/pairing" });
|
|
654
|
+
const pending = (pendingPairing.body as { pending?: { userCode?: string; clientName?: string; clientId?: string; agentCardUrl?: string }[] }).pending ?? [];
|
|
655
|
+
assert.equal(pending.some((row) => row.userCode === code && row.clientName === "Barry Bot" && row.clientId === "barry-alias" && row.agentCardUrl === "https://barry.example/.well-known/agent-card.json"), true);
|
|
656
|
+
const refused = await stub.owner({ accountId: "acct_carol", method: "POST", path: `/owner/pairing/${code}/approve` });
|
|
657
|
+
assert.equal(refused.status, 403);
|
|
658
|
+
const httpSpoof = await mf.dispatchFetch(`https://carol.example.com/owner/pairing/${code}/approve`, {
|
|
659
|
+
method: "POST",
|
|
660
|
+
headers: { authorization: "Bearer owner-carol", "content-type": "application/json", "x-a2a-dashboard-actor": "human" },
|
|
661
|
+
});
|
|
662
|
+
assert.equal(httpSpoof.status, 403);
|
|
663
|
+
const approved = await stub.owner({ accountId: "acct_carol", method: "POST", path: `/owner/pairing/${code}/approve`, actor: "human" });
|
|
664
|
+
assert.equal(approved.status, 200);
|
|
665
|
+
assert.equal((approved.body as { decision?: string }).decision, "approved");
|
|
666
|
+
assert.equal(await saw('"event":"pairing_approved"'), true);
|
|
667
|
+
assert.equal(lines.some((line) => line.includes('"event":"pairing_approved"') && line.includes('"by":"human"')), true);
|
|
668
|
+
|
|
669
|
+
const deniedStart = await mf.dispatchFetch("https://carol.example.com/oauth/device_authorization", {
|
|
670
|
+
method: "POST",
|
|
671
|
+
headers: { "content-type": "application/x-www-form-urlencoded", "cf-connecting-ip": "198.51.100.21" },
|
|
672
|
+
body: "client_name=Deny+Bot&client_id=deny-alias",
|
|
673
|
+
});
|
|
674
|
+
const deniedDevice = await deniedStart.json() as { user_code?: string };
|
|
675
|
+
assert.equal(deniedStart.status, 200);
|
|
676
|
+
const ownerDenyAt = lines.length;
|
|
677
|
+
const ownerDenied = await stub.owner({ accountId: "acct_carol", method: "POST", path: `/owner/pairing/${deniedDevice.user_code}/deny` });
|
|
678
|
+
assert.equal(ownerDenied.status, 200);
|
|
679
|
+
assert.equal(await saw('"event":"pairing_denied"'), true);
|
|
680
|
+
const ownerDenyLine = lines.slice(ownerDenyAt).find((line) => line.includes('"event":"pairing_denied"')) ?? "";
|
|
681
|
+
assert.match(ownerDenyLine, /"by":"owner"/);
|
|
682
|
+
assert.equal(ownerDenyLine.includes('"by":"human"'), false);
|
|
683
|
+
|
|
684
|
+
const humanDenyStart = await mf.dispatchFetch("https://carol.example.com/oauth/device_authorization", {
|
|
685
|
+
method: "POST",
|
|
686
|
+
headers: { "content-type": "application/x-www-form-urlencoded", "cf-connecting-ip": "198.51.100.22" },
|
|
687
|
+
body: "client_name=Human+Deny&client_id=human-deny",
|
|
688
|
+
});
|
|
689
|
+
const humanDenyDevice = await humanDenyStart.json() as { user_code?: string };
|
|
690
|
+
assert.equal(humanDenyStart.status, 200);
|
|
691
|
+
const humanDenyAt = lines.length;
|
|
692
|
+
const humanDenied = await stub.owner({ accountId: "acct_carol", method: "POST", path: `/owner/pairing/${humanDenyDevice.user_code}/deny`, actor: "human" });
|
|
693
|
+
assert.equal(humanDenied.status, 200);
|
|
694
|
+
assert.equal(await sawAfter(humanDenyAt, '"by":"human"'), true);
|
|
695
|
+
assert.equal(lines.slice(humanDenyAt).some((line) => line.includes('"event":"pairing_denied"') && line.includes('"by":"human"')), true);
|
|
696
|
+
|
|
697
|
+
const flagged = await stub.owner({ accountId: "acct_carol", method: "GET", path: "/owner/peers", actor: "human" });
|
|
698
|
+
assert.equal(flagged.status, 400);
|
|
699
|
+
const badActor = await stub.owner({ accountId: "acct_carol", method: "POST", path: "/owner/pairing/WDJB-4827/approve", actor: "agent" });
|
|
700
|
+
assert.equal(badActor.status, 400);
|
|
701
|
+
assert.equal(await saw('"reason":"actor"'), true);
|
|
702
|
+
|
|
605
703
|
await stub.pushConfig({ version: 3, tenantId: "id-carol", name: "carol", status: "suspended", ownerTokenHash: hash, config: { AGENT_NAME: "Carol" } });
|
|
606
704
|
const suspended = await stub.owner({ accountId: "acct_carol", method: "GET", path: "/owner/peers" });
|
|
607
705
|
assert.equal(suspended.status, 403);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Owner RPC allowlist and response filter. No Durable Object: the hosted test calls TenantStore.owner.
|
|
2
2
|
import { test } from "node:test";
|
|
3
3
|
import assert from "node:assert/strict";
|
|
4
|
-
import { accountIdOf, matchOwnerRpc, ownerResponseLeaks } from "../src/owner-rpc.ts";
|
|
4
|
+
import { accountIdOf, matchOwnerRpc, ownerResponseLeaks, scrubOwnerClaims } from "../src/owner-rpc.ts";
|
|
5
5
|
|
|
6
6
|
test("owner RPC allowlist is the management reads and the calls that do not return a secret", () => {
|
|
7
7
|
const ok = (method: string, path: string, query?: Record<string, string>) => {
|
|
@@ -10,6 +10,7 @@ test("owner RPC allowlist is the management reads and the calls that do not retu
|
|
|
10
10
|
return m.ok ? m.route : null;
|
|
11
11
|
};
|
|
12
12
|
assert.equal(ok("GET", "/owner/inbox")?.name, "GET /owner/inbox");
|
|
13
|
+
assert.equal(ok("GET", "/owner/summary")?.name, "GET /owner/summary");
|
|
13
14
|
assert.equal(ok("GET", "/owner/inbox", { context: "ctx-1", all: "1" })?.search, "?context=ctx-1&all=1");
|
|
14
15
|
assert.equal(ok("GET", "/owner/tasks/task-1")?.path, "/owner/tasks/task-1");
|
|
15
16
|
assert.equal(ok("GET", "/owner/history/ctx-1", { n: "50" })?.search, "?n=50");
|
|
@@ -43,6 +44,7 @@ test("owner RPC allowlist is the management reads and the calls that do not retu
|
|
|
43
44
|
assert.deepEqual(refused("GET", "/owner/inbox?all=1"), { ok: false, reason: "path" });
|
|
44
45
|
assert.deepEqual(refused("POST", "/owner/pairing/AEIO-4827/approve"), { ok: false, reason: "path" });
|
|
45
46
|
assert.deepEqual(refused("GET", "/owner/inbox", { format: "sql" }), { ok: false, reason: "query" });
|
|
47
|
+
assert.deepEqual(refused("GET", "/owner/summary", { all: "1" }), { ok: false, reason: "query" });
|
|
46
48
|
assert.deepEqual(refused("GET", "/owner/history/ctx-1", { n: "1001" }), { ok: false, reason: "query" });
|
|
47
49
|
assert.deepEqual(refused("GET", "/owner/history/.."), { ok: false, reason: "path" });
|
|
48
50
|
assert.equal(accountIdOf("acct_carol"), "acct_carol");
|
|
@@ -60,13 +62,46 @@ test("owner RPC withholds peer tokens and decrypted secrets", () => {
|
|
|
60
62
|
assert.equal(ownerResponseLeaks({ clientSecret: "tenant-secret-value" }), true);
|
|
61
63
|
assert.equal(ownerResponseLeaks({ secretsEnc: "v1.ciphertext-value" }), true);
|
|
62
64
|
assert.equal(ownerResponseLeaks("https://user:pass@hooks.example/wake"), true);
|
|
63
|
-
assert.equal(ownerResponseLeaks({ authorization: "Bearer abcd…" }),
|
|
65
|
+
assert.equal(ownerResponseLeaks({ method: "POST", headers: { authorization: "Bearer abcd…" } }), true);
|
|
66
|
+
assert.equal(ownerResponseLeaks({ method: "POST", headers: { authorization: "Bearer ••••" } }), false);
|
|
67
|
+
assert.equal(ownerResponseLeaks({ method: "POST", headers: { "x-credential": "••••" } }), false);
|
|
64
68
|
assert.equal(ownerResponseLeaks({
|
|
65
69
|
request: { method: "POST", url: "https://hooks.example/wake", headers: { "x-credential": "supersecretvalue", "content-type": "application/json" }, body: "{}" },
|
|
66
70
|
}), true);
|
|
67
71
|
assert.equal(ownerResponseLeaks({
|
|
68
|
-
request: { method: "POST", url: "https://hooks.example/wake", headers: { "x-credential": "
|
|
72
|
+
request: { method: "POST", url: "https://hooks.example/wake", headers: { "x-credential": "Bearer ••••", "content-type": "application/json", "x-request-id": "req-1" }, body: "{}" },
|
|
69
73
|
}), false);
|
|
70
74
|
assert.equal(ownerResponseLeaks({ fingerprints: { url: "0123456789ab", key: null } }), false);
|
|
71
75
|
assert.equal(ownerResponseLeaks({ token_hash: "ab".repeat(32) }), false);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("a token-shaped claim is blanked and the rest of the list is returned", () => {
|
|
79
|
+
const token = "a2aow_" + "a".repeat(20);
|
|
80
|
+
const setup = "https://card.example/card.json?t=" + "c".repeat(16);
|
|
81
|
+
const userinfo = "https://user:secret@card.example/card.json";
|
|
82
|
+
const raw = {
|
|
83
|
+
pending: [
|
|
84
|
+
{ userCode: "WDJB-4827", clientName: token, clientId: "barry-alias", agentCardUrl: setup },
|
|
85
|
+
{ userCode: "BCDF-2345", clientName: "Barry Bot", clientId: token, agentCardUrl: "https://barry.example/card.json" },
|
|
86
|
+
{ client_name: token, agent_card_url: userinfo },
|
|
87
|
+
],
|
|
88
|
+
token,
|
|
89
|
+
};
|
|
90
|
+
assert.equal(ownerResponseLeaks(raw), true);
|
|
91
|
+
const scrubbed = scrubOwnerClaims(raw) as { pending: { clientName: string; clientId: string; agentCardUrl: string; client_name?: string; agent_card_url?: string }[]; token: string };
|
|
92
|
+
assert.equal(scrubbed.pending[0]!.clientName, "");
|
|
93
|
+
assert.equal(scrubbed.pending[0]!.clientId, "barry-alias");
|
|
94
|
+
assert.equal(scrubbed.pending[0]!.agentCardUrl, "");
|
|
95
|
+
assert.equal(scrubbed.pending[1]!.clientName, "Barry Bot");
|
|
96
|
+
assert.equal(scrubbed.pending[1]!.clientId, "");
|
|
97
|
+
assert.equal(scrubbed.pending[1]!.agentCardUrl, "https://barry.example/card.json");
|
|
98
|
+
assert.equal(scrubbed.pending[2]!.client_name, "");
|
|
99
|
+
assert.equal(scrubbed.pending[2]!.agent_card_url, "");
|
|
100
|
+
assert.equal(scrubbed.token, token);
|
|
101
|
+
assert.equal(ownerResponseLeaks(scrubbed), true, "a real token under another key is still withheld");
|
|
102
|
+
const listed = scrubOwnerClaims({ pending: raw.pending });
|
|
103
|
+
assert.equal(ownerResponseLeaks(listed), false);
|
|
104
|
+
assert.equal(JSON.stringify(listed).includes(token), false);
|
|
105
|
+
assert.equal(JSON.stringify(listed).includes("t=" + "c".repeat(16)), false);
|
|
106
|
+
assert.equal(JSON.stringify(listed).includes("user:secret"), false);
|
|
72
107
|
});
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { test } from "node:test";
|
|
4
4
|
import assert from "node:assert/strict";
|
|
5
5
|
import worker from "../src/index.ts";
|
|
6
|
+
import { DASHBOARD_ACTOR_HEADER } from "../src/owner-rpc.ts";
|
|
6
7
|
import * as P from "../src/pairing.ts";
|
|
7
8
|
import { wakeSummary, renderWake, type WakeEvent } from "../src/wake.ts";
|
|
8
9
|
import { d1 } from "./d1.ts";
|
|
@@ -221,10 +222,13 @@ test("human approval: pending, slow_down, page approve with the password, single
|
|
|
221
222
|
advance(16);
|
|
222
223
|
assert.equal((await s.poll(d.device_code)).data.error, "authorization_pending");
|
|
223
224
|
|
|
224
|
-
// human mode: the owner API (the agent) cannot approve
|
|
225
|
+
// human mode: the owner API (the agent) cannot approve, and the dashboard header does not change that
|
|
225
226
|
const oa = await s.owner("POST", `/owner/pairing/${d.user_code}/approve`);
|
|
226
227
|
assert.equal(oa.status, 403);
|
|
227
228
|
assert.match(oa.data.error, /approval mode is human: the owner approves on https:\/\/agent\.example\.com\/device\?user_code=/);
|
|
229
|
+
const spoofed = await s.call("POST", `/owner/pairing/${d.user_code}/approve`, { headers: { authorization: "Bearer owner-secret", [DASHBOARD_ACTOR_HEADER]: "human" } });
|
|
230
|
+
assert.equal(spoofed.status, 403);
|
|
231
|
+
assert.match(spoofed.data.error, /approval mode is human/);
|
|
228
232
|
const list = await s.owner("GET", "/owner/pairing");
|
|
229
233
|
assert.deepEqual([list.data.mode, list.data.passwordSet, list.data.pending.length, list.data.pending[0].userCode], ["human", false, 1, d.user_code]);
|
|
230
234
|
|
|
@@ -289,10 +293,19 @@ test("human approval: pending, slow_down, page approve with the password, single
|
|
|
289
293
|
});
|
|
290
294
|
|
|
291
295
|
test("a pairing label comes from the client name, a second one is suffixed, and inbox from uses it", async (t) => {
|
|
296
|
+
const lines: string[] = [];
|
|
297
|
+
const orig = console.log;
|
|
298
|
+
console.log = (...args: unknown[]) => { lines.push(args.map((part) => String(part)).join(" ")); orig(...args); };
|
|
299
|
+
t.after(() => { console.log = orig; });
|
|
292
300
|
const s = setup({ PAIRING_APPROVAL: "agent" }); t.after(s.restore);
|
|
293
301
|
const first = (await s.start({ client_name: "tester-alpha", client_id: "other-alias" }, "203.0.113.40")).data;
|
|
294
|
-
const approved = await s.
|
|
302
|
+
const approved = await s.call("POST", `/owner/pairing/${first.user_code}/approve`, { headers: { authorization: "Bearer owner-secret", [DASHBOARD_ACTOR_HEADER]: "human" } });
|
|
295
303
|
assert.equal(approved.data.label, "tester-alpha");
|
|
304
|
+
const decided = s.DB.db.prepare("SELECT decided_by FROM device_requests WHERE user_code = ?").get(first.user_code.replace("-", "")) as { decided_by: string };
|
|
305
|
+
assert.equal(decided.decided_by, "agent");
|
|
306
|
+
const logged = lines.find((line) => line.includes("pairing_approved"));
|
|
307
|
+
assert.match(logged ?? "", /"by":"agent"/);
|
|
308
|
+
assert.equal((logged ?? "").includes('"by":"human"'), false);
|
|
296
309
|
const token = (await s.poll(first.device_code)).data.access_token;
|
|
297
310
|
const sent = await s.call("POST", "/", {
|
|
298
311
|
json: { jsonrpc: "2.0", id: 1, method: "SendMessage", params: { message: { role: "ROLE_USER", parts: [{ text: "hello" }] } } },
|
|
@@ -681,3 +694,31 @@ test("GET / with pairing off: no /device link, says to ask the operator", async
|
|
|
681
694
|
assert.match(h.text, /ask this agent's operator for a bearer token/);
|
|
682
695
|
assert.equal((await s.call("GET", "/")).data.pairing, undefined);
|
|
683
696
|
});
|
|
697
|
+
|
|
698
|
+
test("owner summary counts tasks and pending pairing requests without their text", async (t) => {
|
|
699
|
+
const s = setup(); t.after(s.restore);
|
|
700
|
+
const started = await s.start();
|
|
701
|
+
assert.equal(started.status, 200);
|
|
702
|
+
const issued = await s.owner("POST", "/owner/peers", { label: "ada" });
|
|
703
|
+
assert.match(issued.data.token, /^a2aow_/);
|
|
704
|
+
const sent = await s.call("POST", "/", {
|
|
705
|
+
json: { jsonrpc: "2.0", id: 1, method: "message/send", params: { message: { kind: "message", role: "user", messageId: "m1", parts: [{ kind: "text", text: "secret plan from ada" }] } } },
|
|
706
|
+
headers: { authorization: `Bearer ${issued.data.token}` },
|
|
707
|
+
});
|
|
708
|
+
assert.equal(sent.status, 200, JSON.stringify(sent.data));
|
|
709
|
+
const taskId = sent.data.result.id;
|
|
710
|
+
const pending = JSON.stringify({ taskIds: [taskId] });
|
|
711
|
+
const now = Date.now();
|
|
712
|
+
s.DB.db.prepare("INSERT INTO wakes (context_id, last_sent_ms, pending_json, pending_since_ms) VALUES (?, ?, ?, ?)").run("ctx-keep", now, pending, now);
|
|
713
|
+
const summary = await s.owner("GET", "/owner/summary");
|
|
714
|
+
assert.equal(summary.status, 200);
|
|
715
|
+
assert.equal(summary.data.tasks.submitted, 1);
|
|
716
|
+
assert.equal(summary.data.tasks.working, 0);
|
|
717
|
+
assert.equal(summary.data.pairingPending, 1);
|
|
718
|
+
const text = JSON.stringify(summary.data);
|
|
719
|
+
assert.equal(text.includes("secret plan"), false);
|
|
720
|
+
assert.equal(text.includes(started.data.user_code), false);
|
|
721
|
+
assert.equal(text.includes(started.data.device_code), false);
|
|
722
|
+
assert.equal(text.includes(taskId), false);
|
|
723
|
+
assert.equal(s.DB.db.prepare("SELECT pending_json FROM wakes WHERE context_id = ?").get("ctx-keep")?.pending_json, pending);
|
|
724
|
+
});
|
|
@@ -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
|
|