@crewhaus/tool-notify 0.7.0
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/README.md +271 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.js +2167 -0
- package/dist/lib/blocks.d.ts +113 -0
- package/dist/lib/blocks.js +217 -0
- package/dist/lib/digest.d.ts +56 -0
- package/dist/lib/digest.js +140 -0
- package/dist/lib/dns-records.d.ts +179 -0
- package/dist/lib/dns-records.js +431 -0
- package/dist/lib/mime.d.ts +125 -0
- package/dist/lib/mime.js +618 -0
- package/dist/lib/preflight.d.ts +71 -0
- package/dist/lib/preflight.js +291 -0
- package/dist/lib/quiet.d.ts +59 -0
- package/dist/lib/quiet.js +217 -0
- package/dist/lib/ratelimit.d.ts +57 -0
- package/dist/lib/ratelimit.js +87 -0
- package/dist/lib/sign.d.ts +8 -0
- package/dist/lib/sign.js +39 -0
- package/dist/lib/template.d.ts +41 -0
- package/dist/lib/template.js +85 -0
- package/dist/net.d.ts +391 -0
- package/dist/net.js +1131 -0
- package/dist/paths.d.ts +42 -0
- package/dist/paths.js +158 -0
- package/dist/smtp.d.ts +56 -0
- package/dist/smtp.js +479 -0
- package/package.json +45 -0
package/dist/net.js
ADDED
|
@@ -0,0 +1,1131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The outbound posture for `@crewhaus/tool-notify`.
|
|
3
|
+
*
|
|
4
|
+
* Every byte this package sends — a chat post, an SMTP session, a webhook,
|
|
5
|
+
* an SMS — leaves through this module. The gate is the one
|
|
6
|
+
* `@crewhaus/tool-http` established, carried over rather than re-derived,
|
|
7
|
+
* because a second outbound surface with a weaker gate is the same hole
|
|
8
|
+
* twice:
|
|
9
|
+
*
|
|
10
|
+
* 1. Empty allow-list ⇒ deny all. There is no "allow everything" value,
|
|
11
|
+
* for origins, for SMTP hosts, or for email recipients.
|
|
12
|
+
* 2. Scheme must be http or https.
|
|
13
|
+
* 3. Origin (scheme + lowercase host + non-default port) must match an
|
|
14
|
+
* allow-list entry exactly, after canonicalisation.
|
|
15
|
+
* 4. SSRF: loopback, link-local (incl. the cloud metadata address),
|
|
16
|
+
* RFC1918, CGNAT, multicast, reserved and mDNS targets are refused even
|
|
17
|
+
* when the host is allow-listed — as an IP literal in any of its
|
|
18
|
+
* encodings, and as the DNS-resolved address. Classification is
|
|
19
|
+
* numeric, never a string prefix, and the ranges that carry an IPv4
|
|
20
|
+
* address (IPv4-mapped, NAT64, 6to4) are judged by the address they
|
|
21
|
+
* carry.
|
|
22
|
+
* 5. The vetted IP is pinned for the actual connection, so a rebinding
|
|
23
|
+
* resolver cannot swap in a private address between the check and the
|
|
24
|
+
* socket (CWE-367). The SMTP client pins the same way.
|
|
25
|
+
* 6. A send never follows a redirect. A 3xx on a POST is reported as a
|
|
26
|
+
* failure, because the alternatives — replaying the body at the new
|
|
27
|
+
* origin, or silently downgrading to a GET that delivers nothing — are
|
|
28
|
+
* both worse than telling the caller to point the allow-list at the
|
|
29
|
+
* real endpoint. Only the read-only `DeliveryCheck` follows, and then
|
|
30
|
+
* the allow-list, the SSRF gate and the credential rules run again on
|
|
31
|
+
* every hop.
|
|
32
|
+
* 7. Credentials are environment variable NAMES, never values. Whatever
|
|
33
|
+
* header a profile sets is dropped the moment a redirect leaves the
|
|
34
|
+
* origin it was minted for, and every result — success or failure —
|
|
35
|
+
* goes through a redactor built from the resolved secret.
|
|
36
|
+
* 8. Every request is deadline-bounded and every response is byte-capped,
|
|
37
|
+
* with the cap bounding memory rather than applied after buffering.
|
|
38
|
+
*
|
|
39
|
+
* What it does NOT do, so nobody assumes otherwise: no proxy support, no
|
|
40
|
+
* cookie jar, and no certificate handling beyond the runtime's own.
|
|
41
|
+
*/
|
|
42
|
+
import { Buffer } from "node:buffer";
|
|
43
|
+
import { lookup as dnsLookup, resolveTxt } from "node:dns/promises";
|
|
44
|
+
import { CrewhausError } from "@crewhaus/errors";
|
|
45
|
+
import { joinTxtChunks, normalizeDomain } from "./lib/dns-records";
|
|
46
|
+
/** Refusal by the allow-list, the SSRF gate, or a redirect rule. */
|
|
47
|
+
export class NotifyPermissionError extends CrewhausError {
|
|
48
|
+
name = "NotifyPermissionError";
|
|
49
|
+
constructor(message) {
|
|
50
|
+
super("tool", message);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
export const DEFAULT_TIMEOUT_MS = 30_000;
|
|
54
|
+
export const MAX_TIMEOUT_MS = 300_000;
|
|
55
|
+
export const DEFAULT_MAX_BYTES = 256 * 1024;
|
|
56
|
+
export const MAX_MAX_BYTES = 4 * 1024 * 1024;
|
|
57
|
+
export const MAX_REDIRECTS = 3;
|
|
58
|
+
/** Compact JSON — every byte returned is a byte in somebody's context window. */
|
|
59
|
+
export const json = (value) => JSON.stringify(value);
|
|
60
|
+
/** Locale-independent string order. `localeCompare` without a locale is not deterministic. */
|
|
61
|
+
export function byString(a, b) {
|
|
62
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
63
|
+
}
|
|
64
|
+
const EMPTY_CONFIG = {
|
|
65
|
+
allowedOrigins: new Set(),
|
|
66
|
+
allowedRecipients: [],
|
|
67
|
+
allowedSmtpHosts: new Set(),
|
|
68
|
+
allowedSenderDomains: new Set(),
|
|
69
|
+
providers: new Map(),
|
|
70
|
+
};
|
|
71
|
+
let notifyConfig = EMPTY_CONFIG;
|
|
72
|
+
/** Build a config from a spec-shaped block. Pure; both key spellings accepted. */
|
|
73
|
+
export function buildNotifyConfig(input) {
|
|
74
|
+
const origins = new Set();
|
|
75
|
+
for (const origin of input.allowedOrigins ?? input.allowed_origins ?? []) {
|
|
76
|
+
origins.add(canonicalizeOrigin(origin));
|
|
77
|
+
}
|
|
78
|
+
const recipients = [...(input.allowedRecipients ?? input.allowed_recipients ?? [])]
|
|
79
|
+
.map((r) => r.trim().toLowerCase())
|
|
80
|
+
.filter((r) => r !== "")
|
|
81
|
+
.sort(byString);
|
|
82
|
+
const smtpHosts = new Set();
|
|
83
|
+
for (const host of input.allowedSmtpHosts ?? input.allowed_smtp_hosts ?? []) {
|
|
84
|
+
const trimmed = host.trim().toLowerCase();
|
|
85
|
+
if (trimmed !== "")
|
|
86
|
+
smtpHosts.add(trimmed);
|
|
87
|
+
}
|
|
88
|
+
const senderDomains = new Set();
|
|
89
|
+
for (const domain of input.allowedSenderDomains ?? input.allowed_sender_domains ?? []) {
|
|
90
|
+
if (domain.trim() === "")
|
|
91
|
+
continue;
|
|
92
|
+
// Canonicalised by the SAME function the tool runs on its argument, so
|
|
93
|
+
// an operator writing `München.DE` and a caller writing `xn--mnchen-3ya.de`
|
|
94
|
+
// name the same domain rather than two that never match. A malformed
|
|
95
|
+
// entry throws here rather than sitting in the set matching nothing,
|
|
96
|
+
// which is how a misconfiguration surfaces at boot and not at the first
|
|
97
|
+
// lookup.
|
|
98
|
+
const normalized = normalizeDomain(domain);
|
|
99
|
+
if (!normalized.ok) {
|
|
100
|
+
throw new NotifyPermissionError(`invalid entry in allowed_sender_domains: ${normalized.reason}`);
|
|
101
|
+
}
|
|
102
|
+
senderDomains.add(normalized.name);
|
|
103
|
+
}
|
|
104
|
+
const providers = new Map();
|
|
105
|
+
for (const [name, profile] of Object.entries(input.providers ?? {})) {
|
|
106
|
+
providers.set(name, profile);
|
|
107
|
+
}
|
|
108
|
+
return {
|
|
109
|
+
allowedOrigins: origins,
|
|
110
|
+
allowedRecipients: recipients,
|
|
111
|
+
allowedSmtpHosts: smtpHosts,
|
|
112
|
+
allowedSenderDomains: senderDomains,
|
|
113
|
+
providers,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
/** Replace the process-global allow-list. Codegen calls this at boot. */
|
|
117
|
+
export function registerNotifyConfig(input) {
|
|
118
|
+
notifyConfig = buildNotifyConfig(input);
|
|
119
|
+
}
|
|
120
|
+
export function getNotifyConfig() {
|
|
121
|
+
return notifyConfig;
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* The config ONE call runs under: the serving candidate's
|
|
125
|
+
* `tool_config.notify` block when it declares one, else the boot
|
|
126
|
+
* registration. A non-object override is ignored rather than widened — the
|
|
127
|
+
* allow-list only ever comes from a spec block.
|
|
128
|
+
*/
|
|
129
|
+
export function resolveNotifyConfig(override) {
|
|
130
|
+
if (typeof override === "object" && override !== null && !Array.isArray(override)) {
|
|
131
|
+
return buildNotifyConfig(override);
|
|
132
|
+
}
|
|
133
|
+
return notifyConfig;
|
|
134
|
+
}
|
|
135
|
+
/** Test-only — back to fail-closed empty. */
|
|
136
|
+
export function _resetNotifyConfig() {
|
|
137
|
+
notifyConfig = EMPTY_CONFIG;
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Canonicalise an origin for exact-match comparison: lowercase scheme and
|
|
141
|
+
* host, drop path/query/fragment, elide the default port. Throws on a
|
|
142
|
+
* malformed origin so a misconfiguration surfaces at boot, not at the first
|
|
143
|
+
* send.
|
|
144
|
+
*/
|
|
145
|
+
export function canonicalizeOrigin(raw) {
|
|
146
|
+
let url;
|
|
147
|
+
try {
|
|
148
|
+
url = new URL(raw);
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
throw new NotifyPermissionError(`invalid origin "${raw}" — must be an absolute URL`);
|
|
152
|
+
}
|
|
153
|
+
return canonicalizeUrlOrigin(url, `${url.protocol}//${url.host}`);
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* The origin of a URL already parsed, without putting the URL back into any
|
|
157
|
+
* message. `label` is what a refusal may name — never the full URL, because
|
|
158
|
+
* a Slack or Teams incoming-webhook URL IS the credential, and an error
|
|
159
|
+
* string is a thing that ends up in a transcript.
|
|
160
|
+
*/
|
|
161
|
+
export function canonicalizeUrlOrigin(url, label = `${url.protocol}//${url.host}`) {
|
|
162
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
163
|
+
throw new NotifyPermissionError(`invalid origin "${label}" — only http/https schemes are supported`);
|
|
164
|
+
}
|
|
165
|
+
const scheme = url.protocol.toLowerCase();
|
|
166
|
+
const host = url.hostname.toLowerCase();
|
|
167
|
+
if (host === "") {
|
|
168
|
+
throw new NotifyPermissionError(`invalid origin "${label}" — host is required`);
|
|
169
|
+
}
|
|
170
|
+
let port = "";
|
|
171
|
+
if (url.port !== "") {
|
|
172
|
+
const isDefault = (scheme === "http:" && url.port === "80") || (scheme === "https:" && url.port === "443");
|
|
173
|
+
port = isDefault ? "" : `:${url.port}`;
|
|
174
|
+
}
|
|
175
|
+
return `${scheme}//${host}${port}`;
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* How a webhook URL is allowed to appear in a message: scheme, host and a
|
|
179
|
+
* path length, with the path itself and the query dropped.
|
|
180
|
+
*
|
|
181
|
+
* A Slack incoming webhook (`/services/T…/B…/xxxx`) and a Discord one
|
|
182
|
+
* (`/api/webhooks/<id>/<token>`) both carry their whole authority in the
|
|
183
|
+
* path. Printing the path into a refusal would post the credential into the
|
|
184
|
+
* transcript, so only its shape is printed.
|
|
185
|
+
*/
|
|
186
|
+
export function safeUrlLabel(raw) {
|
|
187
|
+
let url;
|
|
188
|
+
try {
|
|
189
|
+
url = typeof raw === "string" ? new URL(raw) : raw;
|
|
190
|
+
}
|
|
191
|
+
catch {
|
|
192
|
+
return "<unparseable url>";
|
|
193
|
+
}
|
|
194
|
+
const pathLen = url.pathname.length + url.search.length;
|
|
195
|
+
return `${url.protocol}//${url.host} (path of ${pathLen} characters, not shown)`;
|
|
196
|
+
}
|
|
197
|
+
/** Refuse a URL that carries `user:pass@`. */
|
|
198
|
+
export function assertNoUserinfo(url) {
|
|
199
|
+
if (url.username !== "" || url.password !== "") {
|
|
200
|
+
throw new NotifyPermissionError(`denied: the URL for "${url.protocol}//${url.host}" carries userinfo (user:password@host) — use the auth profile, which names an environment variable instead of putting the secret in the URL`);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
/** Refuse a URL whose origin is not allow-listed. Empty list ⇒ refuse everything. */
|
|
204
|
+
export function assertOriginAllowed(url, cfg) {
|
|
205
|
+
if (cfg.allowedOrigins.size === 0) {
|
|
206
|
+
throw new NotifyPermissionError(`denied: origin "${url.protocol}//${url.host}" is not in allowed_origins (empty allow-list = deny all)`);
|
|
207
|
+
}
|
|
208
|
+
const canonical = canonicalizeUrlOrigin(url);
|
|
209
|
+
if (!cfg.allowedOrigins.has(canonical)) {
|
|
210
|
+
throw new NotifyPermissionError(`denied: origin "${canonical}" is not in allowed_origins`);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
/** Refuse an SMTP host no operator named. Empty list ⇒ refuse everything. */
|
|
214
|
+
export function assertSmtpHostAllowed(host, cfg) {
|
|
215
|
+
const lower = host.trim().toLowerCase();
|
|
216
|
+
if (cfg.allowedSmtpHosts.size === 0) {
|
|
217
|
+
throw new NotifyPermissionError(`denied: SMTP host "${host}" is not in allowed_smtp_hosts (empty allow-list = deny all)`);
|
|
218
|
+
}
|
|
219
|
+
if (!cfg.allowedSmtpHosts.has(lower)) {
|
|
220
|
+
throw new NotifyPermissionError(`denied: SMTP host "${host}" is not in allowed_smtp_hosts`);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* Refuse a domain no operator named. Empty list ⇒ refuse everything.
|
|
225
|
+
*
|
|
226
|
+
* `domain` must ALREADY be the canonical form — the caller normalises once
|
|
227
|
+
* and then uses that one value for the gate, for the names it derives and
|
|
228
|
+
* for what it reports, so there is no spelling that passes the check and a
|
|
229
|
+
* different one that reaches the resolver.
|
|
230
|
+
*/
|
|
231
|
+
export function assertSenderDomainAllowed(domain, cfg) {
|
|
232
|
+
if (cfg.allowedSenderDomains.size === 0) {
|
|
233
|
+
throw new NotifyPermissionError(`denied: "${domain}" is not in allowed_sender_domains (empty allow-list = deny all). An operator lists the domains this harness may ask public DNS about`);
|
|
234
|
+
}
|
|
235
|
+
if (!cfg.allowedSenderDomains.has(domain)) {
|
|
236
|
+
throw new NotifyPermissionError(`denied: "${domain}" is not in allowed_sender_domains`);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* Does `address` match an allow-list entry?
|
|
241
|
+
*
|
|
242
|
+
* An entry is either a whole address or `*@domain`. A bare domain is NOT
|
|
243
|
+
* accepted as a wildcard: `example.com` would read to a human as "anyone at
|
|
244
|
+
* example.com" and to this function as "the address example.com", and a gate
|
|
245
|
+
* whose entries mean one thing to the operator and another to the code is
|
|
246
|
+
* not a gate. Matching is case-insensitive on the whole address, which is
|
|
247
|
+
* looser than RFC 5321 (local-parts are technically case-sensitive) and the
|
|
248
|
+
* looseness is in the safe direction only for the domain — so a deliberately
|
|
249
|
+
* case-varied local part cannot slip a DIFFERENT mailbox past an entry,
|
|
250
|
+
* because the entry had to name that mailbox's spelling in the first place.
|
|
251
|
+
*/
|
|
252
|
+
export function recipientAllowed(address, cfg) {
|
|
253
|
+
const lower = address.trim().toLowerCase();
|
|
254
|
+
const at = lower.lastIndexOf("@");
|
|
255
|
+
if (at <= 0 || at === lower.length - 1)
|
|
256
|
+
return false;
|
|
257
|
+
const domain = lower.slice(at + 1);
|
|
258
|
+
for (const entry of cfg.allowedRecipients) {
|
|
259
|
+
if (entry === lower)
|
|
260
|
+
return true;
|
|
261
|
+
if (entry.startsWith("*@") && entry.slice(2) === domain)
|
|
262
|
+
return true;
|
|
263
|
+
}
|
|
264
|
+
return false;
|
|
265
|
+
}
|
|
266
|
+
const defaultDnsLookup = (host) => dnsLookup(host, { verbatim: false });
|
|
267
|
+
let dnsLookupFn = defaultDnsLookup;
|
|
268
|
+
/** Test-only resolver injection, as in `@crewhaus/tool-http`. */
|
|
269
|
+
export function _setDnsLookup(fn) {
|
|
270
|
+
dnsLookupFn = fn ?? defaultDnsLookup;
|
|
271
|
+
}
|
|
272
|
+
const defaultDnsTxt = (name) => resolveTxt(name);
|
|
273
|
+
let dnsTxtFn = defaultDnsTxt;
|
|
274
|
+
/** Test-only resolver injection. No test in this package may reach real DNS. */
|
|
275
|
+
export function _setDnsTxtResolver(fn) {
|
|
276
|
+
dnsTxtFn = fn ?? defaultDnsTxt;
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* What this package will read back from a name, and report.
|
|
280
|
+
*
|
|
281
|
+
* A DKIM key at 4096 bits is about 800 characters, so these are generous;
|
|
282
|
+
* what they stop is an answer nobody can use ending up in a transcript, a
|
|
283
|
+
* trace and an eval report. The bound is on what is REPORTED — the resolver
|
|
284
|
+
* has already buffered whatever it received by the time this sees it, which
|
|
285
|
+
* is the one place in this package where a cap cannot come first.
|
|
286
|
+
*/
|
|
287
|
+
const MAX_TXT_RECORDS = 32;
|
|
288
|
+
const MAX_TXT_RECORD_CHARS = 8192;
|
|
289
|
+
/**
|
|
290
|
+
* Race a lookup against the call's deadline.
|
|
291
|
+
*
|
|
292
|
+
* `dns.promises` takes no signal, so the deadline is applied here instead.
|
|
293
|
+
* The losing lookup is left to finish on its own — its rejection is already
|
|
294
|
+
* attached below, so it cannot surface later as an unhandled one.
|
|
295
|
+
*/
|
|
296
|
+
function withDeadline(work, deadline) {
|
|
297
|
+
return new Promise((resolve, reject) => {
|
|
298
|
+
const signal = deadline.signal;
|
|
299
|
+
const fail = () => reject(new Error("aborted"));
|
|
300
|
+
if (signal.aborted) {
|
|
301
|
+
fail();
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
const onAbort = () => fail();
|
|
305
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
306
|
+
work.then((value) => {
|
|
307
|
+
signal.removeEventListener("abort", onAbort);
|
|
308
|
+
resolve(value);
|
|
309
|
+
}, (err) => {
|
|
310
|
+
signal.removeEventListener("abort", onAbort);
|
|
311
|
+
reject(err instanceof Error ? err : new Error(String(err)));
|
|
312
|
+
});
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
/**
|
|
316
|
+
* Ask for the TXT records at `name`.
|
|
317
|
+
*
|
|
318
|
+
* The two codes that ARE answers are mapped to answers: c-ares reports
|
|
319
|
+
* NXDOMAIN as `ENOTFOUND` and "the name exists but has no record of this
|
|
320
|
+
* type" as `ENODATA`, and both of those genuinely mean nothing is published.
|
|
321
|
+
* Everything else — SERVFAIL, a refused query, a timeout, a resolver that is
|
|
322
|
+
* not reachable — is `unknown` WITH the reason, because a lookup that did
|
|
323
|
+
* not happen must not read as a domain that published nothing.
|
|
324
|
+
*/
|
|
325
|
+
export async function lookupTxt(name, deadline) {
|
|
326
|
+
let raw;
|
|
327
|
+
try {
|
|
328
|
+
raw = await withDeadline(dnsTxtFn(name), deadline);
|
|
329
|
+
}
|
|
330
|
+
catch (err) {
|
|
331
|
+
if (deadline.expired()) {
|
|
332
|
+
return { outcome: "unknown", reason: `the lookup of "${name}" hit the deadline` };
|
|
333
|
+
}
|
|
334
|
+
const code = err.code;
|
|
335
|
+
if (code === "ENOTFOUND")
|
|
336
|
+
return { outcome: "nxdomain" };
|
|
337
|
+
if (code === "ENODATA")
|
|
338
|
+
return { outcome: "none" };
|
|
339
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
340
|
+
return {
|
|
341
|
+
outcome: "unknown",
|
|
342
|
+
reason: `the lookup of "${name}" failed: ${code === undefined ? detail : code}`,
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
// Answer order is not stable — a resolver may rotate records between
|
|
346
|
+
// queries — so the list is sorted before anybody reads it, and a repeated
|
|
347
|
+
// call reports the same bytes. Order WITHIN a record is untouched: an SPF
|
|
348
|
+
// record's terms are evaluated left to right and mean different things
|
|
349
|
+
// rearranged.
|
|
350
|
+
const records = raw.map((chunks) => joinTxtChunks([...chunks])).sort(byString);
|
|
351
|
+
// Over the cap is `unknown`, never a truncated record: a parser handed the
|
|
352
|
+
// first 8192 characters of an SPF record would report the `-all` it did
|
|
353
|
+
// not see as missing, which is a wrong answer where this is a missing one.
|
|
354
|
+
if (records.length > MAX_TXT_RECORDS) {
|
|
355
|
+
return {
|
|
356
|
+
outcome: "unknown",
|
|
357
|
+
reason: `"${name}" has ${records.length} TXT records, more than the ${MAX_TXT_RECORDS} this tool will read`,
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
const oversized = records.find((record) => record.length > MAX_TXT_RECORD_CHARS);
|
|
361
|
+
if (oversized !== undefined) {
|
|
362
|
+
return {
|
|
363
|
+
outcome: "unknown",
|
|
364
|
+
reason: `"${name}" has a TXT record of ${oversized.length} characters, over the ${MAX_TXT_RECORD_CHARS} this tool will read`,
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
return records.length === 0 ? { outcome: "none" } : { outcome: "records", records };
|
|
368
|
+
}
|
|
369
|
+
/**
|
|
370
|
+
* TEST-ONLY escape hatch for the private-address gate.
|
|
371
|
+
*
|
|
372
|
+
* The tests in this package drive the tools against a real `Bun.serve` and a
|
|
373
|
+
* real SMTP listener on 127.0.0.1, because mocking `fetch` would prove
|
|
374
|
+
* nothing about whether a deadline, a byte cap or an SMTP handshake actually
|
|
375
|
+
* works. Reaching a real local server means the loopback refusal has to be
|
|
376
|
+
* lifted for the duration of a test, and this is the only thing that lifts
|
|
377
|
+
* it.
|
|
378
|
+
*
|
|
379
|
+
* It is deliberately NOT reachable from a spec: no `tool_config` key sets it,
|
|
380
|
+
* nothing exported to the catalog calls it, and it defaults to `false` on
|
|
381
|
+
* every process start.
|
|
382
|
+
*
|
|
383
|
+
* It lifts LOOPBACK ONLY. The tests need 127.0.0.1 and `::1`; they have never
|
|
384
|
+
* needed 169.254.169.254, an RFC1918 address or a `.local` name, so flipping
|
|
385
|
+
* this flag must not open the cloud metadata endpoint or the machine's LAN.
|
|
386
|
+
*/
|
|
387
|
+
let privateHostsAllowedForTest = false;
|
|
388
|
+
export function __setPrivateHostsAllowedForTest(allowed) {
|
|
389
|
+
privateHostsAllowedForTest = allowed;
|
|
390
|
+
}
|
|
391
|
+
/** True for the loopback addresses, and only those. */
|
|
392
|
+
function isLoopback(ip) {
|
|
393
|
+
const bare = ip.trim().replace(/^\[/, "").replace(/\]$/, "").toLowerCase();
|
|
394
|
+
if (bare.includes(":")) {
|
|
395
|
+
const groups = expandIpv6(bare);
|
|
396
|
+
if (groups === null)
|
|
397
|
+
return false;
|
|
398
|
+
const mapped = groups.slice(0, 5).every((g) => g === 0) && (groups[5] === 0 || groups[5] === 0xffff);
|
|
399
|
+
if (mapped) {
|
|
400
|
+
if (groups[6] === 0 && groups[7] === 1)
|
|
401
|
+
return true; // ::1
|
|
402
|
+
const hi = groups[6];
|
|
403
|
+
const lo = groups[7];
|
|
404
|
+
return isLoopback([(hi >> 8) & 0xff, hi & 0xff, (lo >> 8) & 0xff, lo & 0xff].join("."));
|
|
405
|
+
}
|
|
406
|
+
return false;
|
|
407
|
+
}
|
|
408
|
+
return normalizeIpv4(bare)?.startsWith("127.") === true;
|
|
409
|
+
}
|
|
410
|
+
/**
|
|
411
|
+
* Reject a host that is — or resolves to — loopback, link-local, RFC1918,
|
|
412
|
+
* CGNAT, or mDNS. Returns the vetted IP so the caller can pin the socket to
|
|
413
|
+
* it instead of letting the stack re-resolve at connect time.
|
|
414
|
+
*/
|
|
415
|
+
export async function assertNotSsrf(hostname) {
|
|
416
|
+
const lower = hostname.toLowerCase();
|
|
417
|
+
const unbracketed = lower.replace(/^\[/, "").replace(/\]$/, "");
|
|
418
|
+
/** The flag lifts loopback and nothing else — see its declaration. */
|
|
419
|
+
const refused = (ip) => isPrivateIp(ip) && !(privateHostsAllowedForTest && isLoopback(ip));
|
|
420
|
+
if (lower === "localhost" || lower.endsWith(".localhost")) {
|
|
421
|
+
if (!privateHostsAllowedForTest) {
|
|
422
|
+
throw new NotifyPermissionError(`SSRF: host "${hostname}" resolves to loopback`);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
else if (lower.endsWith(".local")) {
|
|
426
|
+
throw new NotifyPermissionError(`SSRF: mDNS host "${hostname}" is not allowed`);
|
|
427
|
+
}
|
|
428
|
+
else if (refused(unbracketed)) {
|
|
429
|
+
throw new NotifyPermissionError(`SSRF: host "${hostname}" is a private/loopback IP`);
|
|
430
|
+
}
|
|
431
|
+
// An IP literal is its own pinned target — no lookup, nothing to rebind. An
|
|
432
|
+
// IPv6 literal is re-emitted in expanded form so the address that is pinned
|
|
433
|
+
// is the address that was classified, not a spelling of it.
|
|
434
|
+
const expanded = unbracketed.includes(":") ? expandIpv6(unbracketed) : null;
|
|
435
|
+
const literal = normalizeIpv4(unbracketed) ??
|
|
436
|
+
(expanded === null ? null : expanded.map((g) => g.toString(16)).join(":"));
|
|
437
|
+
if (literal !== null)
|
|
438
|
+
return literal;
|
|
439
|
+
if (unbracketed.includes(":")) {
|
|
440
|
+
throw new NotifyPermissionError(`SSRF: host "${hostname}" is not a valid IPv6 address`);
|
|
441
|
+
}
|
|
442
|
+
let resolved;
|
|
443
|
+
try {
|
|
444
|
+
resolved = await dnsLookupFn(lower);
|
|
445
|
+
}
|
|
446
|
+
catch (err) {
|
|
447
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
448
|
+
throw new NotifyPermissionError(`SSRF: cannot resolve "${hostname}": ${msg}`);
|
|
449
|
+
}
|
|
450
|
+
if (refused(resolved.address)) {
|
|
451
|
+
throw new NotifyPermissionError(`SSRF: host "${hostname}" resolves to private IP ${resolved.address}`);
|
|
452
|
+
}
|
|
453
|
+
return resolved.address;
|
|
454
|
+
}
|
|
455
|
+
// ---------------------------------------------------------------------------
|
|
456
|
+
// BEGIN SYNCHRONISED BLOCK — private-address classifier
|
|
457
|
+
//
|
|
458
|
+
// This block is BYTE-IDENTICAL across every package that guards an outbound
|
|
459
|
+
// request. Do not edit one copy: `apps/cli/src/tool-registry.test.ts` hashes
|
|
460
|
+
// them all and fails if any differs, and it asserts how many it found, because
|
|
461
|
+
// a copy-scanning guard that matches nothing reports green.
|
|
462
|
+
//
|
|
463
|
+
// It exists in copies rather than a shared package because these files are
|
|
464
|
+
// otherwise independent per-package networking layers; a guard proving the
|
|
465
|
+
// copies are identical is cheaper and safer than the import graph a shared
|
|
466
|
+
// package would need across `crawler`, `computer-use-driver` and ten tools.
|
|
467
|
+
//
|
|
468
|
+
// WHY IT PARSES INSTEAD OF MATCHING TEXT. Six copies were confirmed
|
|
469
|
+
// exploitable on 2026-09-18 because they compared address STRINGS. The WHATWG
|
|
470
|
+
// URL parser rewrites `[::ffff:169.254.169.254]` to `[::ffff:a9fe:a9fe]`, so a
|
|
471
|
+
// text check never sees the spelling it was written for; and `64:ff9b::a9fe:a9fe`
|
|
472
|
+
// IS 169.254.169.254 on any network running DNS64/NAT64. Parsing numerically and
|
|
473
|
+
// recursing into the embedded IPv4 is the only form that holds.
|
|
474
|
+
//
|
|
475
|
+
// WHAT IT CANNOT DO. RFC 6052 lets an operator choose any Network-Specific
|
|
476
|
+
// Prefix for NAT64, so an embedded IPv4 behind an arbitrary NSP is undecidable
|
|
477
|
+
// from the address alone. That case is configuration, and the callers that need
|
|
478
|
+
// it resolve the host and re-check the ANSWER before dialling.
|
|
479
|
+
// ---------------------------------------------------------------------------
|
|
480
|
+
/**
|
|
481
|
+
* Canonicalise an IPv4 literal.
|
|
482
|
+
*
|
|
483
|
+
* `inet_aton` forms are the classic allow-list bypass: `0177.0.0.1`,
|
|
484
|
+
* `0x7f.0.0.1`, `2130706433` and `127.1` are all 127.0.0.1, and a check that
|
|
485
|
+
* only understands dotted-decimal waves every one of them through. Returns
|
|
486
|
+
* `null` when the string is not an IPv4 literal at all.
|
|
487
|
+
*/
|
|
488
|
+
export function normalizeIpv4(raw) {
|
|
489
|
+
const trimmed = raw.trim();
|
|
490
|
+
if (trimmed === "" || /[^0-9a-fA-FxX.]/.test(trimmed))
|
|
491
|
+
return null;
|
|
492
|
+
const parts = trimmed.split(".");
|
|
493
|
+
if (parts.length === 0 || parts.length > 4)
|
|
494
|
+
return null;
|
|
495
|
+
const values = [];
|
|
496
|
+
for (const part of parts) {
|
|
497
|
+
if (part === "")
|
|
498
|
+
return null;
|
|
499
|
+
let value;
|
|
500
|
+
if (/^0[xX][0-9a-fA-F]+$/.test(part))
|
|
501
|
+
value = Number.parseInt(part.slice(2), 16);
|
|
502
|
+
else if (/^0[0-7]+$/.test(part))
|
|
503
|
+
value = Number.parseInt(part.slice(1), 8);
|
|
504
|
+
else if (/^\d+$/.test(part))
|
|
505
|
+
value = Number.parseInt(part, 10);
|
|
506
|
+
else
|
|
507
|
+
return null;
|
|
508
|
+
if (!Number.isFinite(value) || value < 0)
|
|
509
|
+
return null;
|
|
510
|
+
values.push(value);
|
|
511
|
+
}
|
|
512
|
+
// The short forms pack the remaining octets into the last part: `127.1` is
|
|
513
|
+
// 127.0.0.1, not 127.1.0.0. Getting this backwards is how a bypass survives.
|
|
514
|
+
const last = values[values.length - 1];
|
|
515
|
+
const leading = values.slice(0, -1);
|
|
516
|
+
if (leading.some((v) => v > 255))
|
|
517
|
+
return null;
|
|
518
|
+
const remaining = 4 - leading.length;
|
|
519
|
+
if (last >= 2 ** (8 * remaining))
|
|
520
|
+
return null;
|
|
521
|
+
const octets = [...leading];
|
|
522
|
+
for (let i = remaining - 1; i >= 0; i--)
|
|
523
|
+
octets.push((last >>> (8 * i)) & 0xff);
|
|
524
|
+
return octets.join(".");
|
|
525
|
+
}
|
|
526
|
+
/**
|
|
527
|
+
* Expand an IPv6 literal to its eight 16-bit groups, or `null` when the string
|
|
528
|
+
* is not one.
|
|
529
|
+
*
|
|
530
|
+
* Classifying IPv6 by its TEXT is where the bypasses live, because one address
|
|
531
|
+
* has many spellings and the one a check was written against is rarely the one
|
|
532
|
+
* that arrives. `http://[::ffff:169.254.169.254]/` never reaches a guard in
|
|
533
|
+
* that form: the WHATWG URL parser re-serialises the embedded quad as hex
|
|
534
|
+
* pieces, so what the guard sees is `::ffff:a9fe:a9fe`. Normalising to numbers
|
|
535
|
+
* first means the prefix tests below are arithmetic, and spelling stops
|
|
536
|
+
* mattering.
|
|
537
|
+
*/
|
|
538
|
+
export function parseIpv6(raw) {
|
|
539
|
+
let text = raw.trim().toLowerCase();
|
|
540
|
+
if (text.startsWith("["))
|
|
541
|
+
text = text.slice(1);
|
|
542
|
+
if (text.endsWith("]"))
|
|
543
|
+
text = text.slice(0, -1);
|
|
544
|
+
const zone = text.indexOf("%"); // fe80::1%eth0
|
|
545
|
+
if (zone !== -1)
|
|
546
|
+
text = text.slice(0, zone);
|
|
547
|
+
if (!text.includes(":"))
|
|
548
|
+
return null;
|
|
549
|
+
// A trailing dotted quad is the last two groups written in IPv4. Require all
|
|
550
|
+
// three dots: without that, `::1` parses as a one-part inet_aton address and
|
|
551
|
+
// takes a path that has nothing to do with what was written.
|
|
552
|
+
const dotted = /^(.*:)(\d+(?:\.\d+){3})$/.exec(text);
|
|
553
|
+
if (dotted !== null) {
|
|
554
|
+
const quad = normalizeIpv4(dotted[2]);
|
|
555
|
+
if (quad === null)
|
|
556
|
+
return null;
|
|
557
|
+
const o = quad.split(".").map((n) => Number.parseInt(n, 10));
|
|
558
|
+
const hi = (o[0] << 8) | o[1];
|
|
559
|
+
const lo = (o[2] << 8) | o[3];
|
|
560
|
+
text = `${dotted[1]}${hi.toString(16)}:${lo.toString(16)}`;
|
|
561
|
+
}
|
|
562
|
+
const halves = text.split("::");
|
|
563
|
+
if (halves.length > 2)
|
|
564
|
+
return null;
|
|
565
|
+
const pieces = (part) => (part === "" ? [] : part.split(":"));
|
|
566
|
+
const head = pieces(halves[0]);
|
|
567
|
+
const tail = halves.length === 2 ? pieces(halves[1]) : [];
|
|
568
|
+
if (halves.length === 1 && head.length !== 8)
|
|
569
|
+
return null;
|
|
570
|
+
if (head.length + tail.length > 8)
|
|
571
|
+
return null;
|
|
572
|
+
const groups = [];
|
|
573
|
+
for (const piece of head) {
|
|
574
|
+
if (!/^[0-9a-f]{1,4}$/.test(piece))
|
|
575
|
+
return null;
|
|
576
|
+
groups.push(Number.parseInt(piece, 16));
|
|
577
|
+
}
|
|
578
|
+
for (let i = head.length + tail.length; i < 8; i++)
|
|
579
|
+
groups.push(0);
|
|
580
|
+
for (const piece of tail) {
|
|
581
|
+
if (!/^[0-9a-f]{1,4}$/.test(piece))
|
|
582
|
+
return null;
|
|
583
|
+
groups.push(Number.parseInt(piece, 16));
|
|
584
|
+
}
|
|
585
|
+
return groups.length === 8 ? groups : null;
|
|
586
|
+
}
|
|
587
|
+
/**
|
|
588
|
+
* The IPv4 address an IPv6 address carries, when it carries one.
|
|
589
|
+
*
|
|
590
|
+
* Every transition mechanism embeds a v4 address somewhere, and every one of
|
|
591
|
+
* them is a way to reach a v4 destination while wearing a v6 spelling that no
|
|
592
|
+
* v4 range check looks at. The NAT64 well-known prefix is the sharpest: a
|
|
593
|
+
* DNS64 resolver answers an IPv4-only name with `64:ff9b::<the v4>`, so
|
|
594
|
+
* `64:ff9b::a9fe:a9fe` IS the metadata service on any network that runs one.
|
|
595
|
+
*/
|
|
596
|
+
function embeddedIpv4(g) {
|
|
597
|
+
const quad = (hi, lo) => `${(hi >>> 8) & 0xff}.${hi & 0xff}.${(lo >>> 8) & 0xff}.${lo & 0xff}`;
|
|
598
|
+
const zeros = (from, to) => g.slice(from, to).every((x) => x === 0);
|
|
599
|
+
const last = quad(g[6], g[7]);
|
|
600
|
+
if (zeros(0, 5) && g[5] === 0xffff)
|
|
601
|
+
return last; // ::ffff:0:0/96, IPv4-mapped
|
|
602
|
+
if (zeros(0, 4) && g[4] === 0xffff && g[5] === 0)
|
|
603
|
+
return last; // ::ffff:0:0:0/96, translated
|
|
604
|
+
if (g[0] === 0x64 && g[1] === 0xff9b)
|
|
605
|
+
return last; // 64:ff9b::/96 and 64:ff9b:1::/48, NAT64
|
|
606
|
+
if (g[0] === 0x2002)
|
|
607
|
+
return quad(g[1], g[2]); // 2002::/16, 6to4
|
|
608
|
+
// ::a.b.c.d, IPv4-compatible: deprecated, still routed by some stacks, and
|
|
609
|
+
// not to be confused with `::` or `::1`, which are handled before this.
|
|
610
|
+
if (zeros(0, 6))
|
|
611
|
+
return last;
|
|
612
|
+
return null;
|
|
613
|
+
}
|
|
614
|
+
/** Ranges that are never a public RPC endpoint. */
|
|
615
|
+
export function isPrivateIp(address) {
|
|
616
|
+
const v4 = normalizeIpv4(address);
|
|
617
|
+
if (v4 !== null) {
|
|
618
|
+
const [a = 0, b = 0, c = 0, d = 0] = v4.split(".").map((n) => Number.parseInt(n, 10));
|
|
619
|
+
if (a === 0 || a === 10 || a === 127)
|
|
620
|
+
return true;
|
|
621
|
+
if (a === 169 && b === 254)
|
|
622
|
+
return true; // link-local, and the metadata service
|
|
623
|
+
if (a === 172 && b >= 16 && b <= 31)
|
|
624
|
+
return true;
|
|
625
|
+
if (a === 192 && b === 168)
|
|
626
|
+
return true;
|
|
627
|
+
if (a === 192 && b === 0 && c === 0)
|
|
628
|
+
return true;
|
|
629
|
+
if (a === 100 && b >= 64 && b <= 127)
|
|
630
|
+
return true; // carrier-grade NAT
|
|
631
|
+
if (a === 198 && (b === 18 || b === 19))
|
|
632
|
+
return true; // benchmarking
|
|
633
|
+
if (a >= 224)
|
|
634
|
+
return true; // multicast, reserved, broadcast
|
|
635
|
+
return a === 255 && b === 255 && c === 255 && d === 255;
|
|
636
|
+
}
|
|
637
|
+
const groups = parseIpv6(address);
|
|
638
|
+
if (groups === null)
|
|
639
|
+
return false;
|
|
640
|
+
if (groups.every((g) => g === 0))
|
|
641
|
+
return true; // ::, the unspecified address
|
|
642
|
+
if (groups.slice(0, 7).every((g) => g === 0) && groups[7] === 1)
|
|
643
|
+
return true; // ::1
|
|
644
|
+
const carried = embeddedIpv4(groups);
|
|
645
|
+
if (carried !== null)
|
|
646
|
+
return isPrivateIp(carried);
|
|
647
|
+
const head = groups[0];
|
|
648
|
+
if ((head & 0xfe00) === 0xfc00)
|
|
649
|
+
return true; // fc00::/7, unique-local
|
|
650
|
+
if ((head & 0xffc0) === 0xfe80)
|
|
651
|
+
return true; // fe80::/10, link-local
|
|
652
|
+
return (head & 0xff00) === 0xff00; // ff00::/8, multicast
|
|
653
|
+
}
|
|
654
|
+
// END SYNCHRONISED BLOCK
|
|
655
|
+
/**
|
|
656
|
+
* `parseIpv6` under this package's older name, returning a mutable array.
|
|
657
|
+
*
|
|
658
|
+
* Deliberately OUTSIDE the synchronised block: `smtp.ts` uses it to decide
|
|
659
|
+
* whether a host is an IP literal and so has no SNI name, the SSRF gate
|
|
660
|
+
* re-emits an IPv6 literal in expanded form from it, and `lib.test.ts`
|
|
661
|
+
* imports it. The block's own text must not change, so the adaptation to
|
|
662
|
+
* this package's older name and signature lives here instead.
|
|
663
|
+
*/
|
|
664
|
+
export function expandIpv6(raw) {
|
|
665
|
+
const groups = parseIpv6(raw);
|
|
666
|
+
return groups === null ? null : [...groups];
|
|
667
|
+
}
|
|
668
|
+
/**
|
|
669
|
+
* A deadline that also honours the runtime's own cancellation. Every
|
|
670
|
+
* outbound tool here opens one before its first byte and cancels it in a
|
|
671
|
+
* `finally` — a tool that can hang forever is a defect.
|
|
672
|
+
*/
|
|
673
|
+
export function startDeadline(ms, outer) {
|
|
674
|
+
const ctrl = new AbortController();
|
|
675
|
+
const startedAt = Date.now();
|
|
676
|
+
const timer = setTimeout(() => ctrl.abort(new Error(`deadline of ${ms}ms elapsed`)), ms);
|
|
677
|
+
const onOuter = () => ctrl.abort(outer?.reason);
|
|
678
|
+
if (outer !== undefined) {
|
|
679
|
+
if (outer.aborted)
|
|
680
|
+
ctrl.abort(outer.reason);
|
|
681
|
+
else
|
|
682
|
+
outer.addEventListener("abort", onOuter, { once: true });
|
|
683
|
+
}
|
|
684
|
+
return {
|
|
685
|
+
signal: ctrl.signal,
|
|
686
|
+
remaining: () => Math.max(0, ms - (Date.now() - startedAt)),
|
|
687
|
+
expired: () => Date.now() - startedAt >= ms,
|
|
688
|
+
cancel: () => {
|
|
689
|
+
clearTimeout(timer);
|
|
690
|
+
outer?.removeEventListener("abort", onOuter);
|
|
691
|
+
},
|
|
692
|
+
};
|
|
693
|
+
}
|
|
694
|
+
/** Resolve after `ms`, or as soon as `signal` aborts. Never rejects. */
|
|
695
|
+
export function sleep(ms, signal) {
|
|
696
|
+
return new Promise((resolve) => {
|
|
697
|
+
if (signal?.aborted === true) {
|
|
698
|
+
resolve();
|
|
699
|
+
return;
|
|
700
|
+
}
|
|
701
|
+
const timer = setTimeout(() => {
|
|
702
|
+
signal?.removeEventListener("abort", onAbort);
|
|
703
|
+
resolve();
|
|
704
|
+
}, ms);
|
|
705
|
+
function onAbort() {
|
|
706
|
+
clearTimeout(timer);
|
|
707
|
+
resolve();
|
|
708
|
+
}
|
|
709
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
710
|
+
});
|
|
711
|
+
}
|
|
712
|
+
const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
713
|
+
const MAX_ENV_NAME_LENGTH = 64;
|
|
714
|
+
const SECRET_PREFIXES = [
|
|
715
|
+
"xoxb-",
|
|
716
|
+
"xoxp-",
|
|
717
|
+
"xoxa-",
|
|
718
|
+
"xapp-",
|
|
719
|
+
"sk_live_",
|
|
720
|
+
"sk_test_",
|
|
721
|
+
"ghp_",
|
|
722
|
+
"github_pat_",
|
|
723
|
+
"glpat-",
|
|
724
|
+
"whsec_",
|
|
725
|
+
"bearer ",
|
|
726
|
+
"sk-",
|
|
727
|
+
"ac", // Twilio account sids are `AC` + 32 hex, and are paired with a token
|
|
728
|
+
];
|
|
729
|
+
/** True when a value that arrived as a NAME is really a secret. */
|
|
730
|
+
function looksLikeASecret(value) {
|
|
731
|
+
const lower = value.toLowerCase();
|
|
732
|
+
if (lower.startsWith("ac") && /^ac[0-9a-f]{32}$/.test(lower))
|
|
733
|
+
return true;
|
|
734
|
+
return SECRET_PREFIXES.filter((p) => p !== "ac").some((prefix) => lower.startsWith(prefix));
|
|
735
|
+
}
|
|
736
|
+
/**
|
|
737
|
+
* Read a secret out of the named environment variable.
|
|
738
|
+
*
|
|
739
|
+
* The NAME travels through the tool call; the value never does. A value that
|
|
740
|
+
* is not shaped like an environment variable name, or that carries a known
|
|
741
|
+
* secret prefix, is refused as a pasted credential — and the refusal quotes
|
|
742
|
+
* nothing, because a tool result is a transcript, a trace and usually an
|
|
743
|
+
* eval report.
|
|
744
|
+
*/
|
|
745
|
+
export function resolveSecret(envVar, what, env = process.env) {
|
|
746
|
+
if (envVar === undefined || envVar === "") {
|
|
747
|
+
return {
|
|
748
|
+
ok: false,
|
|
749
|
+
message: `no ${what}: name the environment variable holding it — the secret itself is never accepted as an argument`,
|
|
750
|
+
};
|
|
751
|
+
}
|
|
752
|
+
if (!ENV_NAME.test(envVar) || envVar.length > MAX_ENV_NAME_LENGTH || looksLikeASecret(envVar)) {
|
|
753
|
+
return {
|
|
754
|
+
ok: false,
|
|
755
|
+
message: `${what} must be the NAME of an environment variable (letters, digits and underscores, e.g. SLACK_WEBHOOK_URL), not the secret. The value given is not a usable name and has not been echoed back; if it was the secret itself, treat it as exposed to whoever wrote it and set the variable instead`,
|
|
756
|
+
};
|
|
757
|
+
}
|
|
758
|
+
const value = env[envVar];
|
|
759
|
+
if (value === undefined || value === "") {
|
|
760
|
+
return {
|
|
761
|
+
ok: false,
|
|
762
|
+
message: `${what} names environment variable "${envVar}", which is unset or empty in this process`,
|
|
763
|
+
};
|
|
764
|
+
}
|
|
765
|
+
return { ok: true, value };
|
|
766
|
+
}
|
|
767
|
+
/** Header names that must never be supplied inline, and never survive a hop. */
|
|
768
|
+
export const CREDENTIAL_HEADERS = new Set([
|
|
769
|
+
"authorization",
|
|
770
|
+
"proxy-authorization",
|
|
771
|
+
"cookie",
|
|
772
|
+
]);
|
|
773
|
+
/**
|
|
774
|
+
* Refuse caller-supplied credential headers. The `auth` profile is the only
|
|
775
|
+
* way to attach one, which keeps the secret out of the tool arguments and
|
|
776
|
+
* therefore out of the transcript.
|
|
777
|
+
*/
|
|
778
|
+
export function rejectInlineCredentials(headers) {
|
|
779
|
+
for (const name of Object.keys(headers)) {
|
|
780
|
+
if (CREDENTIAL_HEADERS.has(name.toLowerCase())) {
|
|
781
|
+
return `header "${name}" cannot be set inline — use the auth profile, which names an environment variable instead of carrying the secret in the tool call`;
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
return null;
|
|
785
|
+
}
|
|
786
|
+
/**
|
|
787
|
+
* Apply an auth profile to `headers`. Returns the lowercased names of the
|
|
788
|
+
* headers it set — so a profile that puts the secret in `X-Api-Key` is
|
|
789
|
+
* guarded exactly as hard as one that puts it in `Authorization` — and the
|
|
790
|
+
* resolved secret values, which feed the redactor.
|
|
791
|
+
*/
|
|
792
|
+
export function applyAuth(headers, auth, env = process.env) {
|
|
793
|
+
if (auth === undefined)
|
|
794
|
+
return { ok: true, secretHeaders: new Set(), secrets: [] };
|
|
795
|
+
const resolved = resolveSecret(auth.envVar, "auth profile envVar", env);
|
|
796
|
+
if (!resolved.ok)
|
|
797
|
+
return { ok: false, message: resolved.message };
|
|
798
|
+
const secret = resolved.value;
|
|
799
|
+
if (auth.type === "bearer") {
|
|
800
|
+
headers["Authorization"] = `Bearer ${secret}`;
|
|
801
|
+
return { ok: true, secretHeaders: new Set(["authorization"]), secrets: [secret] };
|
|
802
|
+
}
|
|
803
|
+
if (auth.type === "basic") {
|
|
804
|
+
if (auth.username === undefined) {
|
|
805
|
+
return {
|
|
806
|
+
ok: false,
|
|
807
|
+
message: 'auth type "basic" needs a username; the password comes from envVar',
|
|
808
|
+
};
|
|
809
|
+
}
|
|
810
|
+
const encoded = Buffer.from(`${auth.username}:${secret}`, "utf8").toString("base64");
|
|
811
|
+
headers["Authorization"] = `Basic ${encoded}`;
|
|
812
|
+
return { ok: true, secretHeaders: new Set(["authorization"]), secrets: [secret, encoded] };
|
|
813
|
+
}
|
|
814
|
+
if (auth.headerName === undefined) {
|
|
815
|
+
return { ok: false, message: 'auth type "header" needs a headerName' };
|
|
816
|
+
}
|
|
817
|
+
const lower = auth.headerName.toLowerCase();
|
|
818
|
+
if (lower === "host") {
|
|
819
|
+
return { ok: false, message: 'auth type "header" cannot set the Host header' };
|
|
820
|
+
}
|
|
821
|
+
headers[auth.headerName] = `${auth.prefix ?? ""}${secret}`;
|
|
822
|
+
return { ok: true, secretHeaders: new Set([lower]), secrets: [secret] };
|
|
823
|
+
}
|
|
824
|
+
/**
|
|
825
|
+
* A function that scrubs secrets out of anything on its way back to the
|
|
826
|
+
* caller.
|
|
827
|
+
*
|
|
828
|
+
* Everything this package returns — results, refusals, the text of a
|
|
829
|
+
* provider error, an SMTP server's reply — goes through one of these. It is
|
|
830
|
+
* the backstop rather than the primary defence: a secret is only ever placed
|
|
831
|
+
* in a header or an SMTP AUTH line, never in a path or a body. But a
|
|
832
|
+
* provider that echoes a header, a transport error that quotes a request, or
|
|
833
|
+
* an SMTP server that repeats what it was sent would each put the secret in
|
|
834
|
+
* a transcript, and that is not a mistake worth leaving one layer deep.
|
|
835
|
+
*
|
|
836
|
+
* Secrets shorter than six characters are left alone: replacing every "x" in
|
|
837
|
+
* a result would mangle it without protecting anything real.
|
|
838
|
+
*/
|
|
839
|
+
export function redactorFor(secrets) {
|
|
840
|
+
const forms = new Set();
|
|
841
|
+
for (const secret of secrets) {
|
|
842
|
+
if (secret === undefined || secret.length < 6)
|
|
843
|
+
continue;
|
|
844
|
+
forms.add(secret);
|
|
845
|
+
forms.add(encodeURIComponent(secret));
|
|
846
|
+
try {
|
|
847
|
+
forms.add(Buffer.from(secret, "utf8").toString("base64"));
|
|
848
|
+
}
|
|
849
|
+
catch {
|
|
850
|
+
// not encodable — the literal form is still covered
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
const ordered = [...forms].filter((f) => f.length >= 6).sort((a, b) => b.length - a.length);
|
|
854
|
+
if (ordered.length === 0)
|
|
855
|
+
return (text) => text;
|
|
856
|
+
return (text) => {
|
|
857
|
+
let out = text;
|
|
858
|
+
for (const form of ordered)
|
|
859
|
+
out = out.split(form).join("<redacted>");
|
|
860
|
+
return out;
|
|
861
|
+
};
|
|
862
|
+
}
|
|
863
|
+
/** Response headers as a sorted plain object, credentials removed. */
|
|
864
|
+
const STRIPPED_RESPONSE_HEADERS = new Set([
|
|
865
|
+
"authorization",
|
|
866
|
+
"cookie",
|
|
867
|
+
"set-cookie",
|
|
868
|
+
]);
|
|
869
|
+
export function responseHeaders(res) {
|
|
870
|
+
const pairs = [];
|
|
871
|
+
for (const [key, value] of res.headers.entries()) {
|
|
872
|
+
if (STRIPPED_RESPONSE_HEADERS.has(key.toLowerCase()))
|
|
873
|
+
continue;
|
|
874
|
+
pairs.push([key.toLowerCase(), value]);
|
|
875
|
+
}
|
|
876
|
+
pairs.sort((a, b) => byString(a[0], b[0]));
|
|
877
|
+
return Object.fromEntries(pairs);
|
|
878
|
+
}
|
|
879
|
+
/**
|
|
880
|
+
* Dial the vetted IP while keeping the real hostname for the `Host` header
|
|
881
|
+
* and TLS SNI, so virtual hosting and certificate validation still work.
|
|
882
|
+
*/
|
|
883
|
+
function pinnedFetch(req, pinnedIp) {
|
|
884
|
+
const original = new URL(req.url);
|
|
885
|
+
const host = original.hostname;
|
|
886
|
+
const unbracketed = host.replace(/^\[/, "").replace(/\]$/, "");
|
|
887
|
+
if (unbracketed === pinnedIp || pinnedIp === "")
|
|
888
|
+
return globalThis.fetch(req);
|
|
889
|
+
const pinnedUrl = new URL(original.toString());
|
|
890
|
+
pinnedUrl.hostname = pinnedIp.includes(":") ? `[${pinnedIp}]` : pinnedIp;
|
|
891
|
+
const headers = new Headers(req.headers);
|
|
892
|
+
headers.set("host", original.port === "" ? host : `${host}:${original.port}`);
|
|
893
|
+
const init = {
|
|
894
|
+
method: req.method,
|
|
895
|
+
headers,
|
|
896
|
+
redirect: "manual",
|
|
897
|
+
signal: req.signal,
|
|
898
|
+
tls: { serverName: host },
|
|
899
|
+
};
|
|
900
|
+
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
901
|
+
init.body = req.body;
|
|
902
|
+
init.duplex = "half";
|
|
903
|
+
}
|
|
904
|
+
return globalThis.fetch(pinnedUrl.toString(), init);
|
|
905
|
+
}
|
|
906
|
+
let rawFetch = pinnedFetch;
|
|
907
|
+
export function _setRawFetch(fn) {
|
|
908
|
+
rawFetch = fn ?? pinnedFetch;
|
|
909
|
+
}
|
|
910
|
+
/**
|
|
911
|
+
* Issue a request. Redirects, when followed at all, are followed by hand so
|
|
912
|
+
* the allow-list, the SSRF gate and the credential rules run on every single
|
|
913
|
+
* hop. The response body is left unread — the caller decides how to drain it
|
|
914
|
+
* under which cap.
|
|
915
|
+
*/
|
|
916
|
+
export async function openRequest(o) {
|
|
917
|
+
const policy = o.redirect ?? "refuse";
|
|
918
|
+
const limit = o.maxRedirects ?? MAX_REDIRECTS;
|
|
919
|
+
const headers = { ...o.headers };
|
|
920
|
+
const isCredential = (name) => {
|
|
921
|
+
const lower = name.toLowerCase();
|
|
922
|
+
return CREDENTIAL_HEADERS.has(lower) || o.credentialHeaders?.has(lower) === true;
|
|
923
|
+
};
|
|
924
|
+
let redirects = 0;
|
|
925
|
+
let credentialsDropped = false;
|
|
926
|
+
let current = o.url;
|
|
927
|
+
let currentOrigin = canonicalizeUrlOrigin(o.url);
|
|
928
|
+
for (let hop = 0;; hop++) {
|
|
929
|
+
assertNoUserinfo(current);
|
|
930
|
+
assertOriginAllowed(current, o.cfg);
|
|
931
|
+
const pinnedIp = await assertNotSsrf(current.hostname);
|
|
932
|
+
const init = {
|
|
933
|
+
method: o.method,
|
|
934
|
+
redirect: "manual",
|
|
935
|
+
signal: o.signal,
|
|
936
|
+
headers,
|
|
937
|
+
...(o.body !== undefined && o.method !== "GET" && o.method !== "HEAD"
|
|
938
|
+
? { body: o.body }
|
|
939
|
+
: {}),
|
|
940
|
+
};
|
|
941
|
+
const res = await rawFetch(new Request(current.toString(), init), pinnedIp);
|
|
942
|
+
const isRedirect = res.status >= 300 && res.status < 400 && res.headers.has("location");
|
|
943
|
+
if (!isRedirect)
|
|
944
|
+
return { res, redirects, credentialsDropped };
|
|
945
|
+
if (policy === "refuse") {
|
|
946
|
+
await discard(res);
|
|
947
|
+
// Deliberately not printing the Location: a redirect target chosen by
|
|
948
|
+
// the far end is content, and this refusal is read by a model.
|
|
949
|
+
throw new NotifyPermissionError(`the endpoint answered ${res.status} with a redirect, which a send never follows — replaying the body at another origin, or downgrading to a GET that delivers nothing, are both worse than stopping. Point the configuration at the endpoint that actually accepts the message.`);
|
|
950
|
+
}
|
|
951
|
+
if (hop >= limit) {
|
|
952
|
+
await discard(res);
|
|
953
|
+
throw new NotifyPermissionError(`too many redirects (>${limit})`);
|
|
954
|
+
}
|
|
955
|
+
const location = res.headers.get("location") ?? "";
|
|
956
|
+
let next;
|
|
957
|
+
try {
|
|
958
|
+
next = new URL(location, current);
|
|
959
|
+
}
|
|
960
|
+
catch {
|
|
961
|
+
await discard(res);
|
|
962
|
+
throw new NotifyPermissionError("the endpoint sent a redirect this tool could not parse");
|
|
963
|
+
}
|
|
964
|
+
if (next.protocol !== "http:" && next.protocol !== "https:") {
|
|
965
|
+
await discard(res);
|
|
966
|
+
throw new NotifyPermissionError(`denied: redirect to scheme "${next.protocol}" — only http/https are allowed`);
|
|
967
|
+
}
|
|
968
|
+
const nextOrigin = canonicalizeUrlOrigin(next);
|
|
969
|
+
if (nextOrigin !== currentOrigin) {
|
|
970
|
+
// The token was minted for the origin we asked, not for wherever it
|
|
971
|
+
// points us next. Drop it before the socket opens — including whatever
|
|
972
|
+
// header the auth profile chose, which is a secret even when it is not
|
|
973
|
+
// called `Authorization`.
|
|
974
|
+
for (const name of Object.keys(headers)) {
|
|
975
|
+
if (isCredential(name)) {
|
|
976
|
+
delete headers[name];
|
|
977
|
+
credentialsDropped = true;
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
await discard(res);
|
|
982
|
+
redirects += 1;
|
|
983
|
+
current = next;
|
|
984
|
+
currentOrigin = nextOrigin;
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
async function discard(res) {
|
|
988
|
+
try {
|
|
989
|
+
await res.body?.cancel();
|
|
990
|
+
}
|
|
991
|
+
catch {
|
|
992
|
+
// already closed — nothing to release
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
/**
|
|
996
|
+
* Drain a body with a hard byte cap, cancelling the stream the moment the cap
|
|
997
|
+
* is passed so a hostile endpoint cannot pin memory. The cap bounds what is
|
|
998
|
+
* ever held, not what is kept after buffering.
|
|
999
|
+
*/
|
|
1000
|
+
export async function readCapped(res, maxBytes) {
|
|
1001
|
+
if (res.body === null)
|
|
1002
|
+
return { text: "", bytes: 0, truncated: false };
|
|
1003
|
+
const reader = res.body.getReader();
|
|
1004
|
+
const chunks = [];
|
|
1005
|
+
let total = 0;
|
|
1006
|
+
let truncated = false;
|
|
1007
|
+
try {
|
|
1008
|
+
while (true) {
|
|
1009
|
+
const { done, value } = await reader.read();
|
|
1010
|
+
if (done || value === undefined)
|
|
1011
|
+
break;
|
|
1012
|
+
if (total + value.byteLength > maxBytes) {
|
|
1013
|
+
chunks.push(value.subarray(0, maxBytes - total));
|
|
1014
|
+
total = maxBytes;
|
|
1015
|
+
truncated = true;
|
|
1016
|
+
try {
|
|
1017
|
+
await reader.cancel();
|
|
1018
|
+
}
|
|
1019
|
+
catch {
|
|
1020
|
+
// already aborting
|
|
1021
|
+
}
|
|
1022
|
+
break;
|
|
1023
|
+
}
|
|
1024
|
+
chunks.push(value);
|
|
1025
|
+
total += value.byteLength;
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
finally {
|
|
1029
|
+
try {
|
|
1030
|
+
reader.releaseLock();
|
|
1031
|
+
}
|
|
1032
|
+
catch {
|
|
1033
|
+
// already released
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
const merged = new Uint8Array(total);
|
|
1037
|
+
let offset = 0;
|
|
1038
|
+
for (const chunk of chunks) {
|
|
1039
|
+
merged.set(chunk, offset);
|
|
1040
|
+
offset += chunk.byteLength;
|
|
1041
|
+
}
|
|
1042
|
+
return {
|
|
1043
|
+
text: new TextDecoder("utf-8", { fatal: false }).decode(merged),
|
|
1044
|
+
bytes: total,
|
|
1045
|
+
truncated,
|
|
1046
|
+
};
|
|
1047
|
+
}
|
|
1048
|
+
/** A URL the caller supplied, or a readable refusal that does not echo it. */
|
|
1049
|
+
export function parseUrl(raw) {
|
|
1050
|
+
try {
|
|
1051
|
+
return new URL(raw);
|
|
1052
|
+
}
|
|
1053
|
+
catch {
|
|
1054
|
+
return "that is not an absolute URL — include the scheme, e.g. https://hooks.example.com/services/… (the value has not been echoed back, in case it was a webhook URL, which is itself a credential)";
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
/** Read a dotted path out of a parsed JSON body. `undefined` when absent. */
|
|
1058
|
+
export function readPath(value, path) {
|
|
1059
|
+
let current = value;
|
|
1060
|
+
for (const segment of path.split(".")) {
|
|
1061
|
+
if (segment === "")
|
|
1062
|
+
continue;
|
|
1063
|
+
if (current === null || typeof current !== "object")
|
|
1064
|
+
return undefined;
|
|
1065
|
+
if (Array.isArray(current)) {
|
|
1066
|
+
const index = Number.parseInt(segment, 10);
|
|
1067
|
+
if (!Number.isInteger(index))
|
|
1068
|
+
return undefined;
|
|
1069
|
+
current = current[index];
|
|
1070
|
+
continue;
|
|
1071
|
+
}
|
|
1072
|
+
current = current[segment];
|
|
1073
|
+
}
|
|
1074
|
+
return current;
|
|
1075
|
+
}
|
|
1076
|
+
/**
|
|
1077
|
+
* Turn anything thrown on the outbound path into the one-line string a tool
|
|
1078
|
+
* returns. A refusal, a deadline and a transport failure read differently,
|
|
1079
|
+
* because the caller's next move differs.
|
|
1080
|
+
*/
|
|
1081
|
+
export function describeFailure(err, deadline) {
|
|
1082
|
+
if (err instanceof NotifyPermissionError)
|
|
1083
|
+
return err.message;
|
|
1084
|
+
if (deadline?.expired() === true)
|
|
1085
|
+
return "deadline elapsed before the send completed";
|
|
1086
|
+
if (err instanceof Error) {
|
|
1087
|
+
if (err.name === "AbortError")
|
|
1088
|
+
return "the send was aborted before it completed";
|
|
1089
|
+
return `${err.name}: ${err.message}`;
|
|
1090
|
+
}
|
|
1091
|
+
return String(err);
|
|
1092
|
+
}
|
|
1093
|
+
/**
|
|
1094
|
+
* The per-process record of what has already been sent under which key.
|
|
1095
|
+
*
|
|
1096
|
+
* A retry loop that re-runs a tool call is the normal failure mode of an
|
|
1097
|
+
* agent, and "post it again" is the wrong answer when the first post
|
|
1098
|
+
* succeeded. Every sending tool takes an `idempotencyKey`; the key is passed
|
|
1099
|
+
* to the provider when the provider honours one, AND recorded here, so a
|
|
1100
|
+
* second call with the same key returns the first call's result and posts
|
|
1101
|
+
* nothing.
|
|
1102
|
+
*
|
|
1103
|
+
* This is deliberate hidden state, and it is the one place in the package
|
|
1104
|
+
* where a repeated call does not repeat its effect — which is the entire
|
|
1105
|
+
* point. It lives for the life of the process, is capped so a long run
|
|
1106
|
+
* cannot grow it without bound, and is keyed by tool as well as key so two
|
|
1107
|
+
* different tools cannot collide on the same string.
|
|
1108
|
+
*/
|
|
1109
|
+
const LEDGER_LIMIT = 512;
|
|
1110
|
+
const ledger = new Map();
|
|
1111
|
+
export function ledgerLookup(tool, key) {
|
|
1112
|
+
if (key === undefined || key === "")
|
|
1113
|
+
return undefined;
|
|
1114
|
+
return ledger.get(`${tool}\u0000${key}`);
|
|
1115
|
+
}
|
|
1116
|
+
export function ledgerRecord(tool, key, result) {
|
|
1117
|
+
if (key === undefined || key === "")
|
|
1118
|
+
return;
|
|
1119
|
+
const id = `${tool}\u0000${key}`;
|
|
1120
|
+
if (ledger.size >= LEDGER_LIMIT && !ledger.has(id)) {
|
|
1121
|
+
// Oldest first — Map preserves insertion order.
|
|
1122
|
+
const oldest = ledger.keys().next();
|
|
1123
|
+
if (!oldest.done)
|
|
1124
|
+
ledger.delete(oldest.value);
|
|
1125
|
+
}
|
|
1126
|
+
ledger.set(id, { tool, result });
|
|
1127
|
+
}
|
|
1128
|
+
/** Test-only — a fresh process's empty ledger. */
|
|
1129
|
+
export function _resetIdempotencyLedger() {
|
|
1130
|
+
ledger.clear();
|
|
1131
|
+
}
|