@zackbart/connecta 0.32.0 → 0.33.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/CHANGELOG.md +63 -0
- package/README.md +15 -7
- package/bin/connecta.mjs +24 -6
- package/bin/version.mjs +1 -1
- package/dist/access-tokens.d.ts +167 -23
- package/dist/access-tokens.js +676 -140
- package/dist/agent-identity.js +47 -0
- package/dist/auth/cloudflare-access.js +13 -5
- package/dist/call-admission-context.js +15 -0
- package/dist/call-admission.js +30 -43
- package/dist/call-recovery.js +6 -2
- package/dist/config-value-sources.js +11 -9
- package/dist/config.d.ts +37 -75
- package/dist/config.js +110 -36
- package/dist/connector-access.d.ts +4 -34
- package/dist/connector-access.js +15 -99
- package/dist/connector-caller.d.ts +7 -1
- package/dist/connector-caller.js +3 -2
- package/dist/connectors/api-connector.d.ts +27 -4
- package/dist/connectors/api-connector.js +2 -0
- package/dist/connectors/remote-mcp.js +2 -0
- package/dist/describe-config.d.ts +29 -7
- package/dist/describe-config.js +7 -17
- package/dist/index.d.ts +4 -3
- package/dist/index.js +29 -10
- package/dist/invocation.js +5 -3
- package/dist/membership.d.ts +51 -0
- package/dist/membership.js +121 -0
- package/dist/module-contracts.d.ts +23 -6
- package/dist/operator-record.d.ts +2 -2
- package/dist/operator-record.js +2 -4
- package/dist/operator-ui/agents.d.ts +74 -0
- package/dist/operator-ui/agents.js +104 -0
- package/dist/operator-ui/contract.d.ts +149 -28
- package/dist/operator-ui/endpoints.js +192 -0
- package/dist/operator-ui/fix-prompts.js +4 -4
- package/dist/operator-ui/generated.js +5 -4
- package/dist/operator-ui/member-page.js +112 -0
- package/dist/operator-ui/model.d.ts +14 -6
- package/dist/operator-ui/view.d.ts +84 -19
- package/dist/operator-ui/view.js +82 -21
- package/dist/page-styles.js +1 -1
- package/dist/provider.d.ts +61 -8
- package/dist/provider.js +46 -7
- package/dist/providers/_shared/google/workspace.d.ts +53 -16
- package/dist/providers/_shared/google/workspace.js +30 -8
- package/dist/providers/_shared/rest/tools.js +2 -2
- package/dist/providers/_shared/rest/value-safety.d.ts +50 -0
- package/dist/providers/_shared/rest/value-safety.js +699 -7
- package/dist/providers/ccb/index.js +1 -1
- package/dist/providers/cloudflare/index.js +3 -3
- package/dist/providers/cloudflare/rest.js +25 -3
- package/dist/providers/cloudflare/skill.generated.js +1 -1
- package/dist/providers/cloudflare/value-safety.js +32 -0
- package/dist/providers/docs/index.js +3 -3
- package/dist/providers/docs/skill.generated.js +2 -2
- package/dist/providers/drive/index.js +3 -3
- package/dist/providers/drive/skill.generated.js +2 -2
- package/dist/providers/forms/index.js +3 -3
- package/dist/providers/forms/skill.generated.js +2 -2
- package/dist/providers/gmail/index.js +10 -8
- package/dist/providers/gmail/skill.generated.js +2 -2
- package/dist/providers/mixpanel/index.js +1 -1
- package/dist/providers/notion/index.d.ts +7 -0
- package/dist/providers/notion/index.js +100 -11
- package/dist/providers/notion/rest.js +45 -5
- package/dist/providers/notion/skill.generated.js +2 -2
- package/dist/providers/notion/value-safety.js +30 -5
- package/dist/providers/overflow/index.js +1 -1
- package/dist/providers/planning-center/index.js +1 -1
- package/dist/providers/sheets/index.d.ts +3 -0
- package/dist/providers/sheets/index.js +6 -3
- package/dist/providers/sheets/skill.generated.js +2 -2
- package/dist/providers/slides/index.js +3 -3
- package/dist/providers/slides/skill.generated.js +2 -2
- package/dist/providers/stripe/rest.js +9 -2
- package/dist/providers/stripe/skill.generated.js +1 -1
- package/dist/providers/stripe/value-safety.js +22 -0
- package/dist/providers/vercel/rest.js +20 -2
- package/dist/providers/vercel/skill.generated.js +1 -1
- package/dist/providers/vercel/value-safety.js +22 -0
- package/dist/registry.d.ts +2 -3
- package/dist/registry.js +8 -9
- package/dist/routes/access-tokens.js +82 -75
- package/dist/routes/activity-disclosure.js +17 -18
- package/dist/routes/activity.js +34 -54
- package/dist/routes/actor-labels.js +33 -4
- package/dist/routes/agent-path.js +43 -0
- package/dist/routes/connect.js +16 -12
- package/dist/routes/credentials.js +16 -40
- package/dist/routes/mcp.js +145 -95
- package/dist/routes/oauth-management.js +11 -5
- package/dist/routes/oauth.js +3 -9
- package/dist/routes/operator.js +24 -23
- package/dist/routes/shared.d.ts +60 -20
- package/dist/routes/shared.js +187 -96
- package/dist/routes/ui-config.js +437 -97
- package/dist/routes/ui-data.js +2 -2
- package/dist/routes/ui.js +9 -16
- package/dist/runtime/call-admission.js +9 -0
- package/dist/server.js +5 -0
- package/dist/storage/keys.js +9 -1
- package/dist/types.d.ts +11 -0
- package/dist/ui.d.ts +1 -1
- package/dist/ui.js +16 -14
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/workspace-address.js +9 -0
- package/dist/workspace-subject.js +23 -0
- package/documentation/README.md +46 -22
- package/documentation/architecture.md +355 -50
- package/documentation/auth.md +775 -243
- package/documentation/code-mode.md +14 -11
- package/documentation/deploying.md +121 -24
- package/documentation/integrating.md +92 -21
- package/documentation/meta-tools.md +14 -9
- package/documentation/migration-0.33.md +344 -0
- package/documentation/operating.md +3 -1
- package/documentation/operator-tests.md +115 -28
- package/documentation/operator-ui.md +326 -61
- package/documentation/provider-auth-migration.md +37 -23
- package/documentation/provider-migration-0.29.md +2 -2
- package/examples/worker/AGENTS.md +30 -8
- package/examples/worker/README.md +92 -53
- package/examples/worker/src/connecta.config.ts +36 -20
- package/examples/worker/wrangler.jsonc +3 -0
- package/package.json +3 -2
- package/templates/node/.env.example +3 -0
- package/templates/node/AGENTS.md +27 -6
- package/templates/node/Dockerfile +2 -2
- package/templates/node/README.md +67 -42
- package/templates/node/docker-compose.yml +3 -2
- package/templates/node/package.json +4 -3
- package/templates/node/src/adopt-token.ts +23 -0
- package/templates/node/src/connecta.config.ts +28 -12
- package/templates/node/src/rotate-token.ts +17 -0
- package/templates/node/src/provision-token.ts +0 -17
|
@@ -1,3 +1,40 @@
|
|
|
1
|
+
// Value safety for every REST vendor (decision 0005): no tool response,
|
|
2
|
+
// success or error, returns a credential (API, service, or bypass tokens,
|
|
3
|
+
// signing secrets, bearer URLs, private keys, temporary credentials, auth
|
|
4
|
+
// headers, authorization verifiers) or a stored secret value (environment
|
|
5
|
+
// variables, secret items), unless a reviewed named tool exists to return it.
|
|
6
|
+
//
|
|
7
|
+
// One mechanism, strongest layer first:
|
|
8
|
+
//
|
|
9
|
+
// 1. Refuse by operation. `scripts/value-safety.mjs` flags every operation in
|
|
10
|
+
// a vendor's pinned spec whose response may carry one; `providers:spec`
|
|
11
|
+
// commits the result as `value-safety.candidates.json`. Each needs a
|
|
12
|
+
// reviewed verdict in the vendor's `value-safety.ts` table, and the shared
|
|
13
|
+
// harness (`test/fixtures/value-safety.ts`) fails on any flagged operation
|
|
14
|
+
// without one, a stale pin, a verdict for an operation the index lacks, a
|
|
15
|
+
// flagged field no verdict accounts for, or a refusal that reaches
|
|
16
|
+
// transport.
|
|
17
|
+
// 2. Reviewed field paths for `redact` verdicts, on every success body the
|
|
18
|
+
// vendor returns (generic tools, HEAD data, named tools, uploads, logs),
|
|
19
|
+
// after any envelope unwrap and before cursors or `select` read it; then
|
|
20
|
+
// resource rules on every object whose type discriminator they name,
|
|
21
|
+
// wherever an expansion, list, or event embeds it.
|
|
22
|
+
// 3. A key-name heuristic, defense in depth only, over every body: any
|
|
23
|
+
// subtree under a credential-named key, labelled or typed secret records,
|
|
24
|
+
// environment containers, header rules, and URLs with userinfo or
|
|
25
|
+
// credential query parameters.
|
|
26
|
+
// 4. Errors: an operation with a verdict, or whose request accepts a
|
|
27
|
+
// credential-named field, answers failures with the vendor's codes and
|
|
28
|
+
// statuses but never its text (`withholdsErrors`), unless its review
|
|
29
|
+
// found its errors cannot echo a stored secret (`vendorErrors`).
|
|
30
|
+
//
|
|
31
|
+
// Residual risk, as with Infisical: a secret a person typed into a free-text
|
|
32
|
+
// name, description, log line, or identifier is out of scope.
|
|
33
|
+
//
|
|
34
|
+
// The key-name rules combine Cloudflare's and Vercel's former engines.
|
|
35
|
+
// URL policy and exemptions are reviewed per vendor in its table (`keep`,
|
|
36
|
+
// `fields`, `scopeMaps`, `envBodies`, `embeddedUrls`).
|
|
37
|
+
import { ConnectorCallError } from "../../../errors.js";
|
|
1
38
|
/** What a removed secret reads as. */
|
|
2
39
|
export const REDACTED = "[redacted]";
|
|
3
40
|
/** Credential vocabulary over normalized names, matched anywhere; mirrors `scripts/value-safety.mjs`. */
|
|
@@ -159,7 +196,7 @@ function isRecord(value) {
|
|
|
159
196
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
160
197
|
}
|
|
161
198
|
/** A URL without userinfo, credential query parameters, or a credential-looking fragment; anything unparseable is withheld. */
|
|
162
|
-
function sanitizeUrl(value) {
|
|
199
|
+
function sanitizeUrl(value, credentials = true) {
|
|
163
200
|
let url;
|
|
164
201
|
try {
|
|
165
202
|
url = new URL(value);
|
|
@@ -174,19 +211,624 @@ function sanitizeUrl(value) {
|
|
|
174
211
|
changed = true;
|
|
175
212
|
}
|
|
176
213
|
// A snapshot of the names: setting a parameter while iterating the live list is unsafe.
|
|
177
|
-
for (const name of Array.from(url.searchParams.keys())) {
|
|
214
|
+
for (const name of credentials ? Array.from(url.searchParams.keys()) : []) {
|
|
178
215
|
if (URL_PARAM_CONTAINS.test(name) || URL_PARAM_SUFFIX.test(name.toLowerCase().replace(/[_-]/g, ""))) {
|
|
179
216
|
url.searchParams.set(name, REDACTED);
|
|
180
217
|
changed = true;
|
|
181
218
|
}
|
|
182
219
|
}
|
|
183
|
-
if (url.hash && /token|secret|key|code|auth/i.test(url.hash)) {
|
|
220
|
+
if (credentials && url.hash && /token|secret|key|code|auth/i.test(url.hash)) {
|
|
184
221
|
url.hash = "";
|
|
185
222
|
changed = true;
|
|
186
223
|
}
|
|
187
224
|
// An ordinary URL comes back exactly as the vendor sent it.
|
|
188
225
|
return changed ? url.toString() : value;
|
|
189
226
|
}
|
|
227
|
+
/** Signing parameters that are a credential in any URL, with their `=` plain or encoded. */
|
|
228
|
+
const SIGNING_PARAM = /x-(?:amz|goog)-(?:signature|credential|security-token)(?:=|%(?:25)*3d|�*61;?|�*3d;?|=?|\\u003d)/gi;
|
|
229
|
+
/**
|
|
230
|
+
* One encoded character at the start of link text: a percent escape, an HTML
|
|
231
|
+
* character reference, a `\u` or `\x` escape, or a Markdown backslash escape.
|
|
232
|
+
*/
|
|
233
|
+
const LEADING_ESCAPE = /^(?:%([0-9a-f]{2})|&(?:#(\d{1,7})|#x([0-9a-f]{1,6})|(amp|quot|apos|lt|gt|quest|num|semi|equals|sol|percnt|bsol));?|\\u([0-9a-f]{4})|\\x([0-9a-f]{2})|\\)/i;
|
|
234
|
+
/** One encoded character just before a host: the same escapes, read backwards over a few characters. */
|
|
235
|
+
const TRAILING_ESCAPE = /(?:%(?:25){0,4}([0-9a-f]{2})|&(?:#0{0,6}(\d{1,7})|#x0{0,6}([0-9a-f]{1,6})|([a-z]{2,6}));?|\\u([0-9a-f]{4})|\\x([0-9a-f]{2})|\\)$/i;
|
|
236
|
+
/** Every escape in link text, decoded together; an unknown entity name stays as written. */
|
|
237
|
+
const ESCAPES = /%([0-9a-f]{2})|&(?:#(\d{1,7})|#x([0-9a-f]{1,6})|([a-z]{2,8}));?|\\u([0-9a-f]{4})|\\x([0-9a-f]{2})|\\(?=[^a-z0-9])/gi;
|
|
238
|
+
/** One slash however it is spelled, read forwards. */
|
|
239
|
+
const SLASH = /\/|\\\/|%(?:25){0,4}2f|�{0,6}47;?|�{0,6}2f;?|/?|\\u002f|\\x2f/iy;
|
|
240
|
+
/** An S3 endpoint host: global, regional, dual-stack, or accelerated. */
|
|
241
|
+
const S3_ENDPOINT = String.raw `s3(?:[.-](?:dualstack|accelerate|external-1|[a-z]{2}(?:-[a-z]{1,12}){1,3}-\d{1,2})){0,2}\.amazonaws\.com`;
|
|
242
|
+
const NAMED = {
|
|
243
|
+
amp: "&",
|
|
244
|
+
colon: ":",
|
|
245
|
+
quot: '"',
|
|
246
|
+
apos: "'",
|
|
247
|
+
lt: "<",
|
|
248
|
+
gt: ">",
|
|
249
|
+
quest: "?",
|
|
250
|
+
num: "#",
|
|
251
|
+
semi: ";",
|
|
252
|
+
equals: "=",
|
|
253
|
+
sol: "/",
|
|
254
|
+
percnt: "%",
|
|
255
|
+
bsol: "\\",
|
|
256
|
+
lbrack: "[",
|
|
257
|
+
rbrack: "]",
|
|
258
|
+
lsqb: "[",
|
|
259
|
+
rsqb: "]",
|
|
260
|
+
period: ".",
|
|
261
|
+
};
|
|
262
|
+
/** A host name's label character: the subdomain labels before a reviewed name are these alone. */
|
|
263
|
+
const LABEL = /[a-z0-9.-]/i;
|
|
264
|
+
/** The longest host name DNS allows: the bound on authority inspection. */
|
|
265
|
+
const MAX_HOST = 253;
|
|
266
|
+
/** The longest scheme read back from its `:`. */
|
|
267
|
+
const MAX_SCHEME = 32;
|
|
268
|
+
const SCHEME_CHAR = /[a-z0-9+.-]/i;
|
|
269
|
+
/** What may open a scheme-less `//host`: whitespace or punctuation that opens a link, value, or attribute. */
|
|
270
|
+
const OPENS_LINK = /[\s"'`(<[{=,;|]/;
|
|
271
|
+
/** Punctuation that may close the syntax around a link; it carries no secret, so it comes back. */
|
|
272
|
+
const TRAILING = /[)\]}.,;:!*_~`\\]/;
|
|
273
|
+
/** Code points past Unicode's range decode to nothing rather than throw. */
|
|
274
|
+
function codePoint(value) {
|
|
275
|
+
return value <= 0x10ffff ? String.fromCodePoint(value) : "";
|
|
276
|
+
}
|
|
277
|
+
/** The character an escape's groups spell: percent, decimal, hex, name, `\u`, `\x`; a bare backslash spells nothing. */
|
|
278
|
+
function unescaped(groups) {
|
|
279
|
+
const [percent, decimal, hex, name, unicode, byte] = groups;
|
|
280
|
+
if (percent !== undefined)
|
|
281
|
+
return String.fromCharCode(Number.parseInt(percent, 16));
|
|
282
|
+
if (decimal !== undefined)
|
|
283
|
+
return codePoint(Number(decimal));
|
|
284
|
+
if (hex !== undefined)
|
|
285
|
+
return codePoint(Number.parseInt(hex, 16));
|
|
286
|
+
if (name !== undefined)
|
|
287
|
+
return NAMED[name.toLowerCase()];
|
|
288
|
+
if (unicode !== undefined || byte !== undefined)
|
|
289
|
+
return String.fromCharCode(Number.parseInt((unicode ?? byte), 16));
|
|
290
|
+
return "";
|
|
291
|
+
}
|
|
292
|
+
/**
|
|
293
|
+
* Whether link text opens its query or fragment here once decoded as a
|
|
294
|
+
* browser, a Markdown renderer, or a JSON parser would: `%3F`, `%253F`,
|
|
295
|
+
* `?`, `&#35;`, `?`. Only the leading character matters, so
|
|
296
|
+
* each round decodes one escape there: constant work.
|
|
297
|
+
*/
|
|
298
|
+
function opener(text, at) {
|
|
299
|
+
let current = text.slice(at, at + 32);
|
|
300
|
+
for (let round = 0; round < 4; round++) {
|
|
301
|
+
if (current[0] === "?" || current[0] === "#")
|
|
302
|
+
return current[0];
|
|
303
|
+
const escape = LEADING_ESCAPE.exec(current);
|
|
304
|
+
if (!escape)
|
|
305
|
+
return undefined;
|
|
306
|
+
current = (unescaped(escape.slice(1)) ?? "") + current.slice(escape[0].length);
|
|
307
|
+
}
|
|
308
|
+
return current[0] === "?" || current[0] === "#" ? current[0] : undefined;
|
|
309
|
+
}
|
|
310
|
+
/** The character ending just before `at`, decoded if it is escaped, and how many characters spell it. */
|
|
311
|
+
function charBefore(text, at) {
|
|
312
|
+
const escape = TRAILING_ESCAPE.exec(text.slice(Math.max(0, at - 16), at));
|
|
313
|
+
const char = escape ? unescaped(escape.slice(1)) : undefined;
|
|
314
|
+
if (escape && char)
|
|
315
|
+
return { char, length: escape[0].length };
|
|
316
|
+
// A JSON-escaped slash, `\/`, is one slash.
|
|
317
|
+
if (text[at - 1] === "/" && text[at - 2] === "\\")
|
|
318
|
+
return { char: "/", length: 2 };
|
|
319
|
+
return { char: text[at - 1], length: 1 };
|
|
320
|
+
}
|
|
321
|
+
/**
|
|
322
|
+
* Where the link whose authority opens at `at` starts, or `-1` when no
|
|
323
|
+
* authority opens there: `//` however spelled, after a scheme's `:`
|
|
324
|
+
* (`https:`, `https%3A`), the text's start, or what opens a scheme-less
|
|
325
|
+
* link (`src=//`, ` //`). A `//` after anything else (`example.com//`) is
|
|
326
|
+
* part of a path.
|
|
327
|
+
*/
|
|
328
|
+
function authorityAt(text, at) {
|
|
329
|
+
if (at === 0)
|
|
330
|
+
return -1;
|
|
331
|
+
const second = charBefore(text, at);
|
|
332
|
+
const between = at - second.length;
|
|
333
|
+
if (second.char !== "/" || between <= 0)
|
|
334
|
+
return -1;
|
|
335
|
+
const first = charBefore(text, between);
|
|
336
|
+
const slashes = between - first.length;
|
|
337
|
+
if (first.char !== "/" || slashes < 0)
|
|
338
|
+
return -1;
|
|
339
|
+
if (slashes === 0)
|
|
340
|
+
return 0;
|
|
341
|
+
const before = charBefore(text, slashes);
|
|
342
|
+
if (before.char !== ":")
|
|
343
|
+
return OPENS_LINK.test(before.char) ? slashes : -1;
|
|
344
|
+
const colon = slashes - before.length;
|
|
345
|
+
let start = colon;
|
|
346
|
+
while (start > 0 && colon - start < MAX_SCHEME && SCHEME_CHAR.test(text[start - 1]))
|
|
347
|
+
start--;
|
|
348
|
+
return start < colon && (start === 0 || OPENS_LINK.test(text[start - 1])) ? start : -1;
|
|
349
|
+
}
|
|
350
|
+
/** Where a host's optional trailing dot and port end after `at`, or `-1` when a `:` holds no port (userinfo's password). */
|
|
351
|
+
function afterPort(text, at) {
|
|
352
|
+
if (text[at] === ".")
|
|
353
|
+
at++;
|
|
354
|
+
if (text[at] !== ":")
|
|
355
|
+
return at;
|
|
356
|
+
let digits = at + 1;
|
|
357
|
+
while (digits - at <= 5 && /\d/.test(text[digits] ?? ""))
|
|
358
|
+
digits++;
|
|
359
|
+
return digits > at + 1 ? digits : -1;
|
|
360
|
+
}
|
|
361
|
+
/**
|
|
362
|
+
* Whether a host or path segment ends at `at`: a slash, query, or fragment
|
|
363
|
+
* however spelled, or the link's end. Never another
|
|
364
|
+
* name character or an `@`: `file.notion.so@evil.com` names userinfo, and
|
|
365
|
+
* `prod-files-secure@other` and `prod-files-securex` are other segments.
|
|
366
|
+
*/
|
|
367
|
+
function boundary(text, at) {
|
|
368
|
+
if (at < 0)
|
|
369
|
+
return false;
|
|
370
|
+
if (at >= text.length)
|
|
371
|
+
return true;
|
|
372
|
+
if (endsLink(text.charCodeAt(at), text[at]))
|
|
373
|
+
return true;
|
|
374
|
+
SLASH.lastIndex = at;
|
|
375
|
+
return SLASH.test(text) || opener(text, at) !== undefined;
|
|
376
|
+
}
|
|
377
|
+
/** Whether the `#` at `at` belongs to a numeric character reference (`&`) rather than opening a fragment. */
|
|
378
|
+
function reference(text, at) {
|
|
379
|
+
return text.charCodeAt(at - 1) === 0x26 && /[0-9x]/i.test(text[at + 1] ?? "");
|
|
380
|
+
}
|
|
381
|
+
/** Whitespace, a quote, or an angle bracket: where a link in text ends, read fail-closed. */
|
|
382
|
+
function endsLink(code, char) {
|
|
383
|
+
if (code <= 0x20 || code === 0x22 || code === 0x27 || code === 0x3c || code === 0x3e)
|
|
384
|
+
return true;
|
|
385
|
+
return code > 0x7f && /\s/.test(char);
|
|
386
|
+
}
|
|
387
|
+
/** Where a signing parameter's value ends: its link's end, or the next parameter or closing bracket. */
|
|
388
|
+
function endsValue(text, at) {
|
|
389
|
+
const code = text.charCodeAt(at);
|
|
390
|
+
if (endsLink(code, text[at]))
|
|
391
|
+
return true;
|
|
392
|
+
if (code === 0x26 || code === 0x23 || code === 0x29 || code === 0x5d || code === 0x60)
|
|
393
|
+
return true;
|
|
394
|
+
// `\/` is a JSON-escaped slash inside the value; any other backslash escapes what follows it.
|
|
395
|
+
return code === 0x5c && text.charCodeAt(at + 1) !== 0x2f;
|
|
396
|
+
}
|
|
397
|
+
const HOST_MATCHERS = new WeakMap();
|
|
398
|
+
function hostMatcher(hosts) {
|
|
399
|
+
let matcher = HOST_MATCHERS.get(hosts);
|
|
400
|
+
if (!matcher) {
|
|
401
|
+
matcher = {
|
|
402
|
+
endpoint: new RegExp(`^${S3_ENDPOINT}$`, "i"),
|
|
403
|
+
domains: hosts.domains.map((host) => host.toLowerCase()),
|
|
404
|
+
buckets: hosts.buckets.map((bucket) => bucket.toLowerCase()),
|
|
405
|
+
};
|
|
406
|
+
HOST_MATCHERS.set(hosts, matcher);
|
|
407
|
+
}
|
|
408
|
+
return matcher;
|
|
409
|
+
}
|
|
410
|
+
/** The end of a reviewed file authority, read only at the current URL's host. */
|
|
411
|
+
function fileHost(text, start, matcher) {
|
|
412
|
+
let end = start;
|
|
413
|
+
while (end < text.length && end - start <= MAX_HOST && LABEL.test(text[end]))
|
|
414
|
+
end++;
|
|
415
|
+
if (end === start || end - start > MAX_HOST)
|
|
416
|
+
return undefined;
|
|
417
|
+
const port = afterPort(text, end);
|
|
418
|
+
if (!boundary(text, port))
|
|
419
|
+
return undefined;
|
|
420
|
+
const name = text.slice(start, end).toLowerCase().replace(/\.$/, "");
|
|
421
|
+
if (matcher.domains.some((domain) => name === domain || name.endsWith(`.${domain}`)))
|
|
422
|
+
return end;
|
|
423
|
+
if (matcher.buckets.some((bucket) => name.startsWith(`${bucket}.`) && matcher.endpoint.test(name.slice(bucket.length + 1)))) {
|
|
424
|
+
return end;
|
|
425
|
+
}
|
|
426
|
+
if (!matcher.endpoint.test(name))
|
|
427
|
+
return undefined;
|
|
428
|
+
SLASH.lastIndex = port;
|
|
429
|
+
if (!SLASH.test(text))
|
|
430
|
+
return undefined;
|
|
431
|
+
const segment = SLASH.lastIndex;
|
|
432
|
+
for (const bucket of matcher.buckets) {
|
|
433
|
+
const after = segment + bucket.length;
|
|
434
|
+
if (text.slice(segment, after).toLowerCase() === bucket && boundary(text, after))
|
|
435
|
+
return after;
|
|
436
|
+
}
|
|
437
|
+
return undefined;
|
|
438
|
+
}
|
|
439
|
+
/** The authority immediately after a destination's scheme and slashes, however escaped. */
|
|
440
|
+
function destinationHost(text, start) {
|
|
441
|
+
// A scheme and three escaped characters (colon and slashes), each bounded
|
|
442
|
+
// as in charBefore. Never search a destination's path or query for another.
|
|
443
|
+
for (let at = start; at < Math.min(text.length, start + MAX_SCHEME + 48); at++) {
|
|
444
|
+
SLASH.lastIndex = at;
|
|
445
|
+
if (!SLASH.test(text))
|
|
446
|
+
continue;
|
|
447
|
+
const next = SLASH.lastIndex;
|
|
448
|
+
SLASH.lastIndex = next;
|
|
449
|
+
if (!SLASH.test(text))
|
|
450
|
+
return undefined;
|
|
451
|
+
const host = SLASH.lastIndex;
|
|
452
|
+
return authorityAt(text, host) === start ? host : undefined;
|
|
453
|
+
}
|
|
454
|
+
return undefined;
|
|
455
|
+
}
|
|
456
|
+
/** CommonMark permits spaces/tabs and at most one line ending before a destination. */
|
|
457
|
+
function markdownStart(text, at) {
|
|
458
|
+
let line = false;
|
|
459
|
+
for (; at < text.length; at++) {
|
|
460
|
+
const char = text[at];
|
|
461
|
+
if (char === " " || char === "\t")
|
|
462
|
+
continue;
|
|
463
|
+
if (char !== "\r" && char !== "\n")
|
|
464
|
+
return at;
|
|
465
|
+
if (line)
|
|
466
|
+
return undefined;
|
|
467
|
+
line = true;
|
|
468
|
+
if (char === "\r" && text[at + 1] === "\n")
|
|
469
|
+
at++;
|
|
470
|
+
}
|
|
471
|
+
return at;
|
|
472
|
+
}
|
|
473
|
+
/**
|
|
474
|
+
* Read a destination once. Admission ends at its actual CommonMark close,
|
|
475
|
+
* counting balanced and escaped parentheses. Redaction with a query keeps
|
|
476
|
+
* its separate fail-closed span through whitespace, a quote, or an angle
|
|
477
|
+
* bracket, since parentheses can also be part of a signed value.
|
|
478
|
+
*/
|
|
479
|
+
function linkSpan(text, start, host, inline, redact) {
|
|
480
|
+
let depth = 0;
|
|
481
|
+
let escaped = false;
|
|
482
|
+
let cut = -1;
|
|
483
|
+
let open = "?";
|
|
484
|
+
let end = start;
|
|
485
|
+
for (; end < text.length; end++) {
|
|
486
|
+
const code = text.charCodeAt(end);
|
|
487
|
+
if (endsLink(code, text[end]))
|
|
488
|
+
break;
|
|
489
|
+
if (inline) {
|
|
490
|
+
if (escaped)
|
|
491
|
+
escaped = false;
|
|
492
|
+
else if (code === 0x5c)
|
|
493
|
+
escaped = true;
|
|
494
|
+
else if (code === 0x28)
|
|
495
|
+
depth++;
|
|
496
|
+
else if (code === 0x29 && depth-- === 0)
|
|
497
|
+
break;
|
|
498
|
+
}
|
|
499
|
+
if (host === undefined || end < host || cut >= 0)
|
|
500
|
+
continue;
|
|
501
|
+
if (code === 0x3f || (code === 0x23 && !reference(text, end))) {
|
|
502
|
+
cut = end;
|
|
503
|
+
open = text[end];
|
|
504
|
+
}
|
|
505
|
+
else if (code === 0x25 || code === 0x26 || code === 0x5c) {
|
|
506
|
+
const opens = opener(text, end);
|
|
507
|
+
if (opens) {
|
|
508
|
+
cut = end;
|
|
509
|
+
open = opens;
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
if (redact && cut >= 0) {
|
|
514
|
+
while (end < text.length && !endsLink(text.charCodeAt(end), text[end]))
|
|
515
|
+
end++;
|
|
516
|
+
}
|
|
517
|
+
return { cut, open, end };
|
|
518
|
+
}
|
|
519
|
+
/**
|
|
520
|
+
* Scan destinations, including ones at other hosts, so an outer URL owns
|
|
521
|
+
* every byte of its path and query. A nested scheme or // cannot re-anchor
|
|
522
|
+
* file-host matching. Each consumed span is skipped in full; admission and
|
|
523
|
+
* redaction choose their own span boundary, and both stay linear.
|
|
524
|
+
*/
|
|
525
|
+
function* hostLinks(text, hosts, redact) {
|
|
526
|
+
const matcher = hosts ? hostMatcher(hosts) : undefined;
|
|
527
|
+
// The first URL delimiter in the current text run also protects relative
|
|
528
|
+
// paths/queries such as /proxy/https://… and image?url=//… .
|
|
529
|
+
let firstReference = -1;
|
|
530
|
+
for (let at = 0; at < text.length; at++) {
|
|
531
|
+
const char = text[at];
|
|
532
|
+
if (endsLink(text.charCodeAt(at), char)) {
|
|
533
|
+
firstReference = -1;
|
|
534
|
+
continue;
|
|
535
|
+
}
|
|
536
|
+
if (char === "]" && text[at + 1] === "(") {
|
|
537
|
+
const destination = markdownStart(text, at + 2);
|
|
538
|
+
if (destination !== undefined) {
|
|
539
|
+
const angle = text[destination] === "<";
|
|
540
|
+
const start = destination + (angle ? 1 : 0);
|
|
541
|
+
const authority = destinationHost(text, start);
|
|
542
|
+
// Generic policy inspects a relative destination's own query/fragment.
|
|
543
|
+
// Host policy must still attribute the leading authority to the vendor.
|
|
544
|
+
const host = matcher
|
|
545
|
+
? authority === undefined
|
|
546
|
+
? undefined
|
|
547
|
+
: fileHost(text, authority, matcher)
|
|
548
|
+
: (authority ?? start);
|
|
549
|
+
const span = linkSpan(text, start, host, !angle, redact);
|
|
550
|
+
at = Math.max(destination, span.end) - 1;
|
|
551
|
+
firstReference = -1;
|
|
552
|
+
if (host !== undefined)
|
|
553
|
+
yield span;
|
|
554
|
+
continue;
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
if (char === "/" || char === "\\" || char === "%" || char === "&") {
|
|
558
|
+
SLASH.lastIndex = at;
|
|
559
|
+
if (SLASH.test(text)) {
|
|
560
|
+
const next = SLASH.lastIndex;
|
|
561
|
+
SLASH.lastIndex = next;
|
|
562
|
+
if (SLASH.test(text)) {
|
|
563
|
+
const authority = SLASH.lastIndex;
|
|
564
|
+
const start = authorityAt(text, authority);
|
|
565
|
+
// Global parameter scanning needs no host attribution. This also
|
|
566
|
+
// catches a URL after a log label such as `download:https://…`.
|
|
567
|
+
// File-host policy keeps the stricter authority/proxy boundary.
|
|
568
|
+
if (!matcher || (start >= 0 && (firstReference < 0 || firstReference >= start))) {
|
|
569
|
+
const host = matcher ? fileHost(text, authority, matcher) : authority;
|
|
570
|
+
const span = linkSpan(text, authority, host, false, redact);
|
|
571
|
+
at = span.end - 1;
|
|
572
|
+
firstReference = -1;
|
|
573
|
+
if (host !== undefined)
|
|
574
|
+
yield span;
|
|
575
|
+
continue;
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
if (firstReference < 0)
|
|
579
|
+
firstReference = at;
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
if (firstReference < 0 && (char === ":" || char === "?" || (char === "#" && !reference(text, at)))) {
|
|
583
|
+
firstReference = at;
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
/**
|
|
588
|
+
* `text` with every pre-signed link withheld: at one of `hosts` (Notion's
|
|
589
|
+
* file storage, say), its whole query and fragment; anywhere, a signing
|
|
590
|
+
* parameter's value. Everything else comes back byte for byte.
|
|
591
|
+
*/
|
|
592
|
+
export function withholdSignedLinks(text, hosts) {
|
|
593
|
+
return withholdSigningParams(withholdLinkQueries(text, hosts));
|
|
594
|
+
}
|
|
595
|
+
/** A credential-named parameter in a URL, rather than an assignment in prose. */
|
|
596
|
+
function credentialQuery(text) {
|
|
597
|
+
let decoded = decodeLinkText(text, false);
|
|
598
|
+
// Decode the destination's enclosing percent layers only until its own
|
|
599
|
+
// opener is visible. Encoded &/# inside a proxy parameter remain data,
|
|
600
|
+
// rather than becoming parameters or a fragment of the outer URL.
|
|
601
|
+
for (let round = 0; round < 4 && decoded[0] !== "?" && decoded[0] !== "#"; round++) {
|
|
602
|
+
const next = decoded.replace(/%([0-9a-f]{2})/gi, (_, hex) => String.fromCharCode(Number.parseInt(hex, 16)));
|
|
603
|
+
if (next === decoded)
|
|
604
|
+
break;
|
|
605
|
+
decoded = next;
|
|
606
|
+
}
|
|
607
|
+
const fragment = decoded.indexOf("#");
|
|
608
|
+
if (fragment >= 0 && /token|secret|key|code|auth/i.test(decodeLinkText(decoded.slice(fragment + 1))))
|
|
609
|
+
return true;
|
|
610
|
+
const query = fragment < 0 ? decoded : decoded.slice(0, fragment);
|
|
611
|
+
for (let at = query[0] === "?" ? 1 : 0; at < query.length;) {
|
|
612
|
+
const next = query.indexOf("&", at);
|
|
613
|
+
const end = next < 0 ? query.length : next;
|
|
614
|
+
const pair = decodeLinkText(query.slice(at, end));
|
|
615
|
+
const equals = pair.indexOf("=");
|
|
616
|
+
at = end + 1;
|
|
617
|
+
if (equals < 0)
|
|
618
|
+
continue;
|
|
619
|
+
const name = pair.slice(0, equals);
|
|
620
|
+
if (!URL_PARAM_CONTAINS.test(name) && !URL_PARAM_SUFFIX.test(normalizedName(name)))
|
|
621
|
+
continue;
|
|
622
|
+
if (pair.slice(equals + 1) !== REDACTED)
|
|
623
|
+
return true;
|
|
624
|
+
}
|
|
625
|
+
return false;
|
|
626
|
+
}
|
|
627
|
+
/** Remove a link's entire query/fragment, keeping its surrounding syntax. */
|
|
628
|
+
function withholdLinkQueries(text, hosts) {
|
|
629
|
+
let out = "";
|
|
630
|
+
let copied = 0;
|
|
631
|
+
for (const { cut, open, end } of hostLinks(text, hosts, hosts !== undefined)) {
|
|
632
|
+
if (cut < 0)
|
|
633
|
+
continue;
|
|
634
|
+
if (!hosts && !credentialQuery(text.slice(cut, end)))
|
|
635
|
+
continue;
|
|
636
|
+
let keep = end;
|
|
637
|
+
while (keep > cut + 1 && TRAILING.test(text[keep - 1])) {
|
|
638
|
+
// A signing pass may already have replaced the final parameter. Its
|
|
639
|
+
// closing bracket belongs to the value, not the surrounding Markdown.
|
|
640
|
+
if (text.endsWith(REDACTED, keep))
|
|
641
|
+
break;
|
|
642
|
+
keep--;
|
|
643
|
+
}
|
|
644
|
+
// A query with nothing in it.
|
|
645
|
+
if (keep <= cut + 1)
|
|
646
|
+
continue;
|
|
647
|
+
out += `${text.slice(copied, cut)}${open}${REDACTED}`;
|
|
648
|
+
copied = keep;
|
|
649
|
+
}
|
|
650
|
+
return copied === 0 ? text : out + text.slice(copied);
|
|
651
|
+
}
|
|
652
|
+
function withholdSigningParams(anchored) {
|
|
653
|
+
let out = "";
|
|
654
|
+
let copied = 0;
|
|
655
|
+
SIGNING_PARAM.lastIndex = 0;
|
|
656
|
+
for (let match = SIGNING_PARAM.exec(anchored); match; match = SIGNING_PARAM.exec(anchored)) {
|
|
657
|
+
const start = match.index + match[0].length;
|
|
658
|
+
// Skip an existing complete placeholder, but still remove any suffix.
|
|
659
|
+
const already = anchored.startsWith(REDACTED, start);
|
|
660
|
+
let end = already ? start + REDACTED.length : start;
|
|
661
|
+
while (end < anchored.length && !endsValue(anchored, end))
|
|
662
|
+
end++;
|
|
663
|
+
SIGNING_PARAM.lastIndex = end;
|
|
664
|
+
if (end === start || (already && end === start + REDACTED.length))
|
|
665
|
+
continue;
|
|
666
|
+
out += `${anchored.slice(copied, start)}${REDACTED}`;
|
|
667
|
+
copied = end;
|
|
668
|
+
}
|
|
669
|
+
return copied === 0 ? anchored : out + anchored.slice(copied);
|
|
670
|
+
}
|
|
671
|
+
/**
|
|
672
|
+
* Reviewed REST text policy: only credential-bearing URL queries/fragments
|
|
673
|
+
* change. Ordinary links, including external proxies, retain their bytes.
|
|
674
|
+
* Literal AWS/Google signing names are withheld anywhere, as in #818.
|
|
675
|
+
*/
|
|
676
|
+
export function withholdEmbeddedUrls(text) {
|
|
677
|
+
return withholdSigningParams(withholdLinkQueries(text));
|
|
678
|
+
}
|
|
679
|
+
/** Every string inside a JSON value through `withholdSignedLinks`; unchanged values keep their identity. */
|
|
680
|
+
export function withholdSignedLinksIn(value, hosts, depth = 0) {
|
|
681
|
+
return mapStrings(value, (text) => withholdSignedLinks(text, hosts), depth);
|
|
682
|
+
}
|
|
683
|
+
/** Apply the reviewed text policy to a selected value or vendor error body. */
|
|
684
|
+
export function withholdEmbeddedUrlsIn(value) {
|
|
685
|
+
return mapStrings(value, withholdEmbeddedUrls, 0);
|
|
686
|
+
}
|
|
687
|
+
function mapStrings(value, cleanText, depth) {
|
|
688
|
+
if (typeof value === "string")
|
|
689
|
+
return cleanText(value);
|
|
690
|
+
if (typeof value !== "object" || value === null)
|
|
691
|
+
return value;
|
|
692
|
+
// Deeper than any vendor's shape: withheld, as the heuristic does.
|
|
693
|
+
if (depth > MAX_DEPTH)
|
|
694
|
+
return REDACTED;
|
|
695
|
+
if (Array.isArray(value)) {
|
|
696
|
+
let changed = false;
|
|
697
|
+
const items = value.map((item) => {
|
|
698
|
+
const clean = mapStrings(item, cleanText, depth + 1);
|
|
699
|
+
if (clean !== item)
|
|
700
|
+
changed = true;
|
|
701
|
+
return clean;
|
|
702
|
+
});
|
|
703
|
+
return changed ? items : value;
|
|
704
|
+
}
|
|
705
|
+
let changed = false;
|
|
706
|
+
const entries = Object.entries(value).map(([key, item]) => {
|
|
707
|
+
const clean = mapStrings(item, cleanText, depth + 1);
|
|
708
|
+
if (clean !== item)
|
|
709
|
+
changed = true;
|
|
710
|
+
return [key, clean];
|
|
711
|
+
});
|
|
712
|
+
return changed ? Object.fromEntries(entries) : value;
|
|
713
|
+
}
|
|
714
|
+
/**
|
|
715
|
+
* Link text decoded as a browser, a Markdown renderer, or a JSON parser
|
|
716
|
+
* would, through up to four layers of escaping (`%255B`, `&#91;`). Each
|
|
717
|
+
* round is one linear pass.
|
|
718
|
+
*/
|
|
719
|
+
function decodeLinkText(text, percent = true) {
|
|
720
|
+
for (let round = 0; round < 4; round++) {
|
|
721
|
+
const next = text.replace(ESCAPES, (token, ...groups) => !percent && groups[0] !== undefined ? token : token === "\\" ? "" : (unescaped(groups.slice(0, 6)) ?? token));
|
|
722
|
+
if (next === text)
|
|
723
|
+
break;
|
|
724
|
+
text = next;
|
|
725
|
+
}
|
|
726
|
+
return text;
|
|
727
|
+
}
|
|
728
|
+
/**
|
|
729
|
+
* Whether a request value carries a link whose query or
|
|
730
|
+
* fragment holds the exact `[redacted]` placeholder, plain or encoded: a
|
|
731
|
+
* withheld read copied into a write, which would store the placeholder.
|
|
732
|
+
* Only the link's own destination is read: a Markdown link ends at its
|
|
733
|
+
* closing `)`, so `[f](…?a=1)[redacted]` is ordinary text after it.
|
|
734
|
+
* The word alone (`unredacted`) or the placeholder away from a file link is
|
|
735
|
+
* a person's text. When `hosts` is supplied, require its actual authority
|
|
736
|
+
* to match that policy, preserving Notion's external-proxy boundary.
|
|
737
|
+
*/
|
|
738
|
+
export function carriesWithheldLink(value, hosts) {
|
|
739
|
+
const pending = [{ value }];
|
|
740
|
+
const ancestors = new WeakSet();
|
|
741
|
+
const inspect = (text) => {
|
|
742
|
+
for (const { cut, end } of hostLinks(text, hosts, false)) {
|
|
743
|
+
if (cut >= 0 && decodeLinkText(text.slice(cut, end)).includes(REDACTED))
|
|
744
|
+
return true;
|
|
745
|
+
}
|
|
746
|
+
return false;
|
|
747
|
+
};
|
|
748
|
+
// JSON structure is bounded by the admitted request's bytes. Visit every key
|
|
749
|
+
// and value iteratively, without imposing a smaller node/depth limit. Reject
|
|
750
|
+
// cycles from direct callers; repeated non-cyclic references remain valid.
|
|
751
|
+
while (pending.length > 0) {
|
|
752
|
+
const { value: item, leave } = pending.pop();
|
|
753
|
+
if (typeof item === "string") {
|
|
754
|
+
if (inspect(item))
|
|
755
|
+
return true;
|
|
756
|
+
}
|
|
757
|
+
else if (typeof item === "object" && item !== null) {
|
|
758
|
+
if (leave) {
|
|
759
|
+
ancestors.delete(item);
|
|
760
|
+
continue;
|
|
761
|
+
}
|
|
762
|
+
if (ancestors.has(item)) {
|
|
763
|
+
throw new ConnectorCallError("invalid_args", "Not sent: URL admission cannot inspect cyclic JSON.");
|
|
764
|
+
}
|
|
765
|
+
ancestors.add(item);
|
|
766
|
+
pending.push({ value: item, leave: true });
|
|
767
|
+
if (Array.isArray(item)) {
|
|
768
|
+
for (const child of item)
|
|
769
|
+
pending.push({ value: child });
|
|
770
|
+
}
|
|
771
|
+
else {
|
|
772
|
+
for (const [key, child] of Object.entries(item)) {
|
|
773
|
+
if (inspect(key))
|
|
774
|
+
return true;
|
|
775
|
+
pending.push({ value: child });
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
return false;
|
|
781
|
+
}
|
|
782
|
+
/** Refuse a withheld destination copied into customer data, before any dispatch. */
|
|
783
|
+
export function refuseWithheldLinks(value, title) {
|
|
784
|
+
if (!carriesWithheldLink(value))
|
|
785
|
+
return;
|
|
786
|
+
throw new ConnectorCallError("invalid_args", `This write carries a URL whose query or fragment Connecta withheld ([redacted]), so ${title} would store the placeholder. Do not write withheld URLs back; obtain the original link or omit the field.`);
|
|
787
|
+
}
|
|
788
|
+
/** Upload admission inspects actual bytes, including base64-decoded files and multipart blobs. */
|
|
789
|
+
export async function refuseWithheldUpload(body, title) {
|
|
790
|
+
if (body instanceof FormData) {
|
|
791
|
+
for (const [name, part] of body) {
|
|
792
|
+
refuseWithheldLinks(name, title);
|
|
793
|
+
if (typeof part === "string")
|
|
794
|
+
refuseWithheldLinks(part, title);
|
|
795
|
+
else {
|
|
796
|
+
refuseWithheldLinks(part.name, title);
|
|
797
|
+
refuseWithheldLinks(await part.text(), title);
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
else if (body instanceof Blob)
|
|
802
|
+
refuseWithheldLinks(await body.text(), title);
|
|
803
|
+
else if (body instanceof ArrayBuffer || ArrayBuffer.isView(body)) {
|
|
804
|
+
refuseWithheldLinks(new TextDecoder().decode(body), title);
|
|
805
|
+
}
|
|
806
|
+
else
|
|
807
|
+
refuseWithheldLinks(body instanceof URLSearchParams ? body.toString() : body, title);
|
|
808
|
+
}
|
|
809
|
+
/** Only for documented JSON fields the vendor decodes as base64, never arbitrary strings. */
|
|
810
|
+
export async function refuseWithheldBase64(value, title, maxDecodedBytes) {
|
|
811
|
+
if (typeof value !== "string") {
|
|
812
|
+
throw new ConnectorCallError("invalid_args", "Not sent: a base64 field must be a string.");
|
|
813
|
+
}
|
|
814
|
+
// One decode, bounded by this field's own encoded length (at most 3/4 as
|
|
815
|
+
// many bytes), itself bounded by the host's request limit. Operation-specific
|
|
816
|
+
// value limits are independent of response caps and raw-upload caps.
|
|
817
|
+
let decoded;
|
|
818
|
+
try {
|
|
819
|
+
decoded = atob(value);
|
|
820
|
+
}
|
|
821
|
+
catch {
|
|
822
|
+
throw new ConnectorCallError("invalid_args", "Not sent: a base64 field must be valid base64.");
|
|
823
|
+
}
|
|
824
|
+
if (maxDecodedBytes !== undefined && decoded.length > maxDecodedBytes) {
|
|
825
|
+
throw new ConnectorCallError("invalid_args", `Not sent: a base64 field must encode at most ${maxDecodedBytes} bytes.`);
|
|
826
|
+
}
|
|
827
|
+
const bytes = new Uint8Array(decoded.length);
|
|
828
|
+
for (let i = 0; i < decoded.length; i++)
|
|
829
|
+
bytes[i] = decoded.charCodeAt(i);
|
|
830
|
+
await refuseWithheldUpload(bytes, title);
|
|
831
|
+
}
|
|
190
832
|
/**
|
|
191
833
|
* A destination URL reduced to its origin: webhook, drain, and bearer URLs
|
|
192
834
|
* can carry a secret in the path itself (`hooks.slack.com/services/…`), so
|
|
@@ -298,10 +940,12 @@ export function validPath(raw) {
|
|
|
298
940
|
return segments.length > 0 && segments.every((segment) => SEGMENT.test(segment));
|
|
299
941
|
}
|
|
300
942
|
/** Apply one reviewed path to a body in place of its value. */
|
|
301
|
-
function applyPath(body, raw) {
|
|
943
|
+
function applyPath(body, raw, transform) {
|
|
302
944
|
const { mode, segments } = parsePath(raw);
|
|
303
945
|
const step = (current, index) => {
|
|
304
946
|
if (index === segments.length) {
|
|
947
|
+
if (transform)
|
|
948
|
+
return transform(current);
|
|
305
949
|
if (current === undefined || current === null)
|
|
306
950
|
return current;
|
|
307
951
|
if (mode === "url")
|
|
@@ -450,8 +1094,16 @@ function heuristic(value, exempt) {
|
|
|
450
1094
|
const walk = (current, trail, parent, depth) => {
|
|
451
1095
|
if (depth > MAX_DEPTH)
|
|
452
1096
|
return REDACTED;
|
|
453
|
-
if (typeof current === "string")
|
|
454
|
-
|
|
1097
|
+
if (typeof current === "string") {
|
|
1098
|
+
// Scan original escaped text before URL parsing. A string containing
|
|
1099
|
+
// HTML, Markdown or escapes is text, even when it has no whitespace.
|
|
1100
|
+
// Reviewed vendors already scanned signing names and selected text;
|
|
1101
|
+
// generic names in customer data must retain their original bytes.
|
|
1102
|
+
if (!exempt.reviewedUrls)
|
|
1103
|
+
return URL_SHAPE.test(current) ? sanitizeUrl(current) : current;
|
|
1104
|
+
const whole = URL_SHAPE.test(current) && !/[\s<>"'`\\]|&(?:#|[a-z]+;)|\]\(/i.test(current);
|
|
1105
|
+
return whole ? sanitizeUrl(current, false) : current;
|
|
1106
|
+
}
|
|
455
1107
|
if (Array.isArray(current))
|
|
456
1108
|
return current.map((item) => walk(item, trail, parent, depth + 1));
|
|
457
1109
|
if (!isRecord(current))
|
|
@@ -548,6 +1200,40 @@ export function valueSafety(table, index) {
|
|
|
548
1200
|
redact(data, method, template) {
|
|
549
1201
|
const reviewed = verdict(method, template);
|
|
550
1202
|
let out = data;
|
|
1203
|
+
if (table.embeddedUrls) {
|
|
1204
|
+
// Do this before any reviewed URL rule parses/serializes the text.
|
|
1205
|
+
out = mapStrings(out, withholdSigningParams, 0);
|
|
1206
|
+
for (const path of table.embeddedUrls.operations?.[`${method} ${template}`] ?? []) {
|
|
1207
|
+
out = applyPath(out, path, withholdEmbeddedUrlsIn);
|
|
1208
|
+
}
|
|
1209
|
+
const resources = table.embeddedUrls.resources;
|
|
1210
|
+
if (resources) {
|
|
1211
|
+
const scan = (value, depth) => {
|
|
1212
|
+
if (depth > MAX_DEPTH || value === null || typeof value !== "object")
|
|
1213
|
+
return value;
|
|
1214
|
+
if (Array.isArray(value))
|
|
1215
|
+
return value.map((item) => scan(item, depth + 1));
|
|
1216
|
+
let clean = Object.fromEntries(Object.entries(value).map(([key, item]) => [key, scan(item, depth + 1)]));
|
|
1217
|
+
const inner = clean[resources.key];
|
|
1218
|
+
const innerType = isRecord(inner) ? inner[resources.key] : undefined;
|
|
1219
|
+
if (typeof innerType === "string" && Object.hasOwn(resources.paths, innerType)) {
|
|
1220
|
+
for (const partial of resources.partials ?? []) {
|
|
1221
|
+
if (!Object.hasOwn(clean, partial))
|
|
1222
|
+
continue;
|
|
1223
|
+
for (const path of resources.paths[innerType])
|
|
1224
|
+
clean[partial] = applyPath(clean[partial], path, withholdEmbeddedUrlsIn);
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
1227
|
+
const type = clean[resources.key];
|
|
1228
|
+
if (typeof type === "string" && Object.hasOwn(resources.paths, type)) {
|
|
1229
|
+
for (const path of resources.paths[type])
|
|
1230
|
+
clean = applyPath(clean, path, withholdEmbeddedUrlsIn);
|
|
1231
|
+
}
|
|
1232
|
+
return clean;
|
|
1233
|
+
};
|
|
1234
|
+
out = scan(out, 0);
|
|
1235
|
+
}
|
|
1236
|
+
}
|
|
551
1237
|
if (reviewed?.verdict === "redact")
|
|
552
1238
|
for (const path of reviewed.paths)
|
|
553
1239
|
out = applyPath(out, path);
|
|
@@ -557,7 +1243,13 @@ export function valueSafety(table, index) {
|
|
|
557
1243
|
if (table.resources)
|
|
558
1244
|
out = resourcePass(out, table.resources, verbatim, 0);
|
|
559
1245
|
const keep = reviewed && reviewed.verdict !== "refuse" ? (reviewed.keep ?? []) : [];
|
|
560
|
-
return heuristic(out, {
|
|
1246
|
+
return heuristic(out, {
|
|
1247
|
+
keep: keep.map(trailOf),
|
|
1248
|
+
verbatim,
|
|
1249
|
+
fields,
|
|
1250
|
+
scopeMaps,
|
|
1251
|
+
reviewedUrls: table.embeddedUrls !== undefined,
|
|
1252
|
+
});
|
|
561
1253
|
},
|
|
562
1254
|
withholdsErrors(op) {
|
|
563
1255
|
const reviewed = verdict(op.method, op.path);
|