Skip to content

Commit 845ad1c

Browse files
committed
fix(tooling): timeout transitive manifest packuments
1 parent 356a199 commit 845ad1c

2 files changed

Lines changed: 49 additions & 45 deletions

File tree

scripts/transitive-manifest-risk-report.mjs

Lines changed: 11 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { readFile } from "node:fs/promises";
66
import path from "node:path";
77
import process from "node:process";
88
import YAML from "yaml";
9+
import { readBoundedResponseText } from "./lib/bounded-response.mjs";
910
import { parseReportCliArgs, writeReportArtifact } from "./lib/report-cli-helpers.mjs";
1011
import {
1112
collectAllResolvedPackagesFromLockfile,
@@ -24,6 +25,7 @@ const RECENTLY_PUBLISHED_VERSION_TYPE = "recently-published-version";
2425
const NPM_PACKUMENT_ACCEPT_HEADER = "application/json";
2526
/** Maximum npm packument response size accepted by the risk scanner. */
2627
export const NPM_PACKUMENT_RESPONSE_MAX_BYTES = 16 * 1024 * 1024;
28+
const NPM_PACKUMENT_FETCH_TIMEOUT_MS = 60_000;
2729

2830
function isAllowedPinnedSpec(spec) {
2931
if (typeof spec !== "string") {
@@ -64,47 +66,12 @@ function isExoticResolvedVersion(version) {
6466
export async function readBoundedNpmRegistryText(
6567
response,
6668
maxBytes = NPM_PACKUMENT_RESPONSE_MAX_BYTES,
69+
options = {},
6770
) {
68-
const contentLength = response.headers?.get?.("content-length");
69-
if (contentLength && /^\d+$/u.test(contentLength)) {
70-
const parsedContentLength = Number(contentLength);
71-
if (Number.isSafeInteger(parsedContentLength) && parsedContentLength > maxBytes) {
72-
await response.body?.cancel().catch(() => {});
73-
throw new Error(
74-
`npm registry response exceeded ${maxBytes} bytes (content-length ${contentLength})`,
75-
);
76-
}
77-
}
78-
79-
if (!response.body) {
80-
return "";
81-
}
82-
83-
const reader = response.body.getReader();
84-
const decoder = new TextDecoder();
85-
let receivedBytes = 0;
86-
let text = "";
87-
88-
try {
89-
while (true) {
90-
const { done, value } = await reader.read();
91-
if (done) {
92-
break;
93-
}
94-
receivedBytes += value.byteLength;
95-
if (receivedBytes > maxBytes) {
96-
await reader.cancel().catch(() => {});
97-
throw new Error(
98-
`npm registry response exceeded ${maxBytes} bytes while reading response body`,
99-
);
100-
}
101-
text += decoder.decode(value, { stream: true });
102-
}
103-
text += decoder.decode();
104-
return text;
105-
} finally {
106-
reader.releaseLock();
107-
}
71+
return await readBoundedResponseText(response, "npm registry", maxBytes, {
72+
signal: options.signal,
73+
formatTooLargeMessage: (_label, bytes) => `npm registry response exceeded ${bytes} bytes`,
74+
});
10875
}
10976

11077
function packageVersionsFromPayload(payload) {
@@ -266,16 +233,19 @@ export async function fetchNpmManifest({
266233
fetchImpl,
267234
registryBaseUrl,
268235
maxBytes = NPM_PACKUMENT_RESPONSE_MAX_BYTES,
236+
timeoutMs = NPM_PACKUMENT_FETCH_TIMEOUT_MS,
269237
}) {
238+
const signal = AbortSignal.timeout(timeoutMs);
270239
const response = await fetchImpl(`${registryBaseUrl}/${encodePackageName(packageName)}`, {
271240
headers: {
272241
Accept: NPM_PACKUMENT_ACCEPT_HEADER,
273242
},
243+
signal,
274244
});
275245
if (!response.ok) {
276246
throw new Error(`${response.status} ${response.statusText}`);
277247
}
278-
const packumentText = await readBoundedNpmRegistryText(response, maxBytes);
248+
const packumentText = await readBoundedNpmRegistryText(response, maxBytes, { signal });
279249
const packument = JSON.parse(packumentText);
280250
const manifest = packument.versions?.[version];
281251
if (!manifest) {

test/scripts/transitive-manifest-risk-report.test.ts

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -179,7 +179,8 @@ describe("transitive-manifest-risk-report", () => {
179179
});
180180

181181
it("fetches full npm packuments for the requested manifest version", async () => {
182-
const fetchCalls: Array<{ url: string; accept: string | null }> = [];
182+
const fetchCalls: Array<{ url: string; accept: string | null; signal: AbortSignal | null }> =
183+
[];
183184
const manifest = await fetchNpmManifest({
184185
packageName: "@scope/package",
185186
version: "1.0.0",
@@ -188,6 +189,7 @@ describe("transitive-manifest-risk-report", () => {
188189
fetchCalls.push({
189190
url: String(url),
190191
accept: new Headers(init?.headers).get("accept"),
192+
signal: init?.signal instanceof AbortSignal ? init.signal : null,
191193
});
192194
return new Response(
193195
JSON.stringify({
@@ -216,6 +218,7 @@ describe("transitive-manifest-risk-report", () => {
216218
{
217219
url: "https://registry.example.test/@scope%2fpackage",
218220
accept: "application/json",
221+
signal: expect.any(AbortSignal),
219222
},
220223
]);
221224
expect(manifest).toEqual({
@@ -231,6 +234,37 @@ describe("transitive-manifest-risk-report", () => {
231234
});
232235
});
233236

237+
it("cancels stalled npm registry body reads when the request aborts", async () => {
238+
const controller = new AbortController();
239+
let canceled = false;
240+
const response = {
241+
headers: new Headers(),
242+
body: {
243+
getReader() {
244+
return {
245+
read() {
246+
return new Promise<ReadableStreamReadResult<Uint8Array>>(() => {});
247+
},
248+
async cancel() {
249+
canceled = true;
250+
},
251+
releaseLock() {
252+
throw new Error("releaseLock should not run while a read is pending");
253+
},
254+
};
255+
},
256+
},
257+
} as unknown as Response;
258+
259+
const readPromise = readBoundedNpmRegistryText(response, 8, {
260+
signal: controller.signal,
261+
});
262+
controller.abort(new Error("npm registry request timed out"));
263+
264+
await expect(readPromise).rejects.toThrow("npm registry request timed out");
265+
expect(canceled).toBe(true);
266+
});
267+
234268
it("rejects npm registry bodies that exceed the content-length cap", async () => {
235269
let canceled = false;
236270
const response = new Response(
@@ -247,7 +281,7 @@ describe("transitive-manifest-risk-report", () => {
247281
);
248282

249283
await expect(readBoundedNpmRegistryText(response, 8)).rejects.toThrow(
250-
"npm registry response exceeded 8 bytes (content-length 12)",
284+
"npm registry response exceeded 8 bytes",
251285
);
252286
expect(canceled).toBe(true);
253287
});
@@ -260,7 +294,7 @@ describe("transitive-manifest-risk-report", () => {
260294
});
261295

262296
await expect(readBoundedNpmRegistryText(response, 8)).rejects.toThrow(
263-
"npm registry response exceeded 8 bytes (content-length 12)",
297+
"npm registry response exceeded 8 bytes",
264298
);
265299
});
266300

@@ -306,7 +340,7 @@ describe("transitive-manifest-risk-report", () => {
306340
);
307341

308342
await expect(readBoundedNpmRegistryText(response, 8)).rejects.toThrow(
309-
"npm registry response exceeded 8 bytes while reading response body",
343+
"npm registry response exceeded 8 bytes",
310344
);
311345
});
312346
});

0 commit comments

Comments
 (0)