@slicedearth/whoisleuth-cli 1.43.0 → 1.45.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 +8 -4
- package/bin/whoisleuth.mjs +20 -9
- package/cli/bulk-checkpoint.mjs +65 -28
- package/cli/bulk-command-runner.mjs +15 -2
- package/cli/bulk.mjs +18 -7
- package/cli/completion.mjs +34 -3
- package/cli/config-profile.mjs +15 -12
- package/cli/discovery-observation-snapshot.mjs +8 -14
- package/cli/discovery-scan-command-runner.mjs +14 -1
- package/cli/discovery-snapshot.mjs +8 -15
- package/cli/errors.mjs +7 -1
- package/cli/formatters/json.mjs +12 -4
- package/cli/formatters/terminal.mjs +1 -1
- package/cli/http.mjs +2 -1
- package/cli/lookup-reconcile.mjs +1 -1
- package/cli/mail-review.mjs +5 -5
- package/cli/manual.mjs +1 -1
- package/cli/registry-fixture-scaffold.mjs +4 -3
- package/cli/runner.mjs +20 -9
- package/docs/cli-reference.md +26 -7
- package/docs/cli.md +1 -0
- package/frontend/src/lib/analysis/acquisition-due-diligence.js +1 -1
- package/frontend/src/lib/analysis/brand-profile-model.js +4 -2
- package/frontend/src/lib/analysis/case-evidence-model.js +1 -1
- package/frontend/src/lib/analysis/case-record-core.js +1 -1
- package/frontend/src/lib/analysis/case-record-operations.js +1 -1
- package/frontend/src/lib/analysis/lookup-asset-graph.js +8 -8
- package/frontend/src/lib/analysis/lookup-decision-support.js +2 -2
- package/frontend/src/lib/analysis/lookup-presentation.js +9 -15
- package/frontend/src/lib/analysis/lookup-source-refresh.js +36 -63
- package/frontend/src/lib/analysis/utils.js +1 -1
- package/frontend/src/lib/analysis/workspace-archive-crypto.js +1 -4
- package/frontend/src/lib/analysis/workspace-archive.js +3 -7
- package/lib/bounded-file.mjs +69 -0
- package/lib/bounded-json-response.mjs +157 -0
- package/lib/browser-library-profile.mjs +167 -38
- package/lib/lookup-cache.mjs +5 -1
- package/lib/rdap-transport.mjs +6 -6
- package/lib/safe-fetch.mjs +37 -32
- package/lib/threat-intelligence-runtime.mjs +1 -2
- package/lib/website-technology.mjs +2 -6
- package/package.json +1 -1
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { BoundedJsonResponseError, requestJsonCapped, } from "../bounded-json-response.js";
|
|
1
2
|
export const LOOKUP_SOURCE_REFRESH_VERSION = 1;
|
|
2
3
|
export const LOOKUP_SOURCE_STALE_AFTER_DAYS = 7;
|
|
3
4
|
export const LOOKUP_FRESHNESS_POLICY_VERSION = 1;
|
|
@@ -141,47 +142,6 @@ function record(value) {
|
|
|
141
142
|
? value
|
|
142
143
|
: {};
|
|
143
144
|
}
|
|
144
|
-
async function boundedJson(response) {
|
|
145
|
-
const declaredLength = Number(response.headers.get('content-length'));
|
|
146
|
-
if (Number.isFinite(declaredLength) && declaredLength > MAX_LOOKUP_SOURCE_REFRESH_BYTES) {
|
|
147
|
-
throw new Error('oversized');
|
|
148
|
-
}
|
|
149
|
-
if (!response.body)
|
|
150
|
-
return {};
|
|
151
|
-
const reader = response.body.getReader();
|
|
152
|
-
const chunks = [];
|
|
153
|
-
let total = 0;
|
|
154
|
-
try {
|
|
155
|
-
while (true) {
|
|
156
|
-
const { done, value } = await reader.read();
|
|
157
|
-
if (done)
|
|
158
|
-
break;
|
|
159
|
-
if (!value)
|
|
160
|
-
continue;
|
|
161
|
-
total += value.byteLength;
|
|
162
|
-
if (total > MAX_LOOKUP_SOURCE_REFRESH_BYTES)
|
|
163
|
-
throw new Error('oversized');
|
|
164
|
-
chunks.push(value);
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
|
-
finally {
|
|
168
|
-
if (total > MAX_LOOKUP_SOURCE_REFRESH_BYTES)
|
|
169
|
-
await reader.cancel().catch(() => { });
|
|
170
|
-
reader.releaseLock();
|
|
171
|
-
}
|
|
172
|
-
const bytes = new Uint8Array(total);
|
|
173
|
-
let offset = 0;
|
|
174
|
-
for (const chunk of chunks) {
|
|
175
|
-
bytes.set(chunk, offset);
|
|
176
|
-
offset += chunk.byteLength;
|
|
177
|
-
}
|
|
178
|
-
try {
|
|
179
|
-
return JSON.parse(new TextDecoder().decode(bytes));
|
|
180
|
-
}
|
|
181
|
-
catch {
|
|
182
|
-
return {};
|
|
183
|
-
}
|
|
184
|
-
}
|
|
185
145
|
function text(value, maximum = 180) {
|
|
186
146
|
return String(value ?? '')
|
|
187
147
|
.replace(/[\u0000-\u001f\u007f]+/gu, ' ')
|
|
@@ -194,6 +154,7 @@ function summarizeSource(plan, body, observedAt, depth, supersedesObservedAt) {
|
|
|
194
154
|
version: LOOKUP_SOURCE_REFRESH_VERSION,
|
|
195
155
|
id: plan.id,
|
|
196
156
|
observedAt,
|
|
157
|
+
attemptedAt: observedAt,
|
|
197
158
|
reason: plan.reason,
|
|
198
159
|
evidenceIds: plan.evidenceIds.slice(0, 12),
|
|
199
160
|
supersedesObservedAt,
|
|
@@ -208,7 +169,6 @@ function summarizeSource(plan, body, observedAt, depth, supersedesObservedAt) {
|
|
|
208
169
|
detail: complete
|
|
209
170
|
? `Registry RDAP returned a validated ${upstreamStatus} response.`
|
|
210
171
|
: `Registry RDAP returned ${Number.isFinite(upstreamStatus) ? `HTTP ${upstreamStatus}` : 'no complete structured record'}.`,
|
|
211
|
-
observedAt,
|
|
212
172
|
};
|
|
213
173
|
}
|
|
214
174
|
if (plan.id === 'whois') {
|
|
@@ -221,7 +181,6 @@ function summarizeSource(plan, body, observedAt, depth, supersedesObservedAt) {
|
|
|
221
181
|
detail: complete
|
|
222
182
|
? `WHOIS returned a complete ${chain.length}-hop referral chain.`
|
|
223
183
|
: `WHOIS returned ${chain.length} hop${chain.length === 1 ? '' : 's'} with ${chainStatus || 'unknown'} chain status.`,
|
|
224
|
-
observedAt,
|
|
225
184
|
};
|
|
226
185
|
}
|
|
227
186
|
const state = text(body.state, 40) || 'unknown';
|
|
@@ -234,7 +193,19 @@ function summarizeSource(plan, body, observedAt, depth, supersedesObservedAt) {
|
|
|
234
193
|
...base,
|
|
235
194
|
state: complete ? 'complete' : 'limited',
|
|
236
195
|
detail: `Domain evidence returned ${state}${sourceStates.length ? `; DNS, HTTP, and TLS states: ${sourceStates.join(', ')}` : ''}.`,
|
|
237
|
-
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
function unavailableRefreshResult(plan, attemptedAt, supersedesObservedAt, detail) {
|
|
199
|
+
return {
|
|
200
|
+
version: LOOKUP_SOURCE_REFRESH_VERSION,
|
|
201
|
+
id: plan.id,
|
|
202
|
+
state: 'unavailable',
|
|
203
|
+
detail,
|
|
204
|
+
observedAt: null,
|
|
205
|
+
attemptedAt,
|
|
206
|
+
reason: plan.reason,
|
|
207
|
+
evidenceIds: plan.evidenceIds.slice(0, 12),
|
|
208
|
+
supersedesObservedAt,
|
|
238
209
|
};
|
|
239
210
|
}
|
|
240
211
|
export async function requestLookupSourceRefresh(plan, query, depth, options = {}) {
|
|
@@ -244,19 +215,24 @@ export async function requestLookupSourceRefresh(plan, query, depth, options = {
|
|
|
244
215
|
const timeoutMs = Number.isFinite(options.timeoutMs)
|
|
245
216
|
? Math.max(1, Math.min(LOOKUP_SOURCE_REFRESH_TIMEOUT_MS, Math.round(Number(options.timeoutMs))))
|
|
246
217
|
: LOOKUP_SOURCE_REFRESH_TIMEOUT_MS;
|
|
247
|
-
const
|
|
248
|
-
const
|
|
218
|
+
const attemptedAt = options.now?.() ?? new Date().toISOString();
|
|
219
|
+
const supersedesObservedAt = options.supersedesObservedAt === undefined
|
|
220
|
+
? plan.supersedesObservedAt
|
|
221
|
+
: options.supersedesObservedAt;
|
|
249
222
|
try {
|
|
250
223
|
const url = `${plan.endpoint}?q=${encodeURIComponent(normalizedQuery)}${plan.id === 'availability' && depth === 'fast' ? '&fast=true' : ''}`;
|
|
251
|
-
const response = await (
|
|
224
|
+
const { response, body: rawBody } = await requestJsonCapped(url, {
|
|
252
225
|
credentials: 'same-origin',
|
|
253
|
-
|
|
226
|
+
}, {
|
|
227
|
+
...(options.fetchImpl ? { fetchImpl: options.fetchImpl } : {}),
|
|
228
|
+
maximumBytes: MAX_LOOKUP_SOURCE_REFRESH_BYTES,
|
|
229
|
+
timeoutMs,
|
|
254
230
|
});
|
|
255
|
-
const body = record(
|
|
231
|
+
const body = record(rawBody);
|
|
256
232
|
if (!response.ok) {
|
|
257
233
|
return {
|
|
258
|
-
ok:
|
|
259
|
-
|
|
234
|
+
ok: true,
|
|
235
|
+
value: unavailableRefreshResult(plan, attemptedAt, supersedesObservedAt, text(body.error, 240) || `The source returned HTTP ${response.status}; no new observation was recorded.`),
|
|
260
236
|
};
|
|
261
237
|
}
|
|
262
238
|
if (Object.keys(body).length > MAX_LOOKUP_SOURCE_REFRESH_KEYS) {
|
|
@@ -264,23 +240,20 @@ export async function requestLookupSourceRefresh(plan, query, depth, options = {
|
|
|
264
240
|
}
|
|
265
241
|
return {
|
|
266
242
|
ok: true,
|
|
267
|
-
value: summarizeSource(plan, body,
|
|
268
|
-
? plan.supersedesObservedAt
|
|
269
|
-
: options.supersedesObservedAt),
|
|
243
|
+
value: summarizeSource(plan, body, attemptedAt, depth, supersedesObservedAt),
|
|
270
244
|
};
|
|
271
245
|
}
|
|
272
246
|
catch (cause) {
|
|
273
|
-
if (cause instanceof
|
|
274
|
-
return { ok:
|
|
247
|
+
if (cause instanceof BoundedJsonResponseError && cause.code === 'response_too_large') {
|
|
248
|
+
return { ok: true, value: unavailableRefreshResult(plan, attemptedAt, supersedesObservedAt, 'The source response exceeded the local limit; no new observation was recorded.') };
|
|
275
249
|
}
|
|
276
250
|
return {
|
|
277
|
-
ok:
|
|
278
|
-
|
|
251
|
+
ok: true,
|
|
252
|
+
value: unavailableRefreshResult(plan, attemptedAt, supersedesObservedAt, cause instanceof BoundedJsonResponseError && cause.code === 'timeout'
|
|
253
|
+
? 'The source refresh timed out; no new observation was recorded.'
|
|
254
|
+
: 'The source refresh could not be completed; no new observation was recorded.'),
|
|
279
255
|
};
|
|
280
256
|
}
|
|
281
|
-
finally {
|
|
282
|
-
clearTimeout(timeout);
|
|
283
|
-
}
|
|
284
257
|
}
|
|
285
258
|
export function mergeLookupSourceRefreshLedger(current, result) {
|
|
286
259
|
const candidates = [
|
|
@@ -289,10 +262,10 @@ export function mergeLookupSourceRefreshLedger(current, result) {
|
|
|
289
262
|
];
|
|
290
263
|
const byObservation = new Map();
|
|
291
264
|
for (const entry of candidates.slice(-MAX_LOOKUP_SOURCE_REFRESH_HISTORY * 4)) {
|
|
292
|
-
byObservation.set(`${entry.id}:${entry.
|
|
265
|
+
byObservation.set(`${entry.id}:${entry.attemptedAt}`, entry);
|
|
293
266
|
}
|
|
294
267
|
const ordered = [...byObservation.values()]
|
|
295
|
-
.sort((left, right) => Date.parse(left.
|
|
268
|
+
.sort((left, right) => Date.parse(left.attemptedAt) - Date.parse(right.attemptedAt));
|
|
296
269
|
const truncated = Boolean(current?.truncated)
|
|
297
270
|
|| ordered.length > MAX_LOOKUP_SOURCE_REFRESH_HISTORY
|
|
298
271
|
|| candidates.length > MAX_LOOKUP_SOURCE_REFRESH_HISTORY;
|
|
@@ -5,7 +5,7 @@ import { hammingDistanceHex, isInformativePerceptualHash, isPerceptualHash, } fr
|
|
|
5
5
|
// this only gates whether a WHOIS/RDAP-sourced string is safe to drop into a
|
|
6
6
|
// mailto: URI as the recipient, not a general email validator. mailto:
|
|
7
7
|
// treats a comma as an additional-recipient separator (RFC 6068), so a
|
|
8
|
-
// registrant/abuse-contact field containing e.g. "
|
|
8
|
+
// registrant/abuse-contact field containing e.g. "person@example.test,cc@hostile.example"
|
|
9
9
|
// would silently add a second recipient to the outreach/abuse draft the user
|
|
10
10
|
// opens - rejecting anything outside a single plain address closes that off.
|
|
11
11
|
const SIMPLE_EMAIL_RE = /^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$/;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Browser-local authenticated encryption for portable workspace archives.
|
|
2
2
|
// This protects a downloaded file while it is locked. It does not encrypt the
|
|
3
3
|
// active IndexedDB workspace or defend an unlocked page from same-origin code.
|
|
4
|
-
import { MAX_WORKSPACE_ARCHIVE_BYTES, WORKSPACE_ARCHIVE_SCHEMA,
|
|
4
|
+
import { MAX_WORKSPACE_ARCHIVE_BYTES, WORKSPACE_ARCHIVE_SCHEMA, isSupportedWorkspaceArchiveVersion, readWorkspaceArchive, } from "./workspace-archive.js";
|
|
5
5
|
export const ENCRYPTED_WORKSPACE_ARCHIVE_SCHEMA = 'whoisleuth.encrypted-workspace-archive';
|
|
6
6
|
export const ENCRYPTED_WORKSPACE_ARCHIVE_VERSION = 1;
|
|
7
7
|
export const WORKSPACE_ARCHIVE_PBKDF2_ITERATIONS = 600_000;
|
|
@@ -26,9 +26,6 @@ function hasExactKeys(value, expected) {
|
|
|
26
26
|
const keys = Object.keys(value);
|
|
27
27
|
return keys.length === expected.length && keys.every((key) => expected.includes(key));
|
|
28
28
|
}
|
|
29
|
-
function byteLength(value) {
|
|
30
|
-
return encoder.encode(value).byteLength;
|
|
31
|
-
}
|
|
32
29
|
function arrayBuffer(bytes) {
|
|
33
30
|
return bytes.slice().buffer;
|
|
34
31
|
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// authority for normalization and merge semantics; this module only packages
|
|
3
3
|
// their portable contracts, verifies integrity, and previews conflicts.
|
|
4
4
|
import { buildCaseExport, CASE_SCHEMA_VERSION, enforceStoreBudget, mergeCases, } from "./case-model.js";
|
|
5
|
-
import { assertBrandProfileStoreBudget, BRAND_PROFILE_SCHEMA, BRAND_PROFILE_SCHEMA_VERSION, buildBrandProfileExport, mergeBrandProfiles, } from "./brand-profile-model.js";
|
|
5
|
+
import { assertBrandProfileStoreBudget, BRAND_PROFILE_SCHEMA, BRAND_PROFILE_SCHEMA_VERSION, buildBrandProfileExport, mergeBrandProfiles, normalizeBrandProfileId, } from "./brand-profile-model.js";
|
|
6
6
|
import { assertCampaignStoreBudget, buildCampaignExport, CAMPAIGN_SCHEMA, CAMPAIGN_SCHEMA_VERSION, mergeCampaigns, } from "./campaign-model.js";
|
|
7
7
|
import { assertWatchlistStoreBudget, buildWatchlistExport, mergeWatchlistStores, WATCHLIST_SCHEMA, WATCHLIST_SCHEMA_VERSION, } from "./watchlist-store.js";
|
|
8
8
|
import { assertShortlistStoreBudget, buildShortlistExport, mergeShortlistStores, SHORTLIST_SCHEMA, SHORTLIST_SCHEMA_VERSION, } from "./shortlist-model.js";
|
|
@@ -40,7 +40,6 @@ export function isSupportedWorkspaceArchiveVersion(value) {
|
|
|
40
40
|
&& SUPPORTED_WORKSPACE_ARCHIVE_VERSIONS.some((version) => version === value);
|
|
41
41
|
}
|
|
42
42
|
const CONTROL_RE = /[\x00-\x1f\x7f]/;
|
|
43
|
-
const SAFE_ID_RE = /^[A-Za-z0-9_-]{1,128}$/;
|
|
44
43
|
const CHECKSUM_RE = /^sha256:[a-f0-9]{64}$/;
|
|
45
44
|
function record(value) {
|
|
46
45
|
return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
|
|
@@ -101,9 +100,7 @@ async function checksum(value, cryptoProvider = globalThis.crypto) {
|
|
|
101
100
|
}
|
|
102
101
|
function settingsDocument(input) {
|
|
103
102
|
const profiles = Array.isArray(input.brandProfiles) ? input.brandProfiles : [];
|
|
104
|
-
const requestedProfileId =
|
|
105
|
-
? input.settings.activeProfileId
|
|
106
|
-
: '';
|
|
103
|
+
const requestedProfileId = normalizeBrandProfileId(input.settings?.activeProfileId) || '';
|
|
107
104
|
const activeProfileId = profiles.some((profile) => profile?.id === requestedProfileId)
|
|
108
105
|
? requestedProfileId
|
|
109
106
|
: '';
|
|
@@ -406,8 +403,7 @@ export async function readWorkspaceArchive(raw, options = {}) {
|
|
|
406
403
|
}
|
|
407
404
|
function settingsPreview(data, local, mergedProfiles) {
|
|
408
405
|
const value = record(data) || {};
|
|
409
|
-
const
|
|
410
|
-
const activeProfileId = SAFE_ID_RE.test(requestedProfileId) ? requestedProfileId : '';
|
|
406
|
+
const activeProfileId = normalizeBrandProfileId(value.activeProfileId) || '';
|
|
411
407
|
const theme = normalizeTheme(value.theme);
|
|
412
408
|
let skipped = 0;
|
|
413
409
|
if (activeProfileId && !mergedProfiles.some((profile) => profile?.id === activeProfileId))
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { constants } from 'node:fs';
|
|
2
|
+
import { open } from 'node:fs/promises';
|
|
3
|
+
const MAX_BOUNDED_FILE_BYTES = 64 * 1024 * 1024;
|
|
4
|
+
function boundedSize(value, fallback) {
|
|
5
|
+
return Number.isSafeInteger(value) && Number(value) >= 0 && Number(value) <= MAX_BOUNDED_FILE_BYTES
|
|
6
|
+
? Number(value)
|
|
7
|
+
: fallback;
|
|
8
|
+
}
|
|
9
|
+
export async function readBoundedRegularFile(filePath, options, dependencies = {}) {
|
|
10
|
+
const label = options.label?.trim().slice(0, 120) || 'Input file';
|
|
11
|
+
const maximumBytes = boundedSize(options.maximumBytes, 0);
|
|
12
|
+
const minimumBytes = boundedSize(options.minimumBytes, 0);
|
|
13
|
+
if (maximumBytes < 1 || minimumBytes > maximumBytes) {
|
|
14
|
+
throw new TypeError(`${label} has an invalid byte boundary.`);
|
|
15
|
+
}
|
|
16
|
+
const expectedBytes = options.expectedBytes === null || options.expectedBytes === undefined
|
|
17
|
+
? null
|
|
18
|
+
: boundedSize(options.expectedBytes, -1);
|
|
19
|
+
if (expectedBytes !== null && (expectedBytes < minimumBytes || expectedBytes > maximumBytes)) {
|
|
20
|
+
throw new TypeError(`${label} expected size is outside its byte boundary.`);
|
|
21
|
+
}
|
|
22
|
+
// Non-blocking open prevents a named pipe from stalling the process before
|
|
23
|
+
// the descriptor can be classified. Final-component symlinks remain denied
|
|
24
|
+
// unless an analyst-facing input explicitly opts in; artefacts resolved
|
|
25
|
+
// inside a verified directory must retain the default confinement boundary.
|
|
26
|
+
const flags = constants.O_RDONLY
|
|
27
|
+
| constants.O_NONBLOCK
|
|
28
|
+
| (options.allowSymbolicLink ? 0 : constants.O_NOFOLLOW);
|
|
29
|
+
const handle = await (dependencies.openFile ?? open)(filePath, flags);
|
|
30
|
+
try {
|
|
31
|
+
const before = await handle.stat();
|
|
32
|
+
if (!before.isFile())
|
|
33
|
+
throw new TypeError(`${label} must be a regular file.`);
|
|
34
|
+
if (before.size < minimumBytes)
|
|
35
|
+
throw new TypeError(`${label} is smaller than its ${minimumBytes}-byte minimum.`);
|
|
36
|
+
if (before.size > maximumBytes)
|
|
37
|
+
throw new TypeError(`${label} exceeds its ${maximumBytes}-byte maximum.`);
|
|
38
|
+
if (expectedBytes !== null && before.size !== expectedBytes) {
|
|
39
|
+
throw new TypeError(`${label} size does not match its declared size.`);
|
|
40
|
+
}
|
|
41
|
+
// Read from the already-opened descriptor and reserve only one sentinel
|
|
42
|
+
// byte beyond the admitted size. A concurrent append is detected without
|
|
43
|
+
// allocating from an attacker-controlled post-stat file length.
|
|
44
|
+
const storage = Buffer.allocUnsafe(Math.min(maximumBytes + 1, before.size + 1));
|
|
45
|
+
let offset = 0;
|
|
46
|
+
while (offset < storage.length) {
|
|
47
|
+
const { bytesRead } = await handle.read(storage, offset, storage.length - offset, null);
|
|
48
|
+
if (bytesRead === 0)
|
|
49
|
+
break;
|
|
50
|
+
offset += bytesRead;
|
|
51
|
+
}
|
|
52
|
+
const after = await handle.stat();
|
|
53
|
+
if (offset > maximumBytes
|
|
54
|
+
|| after.size !== before.size
|
|
55
|
+
|| offset !== after.size
|
|
56
|
+
|| after.mtimeMs !== before.mtimeMs
|
|
57
|
+
|| after.ctimeMs !== before.ctimeMs) {
|
|
58
|
+
throw new TypeError(`${label} changed while it was being read.`);
|
|
59
|
+
}
|
|
60
|
+
return Buffer.from(storage.subarray(0, offset));
|
|
61
|
+
}
|
|
62
|
+
finally {
|
|
63
|
+
await handle.close();
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
export async function readBoundedRegularTextFile(filePath, options, dependencies = {}) {
|
|
67
|
+
return (await readBoundedRegularFile(filePath, options, dependencies)).toString('utf8');
|
|
68
|
+
}
|
|
69
|
+
export { MAX_BOUNDED_FILE_BYTES };
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// Environment-neutral browser/server JSON response boundary. It enforces a
|
|
2
|
+
// declared and streamed byte ceiling before parsing and can apply one deadline
|
|
3
|
+
// across both the fetch and body-read phases.
|
|
4
|
+
export const SMALL_JSON_RESPONSE_BYTES = 64 * 1024;
|
|
5
|
+
export const STANDARD_JSON_RESPONSE_BYTES = 2 * 1024 * 1024;
|
|
6
|
+
export const LARGE_JSON_RESPONSE_BYTES = 8 * 1024 * 1024;
|
|
7
|
+
export const DEFAULT_JSON_RESPONSE_TIMEOUT_MS = 40_000;
|
|
8
|
+
const MAX_JSON_RESPONSE_TIMEOUT_MS = 120_000;
|
|
9
|
+
export class BoundedJsonResponseError extends Error {
|
|
10
|
+
code;
|
|
11
|
+
constructor(code, message) {
|
|
12
|
+
super(message);
|
|
13
|
+
this.name = 'BoundedJsonResponseError';
|
|
14
|
+
this.code = code;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
async function raceWithAbort(promise, signal, onLateResolution) {
|
|
18
|
+
if (signal.aborted)
|
|
19
|
+
throw signal.reason ?? new Error('Request aborted.');
|
|
20
|
+
return new Promise((resolve, reject) => {
|
|
21
|
+
let aborted = false;
|
|
22
|
+
const onAbort = () => {
|
|
23
|
+
aborted = true;
|
|
24
|
+
reject(signal.reason ?? new Error('Request aborted.'));
|
|
25
|
+
};
|
|
26
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
27
|
+
void promise.then((value) => {
|
|
28
|
+
signal.removeEventListener('abort', onAbort);
|
|
29
|
+
if (aborted) {
|
|
30
|
+
void Promise.resolve(onLateResolution?.(value)).catch(() => { });
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
resolve(value);
|
|
34
|
+
}, (cause) => {
|
|
35
|
+
signal.removeEventListener('abort', onAbort);
|
|
36
|
+
reject(cause);
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
function boundedPositiveInteger(value, fallback, maximum) {
|
|
41
|
+
return typeof value === 'number' && Number.isFinite(value)
|
|
42
|
+
? Math.max(1, Math.min(maximum, Math.round(value)))
|
|
43
|
+
: fallback;
|
|
44
|
+
}
|
|
45
|
+
export async function readJsonResponseCapped(response, maximumBytes = STANDARD_JSON_RESPONSE_BYTES, signal) {
|
|
46
|
+
const maxBytes = boundedPositiveInteger(maximumBytes, STANDARD_JSON_RESPONSE_BYTES, LARGE_JSON_RESPONSE_BYTES);
|
|
47
|
+
const declared = response.headers.get('content-length');
|
|
48
|
+
if (declared && /^\d+$/u.test(declared)) {
|
|
49
|
+
const bytes = Number(declared);
|
|
50
|
+
if (!Number.isSafeInteger(bytes) || bytes > maxBytes) {
|
|
51
|
+
await response.body?.cancel().catch(() => { });
|
|
52
|
+
throw new BoundedJsonResponseError('response_too_large', `JSON response exceeded ${maxBytes} bytes.`);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
if (!response.body)
|
|
56
|
+
return null;
|
|
57
|
+
const reader = response.body.getReader();
|
|
58
|
+
const cancelOnAbort = () => { void reader.cancel(signal?.reason).catch(() => { }); };
|
|
59
|
+
signal?.addEventListener('abort', cancelOnAbort, { once: true });
|
|
60
|
+
const chunks = [];
|
|
61
|
+
let total = 0;
|
|
62
|
+
try {
|
|
63
|
+
while (true) {
|
|
64
|
+
const read = reader.read();
|
|
65
|
+
const { done, value } = signal ? await raceWithAbort(read, signal) : await read;
|
|
66
|
+
if (done)
|
|
67
|
+
break;
|
|
68
|
+
if (!value)
|
|
69
|
+
continue;
|
|
70
|
+
total += value.byteLength;
|
|
71
|
+
if (total > maxBytes) {
|
|
72
|
+
await reader.cancel().catch(() => { });
|
|
73
|
+
throw new BoundedJsonResponseError('response_too_large', `JSON response exceeded ${maxBytes} bytes.`);
|
|
74
|
+
}
|
|
75
|
+
chunks.push(value);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
finally {
|
|
79
|
+
signal?.removeEventListener('abort', cancelOnAbort);
|
|
80
|
+
reader.releaseLock();
|
|
81
|
+
}
|
|
82
|
+
const bytes = new Uint8Array(total);
|
|
83
|
+
let offset = 0;
|
|
84
|
+
for (const chunk of chunks) {
|
|
85
|
+
bytes.set(chunk, offset);
|
|
86
|
+
offset += chunk.byteLength;
|
|
87
|
+
}
|
|
88
|
+
try {
|
|
89
|
+
return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes));
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
throw new BoundedJsonResponseError('invalid_json', 'Response body was not valid UTF-8 JSON.');
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
export async function requestJsonCapped(input, init = {}, options = {}) {
|
|
96
|
+
const timeoutMs = boundedPositiveInteger(options.timeoutMs, DEFAULT_JSON_RESPONSE_TIMEOUT_MS, MAX_JSON_RESPONSE_TIMEOUT_MS);
|
|
97
|
+
const controller = new AbortController();
|
|
98
|
+
const upstreamSignal = init.signal;
|
|
99
|
+
const forwardAbort = () => controller.abort(upstreamSignal?.reason);
|
|
100
|
+
if (upstreamSignal?.aborted)
|
|
101
|
+
forwardAbort();
|
|
102
|
+
else
|
|
103
|
+
upstreamSignal?.addEventListener('abort', forwardAbort, { once: true });
|
|
104
|
+
const timeoutReason = Object.freeze({ type: 'bounded-json-timeout' });
|
|
105
|
+
const timeout = setTimeout(() => controller.abort(timeoutReason), timeoutMs);
|
|
106
|
+
try {
|
|
107
|
+
if (controller.signal.aborted) {
|
|
108
|
+
throw new BoundedJsonResponseError('aborted', 'Request was cancelled.');
|
|
109
|
+
}
|
|
110
|
+
const fetchRequest = (options.fetchImpl ?? fetch)(input, {
|
|
111
|
+
...init,
|
|
112
|
+
signal: controller.signal,
|
|
113
|
+
});
|
|
114
|
+
const response = await raceWithAbort(fetchRequest, controller.signal, async (lateResponse) => lateResponse.body?.cancel().catch(() => { }));
|
|
115
|
+
const contentType = response.headers.get('content-type')?.toLowerCase() ?? '';
|
|
116
|
+
const plausibleJson = !contentType
|
|
117
|
+
|| contentType.includes('application/json')
|
|
118
|
+
|| contentType.includes('+json');
|
|
119
|
+
if (options.allowNonJsonErrorResponse !== false && !response.ok && !plausibleJson) {
|
|
120
|
+
await response.body?.cancel().catch(() => { });
|
|
121
|
+
return { response, body: null };
|
|
122
|
+
}
|
|
123
|
+
let body;
|
|
124
|
+
try {
|
|
125
|
+
body = await readJsonResponseCapped(response, options.maximumBytes, controller.signal);
|
|
126
|
+
}
|
|
127
|
+
catch (cause) {
|
|
128
|
+
// Reverse proxies and hosting adapters can replace an upstream JSON
|
|
129
|
+
// error with a bounded HTML or plain-text response. Preserve the HTTP
|
|
130
|
+
// status so callers can apply retry and generic error handling, while
|
|
131
|
+
// continuing to require valid JSON for successful responses.
|
|
132
|
+
if (options.allowNonJsonErrorResponse !== false
|
|
133
|
+
&& !response.ok
|
|
134
|
+
&& cause instanceof BoundedJsonResponseError
|
|
135
|
+
&& cause.code === 'invalid_json') {
|
|
136
|
+
body = null;
|
|
137
|
+
}
|
|
138
|
+
else {
|
|
139
|
+
throw cause;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return { response, body };
|
|
143
|
+
}
|
|
144
|
+
catch (cause) {
|
|
145
|
+
if (controller.signal.aborted) {
|
|
146
|
+
const timedOut = controller.signal.reason === timeoutReason;
|
|
147
|
+
throw new BoundedJsonResponseError(timedOut ? 'timeout' : 'aborted', timedOut ? `Request timed out after ${timeoutMs} ms.` : 'Request was cancelled.');
|
|
148
|
+
}
|
|
149
|
+
if (cause instanceof BoundedJsonResponseError)
|
|
150
|
+
throw cause;
|
|
151
|
+
throw cause;
|
|
152
|
+
}
|
|
153
|
+
finally {
|
|
154
|
+
clearTimeout(timeout);
|
|
155
|
+
upstreamSignal?.removeEventListener('abort', forwardAbort);
|
|
156
|
+
}
|
|
157
|
+
}
|