@crewhaus/tool-notify 0.7.0 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/NOTICE +9 -0
- package/README.md +58 -5
- package/dist/index.js +371 -96
- package/dist/lib/instant.d.ts +57 -0
- package/dist/lib/instant.js +188 -0
- package/dist/lib/preflight.js +7 -9
- package/dist/lib/quiet.d.ts +3 -1
- package/dist/lib/quiet.js +48 -15
- package/dist/lib/ratelimit.js +6 -3
- package/dist/lib/template.js +8 -1
- package/dist/lib/webhook-path.d.ts +11 -0
- package/dist/lib/webhook-path.js +42 -0
- package/dist/net.d.ts +189 -17
- package/dist/net.js +388 -142
- package/dist/paths.d.ts +21 -2
- package/dist/paths.js +72 -13
- package/package.json +13 -6
package/dist/net.js
CHANGED
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
* twice:
|
|
9
9
|
*
|
|
10
10
|
* 1. Empty allow-list ⇒ deny all. There is no "allow everything" value,
|
|
11
|
-
* for origins, for SMTP hosts,
|
|
11
|
+
* for origins, for SMTP hosts, for email recipients, for SMS numbers or
|
|
12
|
+
* for push targets.
|
|
12
13
|
* 2. Scheme must be http or https.
|
|
13
14
|
* 3. Origin (scheme + lowercase host + non-default port) must match an
|
|
14
15
|
* allow-list entry exactly, after canonicalisation.
|
|
@@ -29,10 +30,13 @@
|
|
|
29
30
|
* real endpoint. Only the read-only `DeliveryCheck` follows, and then
|
|
30
31
|
* the allow-list, the SSRF gate and the credential rules run again on
|
|
31
32
|
* every hop.
|
|
32
|
-
* 7. Credentials are environment variable NAMES, never values
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
33
|
+
* 7. Credentials are environment variable NAMES, never values, and only
|
|
34
|
+
* names the operator listed (`allowed_secret_envs`, or a provider's own
|
|
35
|
+
* `auth.envVar` for that provider): a call chooses among them and can
|
|
36
|
+
* never add one. Whatever header a profile sets is dropped the moment a
|
|
37
|
+
* redirect leaves the origin it was minted for, and every result —
|
|
38
|
+
* success or failure — goes through a redactor built from the resolved
|
|
39
|
+
* secret.
|
|
36
40
|
* 8. Every request is deadline-bounded and every response is byte-capped,
|
|
37
41
|
* with the cap bounding memory rather than applied after buffering.
|
|
38
42
|
*
|
|
@@ -40,8 +44,12 @@
|
|
|
40
44
|
* cookie jar, and no certificate handling beyond the runtime's own.
|
|
41
45
|
*/
|
|
42
46
|
import { Buffer } from "node:buffer";
|
|
47
|
+
import { createHash } from "node:crypto";
|
|
43
48
|
import { lookup as dnsLookup, resolveTxt } from "node:dns/promises";
|
|
44
49
|
import { CrewhausError } from "@crewhaus/errors";
|
|
50
|
+
import { isE164Number, normalizePhoneNumber } from "@crewhaus/tool-permission-matcher";
|
|
51
|
+
import { REDACTED, createSecretRedactor, isEnvName, looksLikePastedSecret, resolveCredentialEnv, secretForms, trimSecretTail, } from "@crewhaus/tool-safety/env";
|
|
52
|
+
import { fetchRaw, readResponseBounded } from "@crewhaus/tool-safety/streams";
|
|
45
53
|
import { joinTxtChunks, normalizeDomain } from "./lib/dns-records";
|
|
46
54
|
/** Refusal by the allow-list, the SSRF gate, or a redirect rule. */
|
|
47
55
|
export class NotifyPermissionError extends CrewhausError {
|
|
@@ -61,12 +69,17 @@ export const json = (value) => JSON.stringify(value);
|
|
|
61
69
|
export function byString(a, b) {
|
|
62
70
|
return a < b ? -1 : a > b ? 1 : 0;
|
|
63
71
|
}
|
|
72
|
+
/** Where an operator allows a credential variable. Every refusal names it. */
|
|
73
|
+
export const SECRET_ENVS_KEY = "tool_config.notify.allowed_secret_envs";
|
|
64
74
|
const EMPTY_CONFIG = {
|
|
65
75
|
allowedOrigins: new Set(),
|
|
66
76
|
allowedRecipients: [],
|
|
67
77
|
allowedSmtpHosts: new Set(),
|
|
68
78
|
allowedSenderDomains: new Set(),
|
|
69
79
|
providers: new Map(),
|
|
80
|
+
allowedSecretEnvs: [],
|
|
81
|
+
allowedSmsRecipients: [],
|
|
82
|
+
allowedPushTargets: [],
|
|
70
83
|
};
|
|
71
84
|
let notifyConfig = EMPTY_CONFIG;
|
|
72
85
|
/** Build a config from a spec-shaped block. Pure; both key spellings accepted. */
|
|
@@ -111,8 +124,114 @@ export function buildNotifyConfig(input) {
|
|
|
111
124
|
allowedSmtpHosts: smtpHosts,
|
|
112
125
|
allowedSenderDomains: senderDomains,
|
|
113
126
|
providers,
|
|
127
|
+
allowedSecretEnvs: buildSecretEnvs(input.allowedSecretEnvs ?? input.allowed_secret_envs),
|
|
128
|
+
allowedSmsRecipients: buildDestinations(input.allowedSmsRecipients ?? input.allowed_sms_recipients, "sms"),
|
|
129
|
+
allowedPushTargets: buildDestinations(input.allowedPushTargets ?? input.allowed_push_targets, "push"),
|
|
114
130
|
};
|
|
115
131
|
}
|
|
132
|
+
const DESTINATION_KEYS = {
|
|
133
|
+
sms: "allowed_sms_recipients",
|
|
134
|
+
push: "allowed_push_targets",
|
|
135
|
+
};
|
|
136
|
+
/**
|
|
137
|
+
* A phone number as the SMS allow-list compares it: the separators people
|
|
138
|
+
* write (spaces, `-`, `.`, parentheses) removed wherever they are, so
|
|
139
|
+
* `+1 (555) 000-1111` and `+15550001111` are the same number. It is the
|
|
140
|
+
* permission matcher's own reading, so a deny rule decides on the number
|
|
141
|
+
* this tool checks and sends.
|
|
142
|
+
*/
|
|
143
|
+
export const normalizeSmsNumber = normalizePhoneNumber;
|
|
144
|
+
/**
|
|
145
|
+
* Check a destination allow-list at boot. A malformed entry throws, so a
|
|
146
|
+
* misconfiguration surfaces when the harness starts. There is no "allow
|
|
147
|
+
* everything" value: a bare `*` is refused, as an empty allow-list elsewhere
|
|
148
|
+
* here refuses everyone.
|
|
149
|
+
*/
|
|
150
|
+
function buildDestinations(raw, kind) {
|
|
151
|
+
const key = `tool_config.notify.${DESTINATION_KEYS[kind]}`;
|
|
152
|
+
if (raw === undefined || raw === null)
|
|
153
|
+
return [];
|
|
154
|
+
if (!Array.isArray(raw))
|
|
155
|
+
throw new NotifyPermissionError(`${key} must be a list`);
|
|
156
|
+
const out = new Set();
|
|
157
|
+
raw.forEach((entry, index) => {
|
|
158
|
+
const where = `${key} entry ${index + 1}`;
|
|
159
|
+
if (typeof entry !== "string")
|
|
160
|
+
throw new NotifyPermissionError(`${where} is not a string`);
|
|
161
|
+
if (kind === "sms") {
|
|
162
|
+
const normalized = normalizeSmsNumber(entry);
|
|
163
|
+
const digits = normalized.endsWith("*") ? normalized.slice(0, -1) : normalized;
|
|
164
|
+
if (!isE164Number(digits)) {
|
|
165
|
+
throw new NotifyPermissionError(`${where} must be a number in E.164 form (a + and up to fifteen digits, such as +15550001111), or a prefix of one ending in * (such as +44*); there is no allow-everything value`);
|
|
166
|
+
}
|
|
167
|
+
out.add(normalized);
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
const target = entry.trim();
|
|
171
|
+
const star = target.indexOf("*");
|
|
172
|
+
if (target === "" || target === "*" || (star !== -1 && star !== target.length - 1)) {
|
|
173
|
+
throw new NotifyPermissionError(`${where} must be a device token or topic, or a prefix of one ending in * (such as topic:ops-*); there is no allow-everything value`);
|
|
174
|
+
}
|
|
175
|
+
out.add(target);
|
|
176
|
+
});
|
|
177
|
+
return [...out].sort(byString);
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* A provider send's destination as its list compares it, and as it is sent:
|
|
181
|
+
* an SMS number normalised (`+1 (555) 000-1111` is `+15550001111`), a push
|
|
182
|
+
* target trimmed. Sending the call's own spelling would let a gateway read
|
|
183
|
+
* it as another number than the one the list and a permission rule decided on.
|
|
184
|
+
*/
|
|
185
|
+
export function destinationFor(to, kind) {
|
|
186
|
+
return kind === "sms" ? normalizeSmsNumber(to) : to.trim();
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Why a provider send's destination is refused, or `null` when the list
|
|
190
|
+
* admits it. The one matcher for both lists: an entry matches exactly, or,
|
|
191
|
+
* ending in `*`, as a prefix. An SMS number is compared in normalised E.164
|
|
192
|
+
* form, so a destination that is not a number in that form matches nothing.
|
|
193
|
+
*/
|
|
194
|
+
export function destinationRefusal(to, kind, cfg) {
|
|
195
|
+
const key = DESTINATION_KEYS[kind];
|
|
196
|
+
const patterns = kind === "sms" ? cfg.allowedSmsRecipients : cfg.allowedPushTargets;
|
|
197
|
+
const value = destinationFor(to, kind);
|
|
198
|
+
const usable = kind === "push" || isE164Number(value);
|
|
199
|
+
if (usable &&
|
|
200
|
+
patterns.some((p) => (p.endsWith("*") ? value.startsWith(p.slice(0, -1)) : value === p))) {
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
const what = kind === "sms"
|
|
204
|
+
? `${JSON.stringify(to)} is not in ${key}${usable ? "" : " (numbers are matched in E.164 form: a + and digits)"}`
|
|
205
|
+
: `that push target is not in ${key}`;
|
|
206
|
+
const how = kind === "sms"
|
|
207
|
+
? "An operator adds a number in E.164 form, or a prefix such as +44*"
|
|
208
|
+
: "An operator adds a device token or topic, or a prefix ending in *";
|
|
209
|
+
return patterns.length === 0
|
|
210
|
+
? `${what} (an empty allow-list refuses everyone). ${how}`
|
|
211
|
+
: `${what}. ${how}`;
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Check `allowed_secret_envs` at boot: a list of variable NAMES. A
|
|
215
|
+
* malformed entry throws, so a misconfiguration surfaces when the harness
|
|
216
|
+
* starts, and it is never quoted: an operator who pasted a token here, or
|
|
217
|
+
* wrote `$SLACK_BOT_TOKEN` (which the bundle resolves to the token itself),
|
|
218
|
+
* would otherwise see it printed.
|
|
219
|
+
*/
|
|
220
|
+
function buildSecretEnvs(raw) {
|
|
221
|
+
if (raw === undefined || raw === null)
|
|
222
|
+
return [];
|
|
223
|
+
if (!Array.isArray(raw)) {
|
|
224
|
+
throw new NotifyPermissionError(`${SECRET_ENVS_KEY} must be a list of variable names`);
|
|
225
|
+
}
|
|
226
|
+
const names = new Set();
|
|
227
|
+
raw.forEach((name, index) => {
|
|
228
|
+
if (!isEnvName(name) || looksLikePastedSecret(name)) {
|
|
229
|
+
throw new NotifyPermissionError(`${SECRET_ENVS_KEY} lists environment variable NAMES (such as SLACK_BOT_TOKEN, written without a $); entry ${index + 1} is not one, and has not been echoed back`);
|
|
230
|
+
}
|
|
231
|
+
names.add(name);
|
|
232
|
+
});
|
|
233
|
+
return [...names].sort(byString);
|
|
234
|
+
}
|
|
116
235
|
/** Replace the process-global allow-list. Codegen calls this at boot. */
|
|
117
236
|
export function registerNotifyConfig(input) {
|
|
118
237
|
notifyConfig = buildNotifyConfig(input);
|
|
@@ -296,7 +415,18 @@ const MAX_TXT_RECORD_CHARS = 8192;
|
|
|
296
415
|
function withDeadline(work, deadline) {
|
|
297
416
|
return new Promise((resolve, reject) => {
|
|
298
417
|
const signal = deadline.signal;
|
|
299
|
-
|
|
418
|
+
// The signal's own reason, so the caller can tell the deadline's timer
|
|
419
|
+
// from a runtime cancel by what it caught rather than by the clock.
|
|
420
|
+
const fail = () => {
|
|
421
|
+
const reason = signal.reason;
|
|
422
|
+
if (reason instanceof Error) {
|
|
423
|
+
reject(reason);
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
const err = new Error("aborted");
|
|
427
|
+
err.name = "AbortError";
|
|
428
|
+
reject(err);
|
|
429
|
+
};
|
|
300
430
|
if (signal.aborted) {
|
|
301
431
|
fail();
|
|
302
432
|
return;
|
|
@@ -328,7 +458,10 @@ export async function lookupTxt(name, deadline) {
|
|
|
328
458
|
raw = await withDeadline(dnsTxtFn(name), deadline);
|
|
329
459
|
}
|
|
330
460
|
catch (err) {
|
|
331
|
-
|
|
461
|
+
// An answer that arrived after the deadline's time is still the answer
|
|
462
|
+
// (an NXDOMAIN is still an NXDOMAIN); only the timer's abort is a
|
|
463
|
+
// deadline.
|
|
464
|
+
if (isDeadlineAbort(err, deadline)) {
|
|
332
465
|
return { outcome: "unknown", reason: `the lookup of "${name}" hit the deadline` };
|
|
333
466
|
}
|
|
334
467
|
const code = err.code;
|
|
@@ -665,6 +798,19 @@ export function expandIpv6(raw) {
|
|
|
665
798
|
const groups = parseIpv6(raw);
|
|
666
799
|
return groups === null ? null : [...groups];
|
|
667
800
|
}
|
|
801
|
+
// ---------------------------------------------------------------------------
|
|
802
|
+
// deadlines
|
|
803
|
+
// ---------------------------------------------------------------------------
|
|
804
|
+
/**
|
|
805
|
+
* The reason a deadline's own timer aborts its signal with. An error is
|
|
806
|
+
* traced to the deadline by this reason, never by the clock: a transport
|
|
807
|
+
* failure that merely ARRIVES after the deadline's time (a starved event
|
|
808
|
+
* loop delivers it before the timer callback runs) is that failure, and a
|
|
809
|
+
* runtime cancel aborts with the runtime's reason instead.
|
|
810
|
+
*/
|
|
811
|
+
export class DeadlineElapsedError extends Error {
|
|
812
|
+
name = "TimeoutError";
|
|
813
|
+
}
|
|
668
814
|
/**
|
|
669
815
|
* A deadline that also honours the runtime's own cancellation. Every
|
|
670
816
|
* outbound tool here opens one before its first byte and cancels it in a
|
|
@@ -673,7 +819,7 @@ export function expandIpv6(raw) {
|
|
|
673
819
|
export function startDeadline(ms, outer) {
|
|
674
820
|
const ctrl = new AbortController();
|
|
675
821
|
const startedAt = Date.now();
|
|
676
|
-
const timer = setTimeout(() => ctrl.abort(new
|
|
822
|
+
const timer = setTimeout(() => ctrl.abort(new DeadlineElapsedError(`deadline of ${ms}ms elapsed`)), ms);
|
|
677
823
|
const onOuter = () => ctrl.abort(outer?.reason);
|
|
678
824
|
if (outer !== undefined) {
|
|
679
825
|
if (outer.aborted)
|
|
@@ -683,8 +829,9 @@ export function startDeadline(ms, outer) {
|
|
|
683
829
|
}
|
|
684
830
|
return {
|
|
685
831
|
signal: ctrl.signal,
|
|
686
|
-
remaining: () => Math.max(0, ms - (Date.now() - startedAt)),
|
|
832
|
+
remaining: () => (ctrl.signal.aborted ? 0 : Math.max(0, ms - (Date.now() - startedAt))),
|
|
687
833
|
expired: () => Date.now() - startedAt >= ms,
|
|
834
|
+
timedOut: () => ctrl.signal.aborted && ctrl.signal.reason instanceof DeadlineElapsedError,
|
|
688
835
|
cancel: () => {
|
|
689
836
|
clearTimeout(timer);
|
|
690
837
|
outer?.removeEventListener("abort", onOuter);
|
|
@@ -709,60 +856,30 @@ export function sleep(ms, signal) {
|
|
|
709
856
|
signal?.addEventListener("abort", onAbort, { once: true });
|
|
710
857
|
});
|
|
711
858
|
}
|
|
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
859
|
/**
|
|
737
|
-
* Read a secret out of the named environment variable
|
|
860
|
+
* Read a secret out of the named environment variable, if and only if
|
|
861
|
+
* `allowed` lists it.
|
|
738
862
|
*
|
|
739
|
-
* The NAME travels through the tool call; the value never does.
|
|
740
|
-
* is
|
|
741
|
-
*
|
|
742
|
-
*
|
|
743
|
-
*
|
|
863
|
+
* The NAME travels through the tool call; the value never does. `allowed`
|
|
864
|
+
* is the operator's `allowed_secret_envs` for a name a call supplies, or a
|
|
865
|
+
* provider's own `auth.envVar` for that provider's call: a call may choose
|
|
866
|
+
* among the listed names and never add one, so it cannot send another
|
|
867
|
+
* process secret (the LLM provider's key, say) to an allowed origin, where
|
|
868
|
+
* the egress classifier would see only the name. The refusal names the key
|
|
869
|
+
* to set, reads the same whether or not an unlisted variable is set, and
|
|
870
|
+
* never quotes a value or a "name" that is really a pasted secret, because a
|
|
871
|
+
* tool result is a transcript, a trace and usually an eval report.
|
|
744
872
|
*/
|
|
745
|
-
export function resolveSecret(envVar, what, env = process.env) {
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
}
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
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 };
|
|
873
|
+
export function resolveSecret(envVar, what, allowed, env = process.env) {
|
|
874
|
+
const resolved = resolveCredentialEnv(envVar, {
|
|
875
|
+
allowed,
|
|
876
|
+
purpose: what,
|
|
877
|
+
configKey: SECRET_ENVS_KEY,
|
|
878
|
+
env,
|
|
879
|
+
});
|
|
880
|
+
return resolved.ok
|
|
881
|
+
? { ok: true, value: resolved.value }
|
|
882
|
+
: { ok: false, message: resolved.reason };
|
|
766
883
|
}
|
|
767
884
|
/** Header names that must never be supplied inline, and never survive a hop. */
|
|
768
885
|
export const CREDENTIAL_HEADERS = new Set([
|
|
@@ -789,10 +906,10 @@ export function rejectInlineCredentials(headers) {
|
|
|
789
906
|
* guarded exactly as hard as one that puts it in `Authorization` — and the
|
|
790
907
|
* resolved secret values, which feed the redactor.
|
|
791
908
|
*/
|
|
792
|
-
export function applyAuth(headers, auth, env = process.env) {
|
|
909
|
+
export function applyAuth(headers, auth, allowed, env = process.env) {
|
|
793
910
|
if (auth === undefined)
|
|
794
911
|
return { ok: true, secretHeaders: new Set(), secrets: [] };
|
|
795
|
-
const resolved = resolveSecret(auth.envVar, "auth profile
|
|
912
|
+
const resolved = resolveSecret(auth.envVar, "the auth profile", allowed, env);
|
|
796
913
|
if (!resolved.ok)
|
|
797
914
|
return { ok: false, message: resolved.message };
|
|
798
915
|
const secret = resolved.value;
|
|
@@ -807,9 +924,16 @@ export function applyAuth(headers, auth, env = process.env) {
|
|
|
807
924
|
message: 'auth type "basic" needs a username; the password comes from envVar',
|
|
808
925
|
};
|
|
809
926
|
}
|
|
810
|
-
const
|
|
927
|
+
const publicPrefix = `${auth.username}:`;
|
|
928
|
+
const encoded = Buffer.from(`${publicPrefix}${secret}`, "utf8").toString("base64");
|
|
811
929
|
headers["Authorization"] = `Basic ${encoded}`;
|
|
812
|
-
|
|
930
|
+
// The pair's `user:` is the account name, not a secret: a reply that
|
|
931
|
+
// ends with it is left alone, while every spelling of the pair is not.
|
|
932
|
+
return {
|
|
933
|
+
ok: true,
|
|
934
|
+
secretHeaders: new Set(["authorization"]),
|
|
935
|
+
secrets: [secret, { publicPrefix, secret }],
|
|
936
|
+
};
|
|
813
937
|
}
|
|
814
938
|
if (auth.headerName === undefined) {
|
|
815
939
|
return { ok: false, message: 'auth type "header" needs a headerName' };
|
|
@@ -835,31 +959,42 @@ export function applyAuth(headers, auth, env = process.env) {
|
|
|
835
959
|
*
|
|
836
960
|
* Secrets shorter than six characters are left alone: replacing every "x" in
|
|
837
961
|
* a result would mangle it without protecting anything real.
|
|
962
|
+
*
|
|
963
|
+
* This is tool-safety's redactor: every spelling of a secret, and the part
|
|
964
|
+
* of one a cut left at a string's end. A cut this package makes itself is
|
|
965
|
+
* trimmed where it is made ({@link readCapped}, {@link excerptOf}), because a
|
|
966
|
+
* cut inside a JSON result is not at the end of the text this runs on (net
|
|
967
|
+
* attacker review: a byte cap through an echoed header returned all but one
|
|
968
|
+
* character of the credential).
|
|
969
|
+
*
|
|
970
|
+
* `identifiers` (an SMTP username) are replaced only whole, as 0.7.0 did:
|
|
971
|
+
* they are not secrets, so the start of one at a string's end is not cut.
|
|
838
972
|
*/
|
|
839
|
-
export function redactorFor(secrets) {
|
|
840
|
-
const
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
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;
|
|
973
|
+
export function redactorFor(secrets, identifiers = []) {
|
|
974
|
+
const redactSecrets = createSecretRedactor(secrets);
|
|
975
|
+
const idForms = [
|
|
976
|
+
...new Set(identifiers.flatMap((v) => v === undefined || v.trim().length < 6 ? [] : secretForms(v).filter((f) => f.length >= 6))),
|
|
977
|
+
].sort((a, b) => b.length - a.length);
|
|
856
978
|
return (text) => {
|
|
857
|
-
let out = text;
|
|
858
|
-
for (const form of
|
|
859
|
-
|
|
979
|
+
let out = redactSecrets(text);
|
|
980
|
+
for (const form of idForms)
|
|
981
|
+
if (out.includes(form))
|
|
982
|
+
out = out.split(form).join(REDACTED);
|
|
860
983
|
return out;
|
|
861
984
|
};
|
|
862
985
|
}
|
|
986
|
+
/** The most of a reply an error message quotes. */
|
|
987
|
+
export const EXCERPT_CHARS = 500;
|
|
988
|
+
/**
|
|
989
|
+
* The first {@link EXCERPT_CHARS} of a reply, for an error message. When
|
|
990
|
+
* that cuts the reply, whatever part of an echoed credential the cut leaves
|
|
991
|
+
* at the end is trimmed, since the redactor matches whole spellings.
|
|
992
|
+
*/
|
|
993
|
+
export function excerptOf(text, secrets) {
|
|
994
|
+
if (text.length <= EXCERPT_CHARS)
|
|
995
|
+
return text;
|
|
996
|
+
return trimSecretTail(text.slice(0, EXCERPT_CHARS), secrets);
|
|
997
|
+
}
|
|
863
998
|
/** Response headers as a sorted plain object, credentials removed. */
|
|
864
999
|
const STRIPPED_RESPONSE_HEADERS = new Set([
|
|
865
1000
|
"authorization",
|
|
@@ -879,13 +1014,19 @@ export function responseHeaders(res) {
|
|
|
879
1014
|
/**
|
|
880
1015
|
* Dial the vetted IP while keeping the real hostname for the `Host` header
|
|
881
1016
|
* and TLS SNI, so virtual hosting and certificate validation still work.
|
|
1017
|
+
*
|
|
1018
|
+
* Both branches fetch with the body kept RAW (`fetchRaw`, Bun's
|
|
1019
|
+
* `decompress: false`). Without it Bun inflates a gzip, deflate, br or zstd
|
|
1020
|
+
* reply in native code before any reader sees a byte, so a 300 KB gzip reply
|
|
1021
|
+
* cost about 860 MB however small `maxBytes` was (security-5#7).
|
|
1022
|
+
* {@link readCapped} decodes the body itself, under the cap.
|
|
882
1023
|
*/
|
|
883
1024
|
function pinnedFetch(req, pinnedIp) {
|
|
884
1025
|
const original = new URL(req.url);
|
|
885
1026
|
const host = original.hostname;
|
|
886
1027
|
const unbracketed = host.replace(/^\[/, "").replace(/\]$/, "");
|
|
887
1028
|
if (unbracketed === pinnedIp || pinnedIp === "")
|
|
888
|
-
return
|
|
1029
|
+
return fetchRaw(req);
|
|
889
1030
|
const pinnedUrl = new URL(original.toString());
|
|
890
1031
|
pinnedUrl.hostname = pinnedIp.includes(":") ? `[${pinnedIp}]` : pinnedIp;
|
|
891
1032
|
const headers = new Headers(req.headers);
|
|
@@ -901,7 +1042,7 @@ function pinnedFetch(req, pinnedIp) {
|
|
|
901
1042
|
init.body = req.body;
|
|
902
1043
|
init.duplex = "half";
|
|
903
1044
|
}
|
|
904
|
-
return
|
|
1045
|
+
return fetchRaw(pinnedUrl.toString(), init);
|
|
905
1046
|
}
|
|
906
1047
|
let rawFetch = pinnedFetch;
|
|
907
1048
|
export function _setRawFetch(fn) {
|
|
@@ -921,6 +1062,13 @@ export async function openRequest(o) {
|
|
|
921
1062
|
const lower = name.toLowerCase();
|
|
922
1063
|
return CREDENTIAL_HEADERS.has(lower) || o.credentialHeaders?.has(lower) === true;
|
|
923
1064
|
};
|
|
1065
|
+
// Ask for the body as it is. The reader decodes gzip, deflate, br and zstd
|
|
1066
|
+
// under the cap anyway, so a provider that compresses regardless still
|
|
1067
|
+
// works; asking for identity just spares the decoding. A caller that set
|
|
1068
|
+
// its own Accept-Encoding keeps it.
|
|
1069
|
+
if (!Object.keys(headers).some((name) => name.toLowerCase() === "accept-encoding")) {
|
|
1070
|
+
headers["accept-encoding"] = "identity";
|
|
1071
|
+
}
|
|
924
1072
|
let redirects = 0;
|
|
925
1073
|
let credentialsDropped = false;
|
|
926
1074
|
let current = o.url;
|
|
@@ -993,57 +1141,45 @@ async function discard(res) {
|
|
|
993
1141
|
}
|
|
994
1142
|
}
|
|
995
1143
|
/**
|
|
996
|
-
* Drain a body with a hard byte cap
|
|
997
|
-
*
|
|
998
|
-
*
|
|
1144
|
+
* Drain a body with a hard byte cap on its DECODED size, so a hostile
|
|
1145
|
+
* endpoint cannot pin memory: the body arrives raw (see `pinnedFetch`), a
|
|
1146
|
+
* gzip, deflate, br or zstd body is decoded here in small steps, and the
|
|
1147
|
+
* decoder stops once `maxBytes` exist. The cap bounds what is ever held, not
|
|
1148
|
+
* what is kept after buffering.
|
|
1149
|
+
*
|
|
1150
|
+
* A body this reader cannot decode within the bound (an unknown or stacked
|
|
1151
|
+
* coding, a corrupt stream) is a refusal that quotes nothing the endpoint
|
|
1152
|
+
* sent. An aborted read throws an `AbortError`, so `describeFailure` reports
|
|
1153
|
+
* the deadline or the abort as it does for the request itself.
|
|
999
1154
|
*/
|
|
1000
|
-
export async function readCapped(res, maxBytes) {
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
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
|
-
}
|
|
1155
|
+
export async function readCapped(res, maxBytes, signal, secrets = []) {
|
|
1156
|
+
const read = await readResponseBounded(res, {
|
|
1157
|
+
maxBytes,
|
|
1158
|
+
...(signal !== undefined ? { signal } : {}),
|
|
1159
|
+
});
|
|
1160
|
+
if (read.ok) {
|
|
1161
|
+
return {
|
|
1162
|
+
// A cut can fall inside a credential the endpoint echoed; what it
|
|
1163
|
+
// leaves is a prefix no whole spelling matches, so it goes here.
|
|
1164
|
+
text: read.truncated ? trimSecretTail(read.text, secrets) : read.text,
|
|
1165
|
+
bytes: read.bytes.byteLength,
|
|
1166
|
+
truncated: read.truncated,
|
|
1167
|
+
};
|
|
1027
1168
|
}
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1169
|
+
switch (read.code) {
|
|
1170
|
+
case "aborted": {
|
|
1171
|
+
const err = new Error("the read was aborted before the reply ended");
|
|
1172
|
+
err.name = "AbortError";
|
|
1173
|
+
throw err;
|
|
1031
1174
|
}
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
merged.set(chunk, offset);
|
|
1040
|
-
offset += chunk.byteLength;
|
|
1175
|
+
case "unsupported-encoding":
|
|
1176
|
+
throw new NotifyPermissionError("the endpoint sent its reply in a stack of content-encodings this tool cannot decode within its byte cap, so the reply was not read");
|
|
1177
|
+
case "decode-error":
|
|
1178
|
+
case "auto-decompressed":
|
|
1179
|
+
throw new NotifyPermissionError("the endpoint's reply is labelled as compressed but could not be decoded, so it was not read");
|
|
1180
|
+
default:
|
|
1181
|
+
throw new NotifyPermissionError("the endpoint's reply could not be read to the end");
|
|
1041
1182
|
}
|
|
1042
|
-
return {
|
|
1043
|
-
text: new TextDecoder("utf-8", { fatal: false }).decode(merged),
|
|
1044
|
-
bytes: total,
|
|
1045
|
-
truncated,
|
|
1046
|
-
};
|
|
1047
1183
|
}
|
|
1048
1184
|
/** A URL the caller supplied, or a readable refusal that does not echo it. */
|
|
1049
1185
|
export function parseUrl(raw) {
|
|
@@ -1054,6 +1190,42 @@ export function parseUrl(raw) {
|
|
|
1054
1190
|
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
1191
|
}
|
|
1056
1192
|
}
|
|
1193
|
+
/**
|
|
1194
|
+
* True for a value made only of dots. `.` and `..` are the two values
|
|
1195
|
+
* `encodeURIComponent` does NOT neutralise: they encode to themselves, and
|
|
1196
|
+
* the URL parser then RESOLVES them, so a message id of `..` substituted
|
|
1197
|
+
* into `/channels/1/messages/{id}` addresses `/channels/1/` — the channel,
|
|
1198
|
+
* not a message. A dot segment is never an id.
|
|
1199
|
+
*/
|
|
1200
|
+
export function isDotSegment(value) {
|
|
1201
|
+
return /^\.+$/.test(value);
|
|
1202
|
+
}
|
|
1203
|
+
/**
|
|
1204
|
+
* `template` with every `placeholder` replaced by `value`, percent-encoded,
|
|
1205
|
+
* as the URL that will be requested — or why not: `invalid` when the
|
|
1206
|
+
* template is not an absolute URL, `reshaped` when the parser would send
|
|
1207
|
+
* the request anywhere but the template's own path with the value written
|
|
1208
|
+
* in.
|
|
1209
|
+
*
|
|
1210
|
+
* The check is made on the URL the parser produced, not on the string it
|
|
1211
|
+
* was built from: the template is also parsed with a sentinel in the
|
|
1212
|
+
* value's place, and the two paths must match once the sentinel is the
|
|
1213
|
+
* value. Whatever normalisation the parser applies (a dot segment today),
|
|
1214
|
+
* a value that changes the shape of the path is refused.
|
|
1215
|
+
*/
|
|
1216
|
+
export function substituteIntoUrl(template, placeholder, value) {
|
|
1217
|
+
const encoded = encodeURIComponent(value);
|
|
1218
|
+
const parsed = parseUrl(template.split(placeholder).join(encoded));
|
|
1219
|
+
const shape = parseUrl(template.split(placeholder).join(PATH_SENTINEL));
|
|
1220
|
+
if (typeof parsed === "string" || typeof shape === "string")
|
|
1221
|
+
return { ok: false, why: "invalid" };
|
|
1222
|
+
if (parsed.pathname !== shape.pathname.split(PATH_SENTINEL).join(encoded)) {
|
|
1223
|
+
return { ok: false, why: "reshaped" };
|
|
1224
|
+
}
|
|
1225
|
+
return { ok: true, url: parsed };
|
|
1226
|
+
}
|
|
1227
|
+
/** A path segment no parser rewrites and no real value contains. */
|
|
1228
|
+
const PATH_SENTINEL = "crewhaus-substitution-4b1f0c";
|
|
1057
1229
|
/** Read a dotted path out of a parsed JSON body. `undefined` when absent. */
|
|
1058
1230
|
export function readPath(value, path) {
|
|
1059
1231
|
let current = value;
|
|
@@ -1081,15 +1253,71 @@ export function readPath(value, path) {
|
|
|
1081
1253
|
export function describeFailure(err, deadline) {
|
|
1082
1254
|
if (err instanceof NotifyPermissionError)
|
|
1083
1255
|
return err.message;
|
|
1084
|
-
|
|
1256
|
+
// The cause decides, not the clock: see DeadlineElapsedError.
|
|
1257
|
+
if (isDeadlineAbort(err, deadline))
|
|
1085
1258
|
return "deadline elapsed before the send completed";
|
|
1259
|
+
const aborted = "the send was aborted before it completed";
|
|
1260
|
+
if (deadline?.signal.aborted === true && err === deadline.signal.reason)
|
|
1261
|
+
return aborted;
|
|
1086
1262
|
if (err instanceof Error) {
|
|
1087
1263
|
if (err.name === "AbortError")
|
|
1088
|
-
return
|
|
1089
|
-
|
|
1264
|
+
return aborted;
|
|
1265
|
+
// A real failure that arrived late is still that failure; the clock is
|
|
1266
|
+
// mentioned, because the caller may want a longer deadline as well.
|
|
1267
|
+
const late = deadline?.expired() === true ? " (the deadline had also elapsed)" : "";
|
|
1268
|
+
return `${err.name}: ${err.message}${late}`;
|
|
1090
1269
|
}
|
|
1091
1270
|
return String(err);
|
|
1092
1271
|
}
|
|
1272
|
+
/**
|
|
1273
|
+
* Whether `err` is what the deadline's own timer did: its abort reason, or an
|
|
1274
|
+
* abort-shaped error (a body reader's AbortError) raised after that timer
|
|
1275
|
+
* fired. Exported for the few callers that report a deadline themselves.
|
|
1276
|
+
*/
|
|
1277
|
+
export function isDeadlineAbort(err, deadline) {
|
|
1278
|
+
if (err instanceof DeadlineElapsedError)
|
|
1279
|
+
return true;
|
|
1280
|
+
if (deadline?.timedOut() !== true)
|
|
1281
|
+
return false;
|
|
1282
|
+
if (err === deadline.signal.reason)
|
|
1283
|
+
return true;
|
|
1284
|
+
return err instanceof Error && (err.name === "AbortError" || err.name === "TimeoutError");
|
|
1285
|
+
}
|
|
1286
|
+
const DELIVERY_KNOBS = new Set([
|
|
1287
|
+
"idempotencyKey",
|
|
1288
|
+
"idempotencyHeader",
|
|
1289
|
+
"timeoutMs",
|
|
1290
|
+
"maxBytes",
|
|
1291
|
+
"retries",
|
|
1292
|
+
"backoffMs",
|
|
1293
|
+
"justification",
|
|
1294
|
+
]);
|
|
1295
|
+
/** Object keys sorted, so `{a, b}` and `{b, a}` are one request. */
|
|
1296
|
+
function sortedKeys(_key, value) {
|
|
1297
|
+
if (value === null || typeof value !== "object" || Array.isArray(value))
|
|
1298
|
+
return value;
|
|
1299
|
+
const sorted = Object.create(null);
|
|
1300
|
+
for (const key of Object.keys(value).sort()) {
|
|
1301
|
+
sorted[key] = value[key];
|
|
1302
|
+
}
|
|
1303
|
+
return sorted;
|
|
1304
|
+
}
|
|
1305
|
+
export function requestFingerprint(args) {
|
|
1306
|
+
const fields = new Map();
|
|
1307
|
+
for (const key of Object.keys(args).sort()) {
|
|
1308
|
+
const value = args[key];
|
|
1309
|
+
if (DELIVERY_KNOBS.has(key) || value === undefined)
|
|
1310
|
+
continue;
|
|
1311
|
+
const canonical = JSON.stringify(value, sortedKeys) ?? "undefined";
|
|
1312
|
+
fields.set(key, createHash("sha256").update(canonical, "utf8").digest("hex"));
|
|
1313
|
+
}
|
|
1314
|
+
return fields;
|
|
1315
|
+
}
|
|
1316
|
+
/** The fields two fingerprints disagree on, sorted; empty when they match. */
|
|
1317
|
+
function differingFields(a, b) {
|
|
1318
|
+
const names = new Set([...a.keys(), ...b.keys()]);
|
|
1319
|
+
return [...names].filter((name) => a.get(name) !== b.get(name)).sort(byString);
|
|
1320
|
+
}
|
|
1093
1321
|
/**
|
|
1094
1322
|
* The per-process record of what has already been sent under which key.
|
|
1095
1323
|
*
|
|
@@ -1097,8 +1325,14 @@ export function describeFailure(err, deadline) {
|
|
|
1097
1325
|
* agent, and "post it again" is the wrong answer when the first post
|
|
1098
1326
|
* succeeded. Every sending tool takes an `idempotencyKey`; the key is passed
|
|
1099
1327
|
* to the provider when the provider honours one, AND recorded here, so a
|
|
1100
|
-
* second call with the same key
|
|
1101
|
-
* nothing.
|
|
1328
|
+
* second call with the same key and the same request returns the first
|
|
1329
|
+
* call's result and posts nothing.
|
|
1330
|
+
*
|
|
1331
|
+
* A key names ONE request. The same key with a different destination or
|
|
1332
|
+
* body used to be answered from here too, so it returned the first call's
|
|
1333
|
+
* `sent: true` and message id for a message that was never sent. It is now
|
|
1334
|
+
* a `conflict`, which the tool refuses (as a provider that honours
|
|
1335
|
+
* Idempotency-Key would), and nothing goes out.
|
|
1102
1336
|
*
|
|
1103
1337
|
* This is deliberate hidden state, and it is the one place in the package
|
|
1104
1338
|
* where a repeated call does not repeat its effect — which is the entire
|
|
@@ -1108,12 +1342,24 @@ export function describeFailure(err, deadline) {
|
|
|
1108
1342
|
*/
|
|
1109
1343
|
const LEDGER_LIMIT = 512;
|
|
1110
1344
|
const ledger = new Map();
|
|
1111
|
-
|
|
1345
|
+
/**
|
|
1346
|
+
* `ignoring` names fields left out of the comparison: EmailSend asks with
|
|
1347
|
+
* `attachmentContent` ignored when an attachment can no longer be read, so
|
|
1348
|
+
* a retry after a temp file was cleaned up finds the message it already
|
|
1349
|
+
* sent instead of reporting it unsent.
|
|
1350
|
+
*/
|
|
1351
|
+
export function ledgerLookup(tool, key, fingerprint, ignoring = []) {
|
|
1112
1352
|
if (key === undefined || key === "")
|
|
1113
1353
|
return undefined;
|
|
1114
|
-
|
|
1354
|
+
const entry = ledger.get(`${tool}\u0000${key}`);
|
|
1355
|
+
if (entry === undefined)
|
|
1356
|
+
return undefined;
|
|
1357
|
+
const differs = differingFields(entry.fingerprint, fingerprint).filter((name) => !ignoring.includes(name));
|
|
1358
|
+
return differs.length === 0
|
|
1359
|
+
? { kind: "replay", result: entry.result }
|
|
1360
|
+
: { kind: "conflict", differs };
|
|
1115
1361
|
}
|
|
1116
|
-
export function ledgerRecord(tool, key, result) {
|
|
1362
|
+
export function ledgerRecord(tool, key, fingerprint, result) {
|
|
1117
1363
|
if (key === undefined || key === "")
|
|
1118
1364
|
return;
|
|
1119
1365
|
const id = `${tool}\u0000${key}`;
|
|
@@ -1123,7 +1369,7 @@ export function ledgerRecord(tool, key, result) {
|
|
|
1123
1369
|
if (!oldest.done)
|
|
1124
1370
|
ledger.delete(oldest.value);
|
|
1125
1371
|
}
|
|
1126
|
-
ledger.set(id, { tool, result });
|
|
1372
|
+
ledger.set(id, { tool, fingerprint, result });
|
|
1127
1373
|
}
|
|
1128
1374
|
/** Test-only — a fresh process's empty ledger. */
|
|
1129
1375
|
export function _resetIdempotencyLedger() {
|