@juspay/neurolink 10.12.2 → 10.12.4

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.
@@ -11,7 +11,7 @@
11
11
  */
12
12
  import type { CommandModule } from "yargs";
13
13
  import type { Hono } from "hono";
14
- import type { AccountAllowlist, LoadedProxyConfig, ModelRouterInterface, ProxyGuardArgs, ProxyNeurolinkRuntime, ProxyStartArgs, ProxyStartStrategy, ProxySupervisorState, ProxyStatusArgs, ProxyTelemetryArgs, ProxyReadinessState } from "../../lib/types/index.js";
14
+ import type { AccountAllowlist, LoadedProxyConfig, ModelRouterInterface, ProxyGuardArgs, ProxyHealthProbe, ProxyNeurolinkRuntime, ProxyStartArgs, ProxyStartStrategy, ProxySupervisorState, ProxyStatusArgs, ProxyTelemetryArgs, ProxyReadinessState } from "../../lib/types/index.js";
15
15
  import { ProxyRuntimeConfigStore } from "../../lib/proxy/runtimeConfig.js";
16
16
  /**
17
17
  * Drop a supervisor `version` that is not a string.
@@ -58,6 +58,7 @@ export declare function isRollingHandoffCapable(state: ProxySupervisorState | nu
58
58
  * confirm a mismatch" and falls through to the args-only result.
59
59
  */
60
60
  export declare function processLooksLikeProxySupervisor(pid: number, expectedStartTimeIso?: string): Promise<boolean>;
61
+ export declare function probeProxyHealth(host: string, port: number, timeoutMs: number): Promise<ProxyHealthProbe>;
61
62
  export declare function mapClaudeErrorTypeToStatus(errorType?: string): number;
62
63
  export declare function createProxyStartApp(params: {
63
64
  neurolink: ProxyNeurolinkRuntime["neurolink"];
@@ -41,6 +41,7 @@ import packageJson from "../../../package.json" with { type: "json" };
41
41
  const _require = createRequire(import.meta.url);
42
42
  const PROXY_VERSION = packageJson.version;
43
43
  const PROXY_INTERNAL_ACCOUNT_LABEL = "proxy/internal";
44
+ const PROXY_INTERNAL_ACCOUNT_TYPE = "internal";
44
45
  const PROXY_TELEMETRY_SCRIPT_PATH = fileURLToPath(new URL("../../../scripts/observability/manage-local-openobserve.sh", import.meta.url));
45
46
  const PROXY_LIFECYCLE_SHUTDOWN_TIMEOUT_MS = 5_000;
46
47
  const LEGACY_STATUS_ACCOUNT_CACHE_TTL_MS = 5_000;
@@ -623,17 +624,51 @@ async function clearOpenCodeProxySettings(expectedBaseUrl) {
623
624
  fs.writeFileSync(OPENCODE_CONFIG_PATH, JSON.stringify(config, null, 2));
624
625
  return hadNeurolink;
625
626
  }
626
- async function isProxyHealthy(host, port, timeoutMs) {
627
+ export async function probeProxyHealth(host, port, timeoutMs) {
628
+ const startedAt = Date.now();
627
629
  try {
628
630
  const response = await fetch(`http://${host}:${port}/health`, {
629
631
  signal: AbortSignal.timeout(timeoutMs),
630
632
  });
631
- return response.ok;
633
+ return {
634
+ healthy: response.ok,
635
+ durationMs: Date.now() - startedAt,
636
+ failure: response.ok ? null : "http_status",
637
+ statusCode: response.status,
638
+ errorCode: null,
639
+ };
632
640
  }
633
- catch {
634
- return false;
641
+ catch (error) {
642
+ const candidate = error;
643
+ const cause = candidate?.cause;
644
+ const errorCode = typeof candidate?.code === "string"
645
+ ? candidate.code
646
+ : typeof cause?.code === "string"
647
+ ? cause.code
648
+ : typeof candidate?.name === "string"
649
+ ? candidate.name
650
+ : null;
651
+ const isTimeout = candidate?.name === "TimeoutError" || candidate?.name === "AbortError";
652
+ return {
653
+ healthy: false,
654
+ durationMs: Date.now() - startedAt,
655
+ failure: isTimeout ? "timeout" : "network",
656
+ statusCode: null,
657
+ errorCode,
658
+ };
635
659
  }
636
660
  }
661
+ function formatProxyHealthProbe(probe) {
662
+ const details = [
663
+ `reason=${probe.failure ?? "none"}`,
664
+ `durationMs=${probe.durationMs}`,
665
+ probe.statusCode === null ? null : `status=${probe.statusCode}`,
666
+ probe.errorCode === null
667
+ ? null
668
+ : `errorCode=${sanitizeForLog(probe.errorCode)}`,
669
+ ].filter((detail) => detail !== null);
670
+ return details.join(" ");
671
+ }
637
672
  async function getProxyRuntimeActivity(host, port, timeoutMs = 3_000) {
638
673
  try {
639
674
  const response = await fetch(`http://${host}:${port}/status`, {
@@ -1312,7 +1347,7 @@ export async function createProxyStartApp(params) {
1312
1347
  const recordRuntimeError = async (metadata, status, errorType, errorMessage, options) => {
1313
1348
  const clientMessage = options?.clientMessage ?? errorMessage;
1314
1349
  const clientErrorType = options?.clientErrorType ?? errorType;
1315
- recordFinalError(status, undefined, undefined, {
1350
+ recordFinalError(status, PROXY_INTERNAL_ACCOUNT_LABEL, PROXY_INTERNAL_ACCOUNT_TYPE, {
1316
1351
  requestId: metadata.requestId,
1317
1352
  errorType,
1318
1353
  errorCode: options?.errorCode,
@@ -3730,19 +3765,23 @@ export const proxyGuardCommand = {
3730
3765
  const startedAt = Date.now();
3731
3766
  let parentStatus = getProcessStatus(parentPid);
3732
3767
  let consecutiveUnhealthy = 0;
3768
+ let lastUnhealthyProbe = null;
3733
3769
  // Keep monitoring for as long as the parent can affect Claude settings.
3734
3770
  while (true) {
3735
- const healthy = await isProxyHealthy(host, port, 1_500);
3771
+ const healthProbe = await probeProxyHealth(host, port, 1_500);
3772
+ const healthy = healthProbe.healthy;
3736
3773
  if (healthy) {
3737
3774
  if (updaterOnly && consecutiveUnhealthy >= failureThreshold) {
3738
- logger.always(`[updater] proxy health recovered after ${consecutiveUnhealthy} failed checks`);
3775
+ logger.always(`[updater] proxy health recovered after ${consecutiveUnhealthy} failed checks; ${formatProxyHealthProbe(lastUnhealthyProbe ?? healthProbe)}`);
3739
3776
  }
3740
3777
  consecutiveUnhealthy = 0;
3778
+ lastUnhealthyProbe = null;
3741
3779
  }
3742
3780
  else {
3743
3781
  consecutiveUnhealthy += 1;
3782
+ lastUnhealthyProbe = healthProbe;
3744
3783
  if (updaterOnly && consecutiveUnhealthy === failureThreshold) {
3745
- logger.always(`[updater] proxy health unavailable after ${consecutiveUnhealthy} checks; worker remains active`);
3784
+ logger.always(`[updater] proxy health unavailable after ${consecutiveUnhealthy} checks; worker remains active; ${formatProxyHealthProbe(healthProbe)}`);
3746
3785
  }
3747
3786
  }
3748
3787
  if (parentStatus === "not_running" && !updateRestartInProgress) {
@@ -40,9 +40,6 @@ import { BaseFileProcessor } from "../base/BaseFileProcessor.js";
40
40
  import { SIZE_LIMITS_MB } from "../config/index.js";
41
41
  import { FileErrorCode } from "../errors/index.js";
42
42
  // =============================================================================
43
- // TYPES
44
- // =============================================================================
45
- // =============================================================================
46
43
  // SECURITY CONFIGURATION
47
44
  // =============================================================================
48
45
  /**
@@ -162,6 +159,60 @@ const SINGLE_STREAM_TOOLS = {
162
159
  xz: "xz",
163
160
  zst: "zstd",
164
161
  };
162
+ /**
163
+ * Read one ZIP entry's bytes without trusting the size it declares.
164
+ *
165
+ * `entry.getData()` cannot be used for this. It sizes its output buffer from
166
+ * the central-directory `size` field, which the archive author chooses, and
167
+ * adm-zip only arms its own guard when that field is positive:
168
+ *
169
+ * const option = version >= 15 && expectedLength > 0
170
+ * ? { maxOutputLength: expectedLength } : {};
171
+ *
172
+ * So an entry declaring 0 disables the bound and the caller's `size > maxSize`
173
+ * check in one move — `0 > 5MB` is false, and the inflate then runs uncapped.
174
+ * The declared size is the attack, so nothing here may depend on it: the cap
175
+ * comes from our own limit and is handed to the decoder.
176
+ *
177
+ * CRC is verified on both paths rather than dropped, so bypassing `getData()`
178
+ * does not also quietly lose its corruption check — a STORED entry is copied
179
+ * out rather than decoded, but it can be damaged just the same. It detects
180
+ * damage, not malice — the CRC field is attacker-controlled too.
181
+ */
182
+ function readZipEntryWithinLimit(entry, maxBytes, zlibModule) {
183
+ const compressed = entry.getCompressedData();
184
+ const matchesCrc = (data) => (zlibModule.crc32(data) >>> 0) === (entry.header.crc >>> 0);
185
+ // STORED: the bytes are already the payload, so its own length is the bound.
186
+ if (entry.header.method === ZIP_METHOD_STORED) {
187
+ if (compressed.length > maxBytes) {
188
+ return { status: "too-large" };
189
+ }
190
+ return matchesCrc(compressed)
191
+ ? { status: "ok", buffer: compressed }
192
+ : { status: "corrupt" };
193
+ }
194
+ if (entry.header.method !== ZIP_METHOD_DEFLATED) {
195
+ return { status: "unsupported-method" };
196
+ }
197
+ let inflated;
198
+ try {
199
+ inflated = zlibModule.inflateRawSync(compressed, {
200
+ maxOutputLength: maxBytes,
201
+ });
202
+ }
203
+ catch (error) {
204
+ if (isDecompressionBoundExceeded(error)) {
205
+ return { status: "too-large" };
206
+ }
207
+ return { status: "corrupt" };
208
+ }
209
+ return matchesCrc(inflated)
210
+ ? { status: "ok", buffer: inflated }
211
+ : { status: "corrupt" };
212
+ }
213
+ /** ZIP compression methods this reader handles (APPNOTE 4.4.5). */
214
+ const ZIP_METHOD_STORED = 0;
215
+ const ZIP_METHOD_DEFLATED = 8;
165
216
  /**
166
217
  * Whether a zlib rejection is the output bound firing rather than bad input.
167
218
  *
@@ -1552,12 +1603,29 @@ export class ArchiveProcessor extends BaseFileProcessor {
1552
1603
  if (targetEntry.isDirectory) {
1553
1604
  return `"${entryPath}" is a directory, not a file.`;
1554
1605
  }
1555
- // Security: size check
1606
+ // Security: size check.
1607
+ //
1608
+ // The declared size is a hint, not the bound — it is chosen by whoever
1609
+ // built the archive, and an entry claiming 0 passes this comparison while
1610
+ // switching off adm-zip's own cap (see readZipEntryWithinLimit). It is
1611
+ // still worth checking, because an honestly-declared oversized entry is
1612
+ // refused here without decompressing anything at all.
1556
1613
  const maxSize = 5 * 1024 * 1024; // 5 MB
1557
1614
  if (targetEntry.header.size > maxSize) {
1558
1615
  return `Entry "${entryPath}" is too large (${this.formatHumanReadableSize(targetEntry.header.size)}). Maximum extraction size is 5 MB.`;
1559
1616
  }
1560
- const data = targetEntry.getData();
1617
+ const zlibModule = await import("zlib");
1618
+ const read = readZipEntryWithinLimit(targetEntry, maxSize, zlibModule);
1619
+ if (read.status === "too-large") {
1620
+ return `Entry "${entryPath}" is too large. Maximum extraction size is 5 MB.`;
1621
+ }
1622
+ if (read.status === "unsupported-method") {
1623
+ return `Entry "${entryPath}" uses an unsupported compression method.`;
1624
+ }
1625
+ if (read.status === "corrupt") {
1626
+ return `Entry "${entryPath}" could not be read — the archive entry is corrupt.`;
1627
+ }
1628
+ const data = read.buffer;
1561
1629
  // Check if it looks like text
1562
1630
  const sampleSize = Math.min(data.length, 512);
1563
1631
  let printable = 0;
@@ -181,6 +181,25 @@ export declare abstract class BaseFileProcessor<T extends ProcessedFileBase> {
181
181
  * @throws Error if download fails
182
182
  */
183
183
  protected downloadFile(url: string, authHeaders?: Record<string, string>, timeout?: number): Promise<Buffer>;
184
+ /**
185
+ * Read a response body, refusing it the moment it passes `maxBytes`.
186
+ *
187
+ * `response.arrayBuffer()` buffers whatever the server sends before anyone
188
+ * can object, so the size check further up only ever ran against a body that
189
+ * had already been paid for in full. Metering the stream makes the ceiling
190
+ * the configured limit instead of the server's choice, and cancelling the
191
+ * reader stops the transfer rather than merely stopping our interest in it.
192
+ *
193
+ * Content-Length is deliberately not trusted as the bound — it is a hint that
194
+ * can be absent or a lie — but it is worth honouring when present, because it
195
+ * refuses the honest oversized case without reading a byte.
196
+ *
197
+ * @param response - Fetch response whose body should be read
198
+ * @param maxBytes - Ceiling for the body, in bytes
199
+ * @returns The body as a Buffer
200
+ * @throws When the body exceeds `maxBytes`
201
+ */
202
+ private readBodyWithinLimit;
184
203
  /**
185
204
  * Download file with retry logic for transient failures.
186
205
  *
@@ -52,6 +52,30 @@ import { tracers } from "../../telemetry/tracers.js";
52
52
  import { createFileError, extractHttpStatus, FileErrorCode, isRetryableError, } from "../errors/index.js";
53
53
  import { DEFAULT_RETRY_CONFIG } from "../../types/index.js";
54
54
  const gunzipAsync = promisify(gunzip);
55
+ /**
56
+ * Marker on the error raised when a download is refused for exceeding the
57
+ * processor's size limit.
58
+ *
59
+ * A code rather than a message match, for two reasons. `classifyDownloadError`
60
+ * can then report FILE_TOO_LARGE — the same verdict the post-download check
61
+ * gave, so refusing earlier does not quietly downgrade the error a caller
62
+ * sees to DOWNLOAD_FAILED. And `isRetryableError` decides from text; a bound
63
+ * that fires is not a transient fault, and re-fetching a file that will be
64
+ * exactly as oversized next time is the one thing worth not doing three times.
65
+ */
66
+ const DOWNLOAD_TOO_LARGE_CODE = "DOWNLOAD_TOO_LARGE";
67
+ function downloadTooLargeError(maxSizeMB, typeName) {
68
+ const error = new Error(`Download exceeds the maximum size of ${maxSizeMB} MB for ${typeName}`);
69
+ error.code = DOWNLOAD_TOO_LARGE_CODE;
70
+ return error;
71
+ }
72
+ function isDownloadTooLarge(error) {
73
+ return (error?.code === DOWNLOAD_TOO_LARGE_CODE);
74
+ }
75
+ /** Node's signal that a zlib output bound was reached. */
76
+ function isBufferTooLargeError(error) {
77
+ return (error?.code === "ERR_BUFFER_TOO_LARGE");
78
+ }
55
79
  /**
56
80
  * Abstract base class for file processors.
57
81
  * Provides common download, validation, and error handling functionality.
@@ -389,8 +413,8 @@ export class BaseFileProcessor {
389
413
  if (contentType && contentType.includes("text/html")) {
390
414
  throw new Error(`Received HTML response instead of file content (Content-Type: ${contentType}). This usually means the download URL returned an error page.`);
391
415
  }
392
- const arrayBuffer = await response.arrayBuffer();
393
- let buffer = Buffer.from(arrayBuffer);
416
+ const maxBytes = this.config.maxSizeMB * 1024 * 1024;
417
+ let buffer = await this.readBodyWithinLimit(response, maxBytes);
394
418
  // Check for gzip encoding and decompress if needed
395
419
  // Only decompress if the data actually starts with gzip magic bytes (0x1f 0x8b)
396
420
  const contentEncoding = response.headers.get("Content-Encoding");
@@ -398,9 +422,16 @@ export class BaseFileProcessor {
398
422
  if (contentEncoding?.toLowerCase().includes("gzip") &&
399
423
  isActuallyGzipped) {
400
424
  try {
401
- buffer = Buffer.from(await gunzipAsync(buffer));
425
+ // Bounded at the decoder. The size check that runs after the download
426
+ // only ever sees what already fits in memory, so a few KB of gzip
427
+ // declaring gigabytes was inflated in full before anything objected —
428
+ // the response passed the "is it small" test on its way in.
429
+ buffer = Buffer.from(await gunzipAsync(buffer, { maxOutputLength: maxBytes }));
402
430
  }
403
431
  catch (gzipError) {
432
+ if (isBufferTooLargeError(gzipError)) {
433
+ throw downloadTooLargeError(this.config.maxSizeMB, this.config.fileTypeName);
434
+ }
404
435
  throw new Error(`Failed to decompress gzip response: ${gzipError instanceof Error ? gzipError.message : String(gzipError)}`, { cause: gzipError });
405
436
  }
406
437
  }
@@ -408,7 +439,68 @@ export class BaseFileProcessor {
408
439
  }
409
440
  finally {
410
441
  clearTimeout(timeoutId);
442
+ // Release the connection on every early exit. Rejecting a response for
443
+ // its status or its Content-Type leaves the body unread, which keeps the
444
+ // socket out of the pool until the response is garbage-collected. After
445
+ // a successful read there is nothing left in flight, so this is a no-op.
446
+ controller.abort();
447
+ }
448
+ }
449
+ /**
450
+ * Read a response body, refusing it the moment it passes `maxBytes`.
451
+ *
452
+ * `response.arrayBuffer()` buffers whatever the server sends before anyone
453
+ * can object, so the size check further up only ever ran against a body that
454
+ * had already been paid for in full. Metering the stream makes the ceiling
455
+ * the configured limit instead of the server's choice, and cancelling the
456
+ * reader stops the transfer rather than merely stopping our interest in it.
457
+ *
458
+ * Content-Length is deliberately not trusted as the bound — it is a hint that
459
+ * can be absent or a lie — but it is worth honouring when present, because it
460
+ * refuses the honest oversized case without reading a byte.
461
+ *
462
+ * @param response - Fetch response whose body should be read
463
+ * @param maxBytes - Ceiling for the body, in bytes
464
+ * @returns The body as a Buffer
465
+ * @throws When the body exceeds `maxBytes`
466
+ */
467
+ async readBodyWithinLimit(response, maxBytes) {
468
+ const declared = Number(response.headers.get("Content-Length"));
469
+ if (Number.isFinite(declared) && declared > maxBytes) {
470
+ // Release the socket. An unread body keeps the connection out of the
471
+ // pool until the response is collected, so a run of oversized downloads
472
+ // would hold one socket open each.
473
+ await response.body?.cancel();
474
+ throw downloadTooLargeError(this.config.maxSizeMB, this.config.fileTypeName);
475
+ }
476
+ // Some responses carry no readable stream (mocked fetch, polyfills). The
477
+ // buffered read is the fallback, still checked — just after the fact.
478
+ if (!response.body) {
479
+ const buffered = Buffer.from(await response.arrayBuffer());
480
+ if (buffered.length > maxBytes) {
481
+ throw downloadTooLargeError(this.config.maxSizeMB, this.config.fileTypeName);
482
+ }
483
+ return buffered;
484
+ }
485
+ const reader = response.body.getReader();
486
+ const chunks = [];
487
+ let total = 0;
488
+ for (;;) {
489
+ const { done, value } = await reader.read();
490
+ if (done) {
491
+ break;
492
+ }
493
+ if (!value) {
494
+ continue;
495
+ }
496
+ total += value.byteLength;
497
+ if (total > maxBytes) {
498
+ await reader.cancel();
499
+ throw downloadTooLargeError(this.config.maxSizeMB, this.config.fileTypeName);
500
+ }
501
+ chunks.push(Buffer.from(value));
411
502
  }
503
+ return Buffer.concat(chunks, total);
412
504
  }
413
505
  /**
414
506
  * Download file with retry logic for transient failures.
@@ -559,6 +651,15 @@ export class BaseFileProcessor {
559
651
  * @returns Structured file processing error
560
652
  */
561
653
  classifyDownloadError(error) {
654
+ // Refusing an oversized body mid-stream is the same verdict the old
655
+ // post-download check gave, so it must carry the same code — otherwise
656
+ // bounding the read earlier would silently reclassify a familiar error.
657
+ if (isDownloadTooLarge(error)) {
658
+ return this.createError(FileErrorCode.FILE_TOO_LARGE, {
659
+ maxMB: this.config.maxSizeMB,
660
+ type: this.config.fileTypeName,
661
+ }, error);
662
+ }
562
663
  if (isAbortError(error)) {
563
664
  return this.createError(FileErrorCode.DOWNLOAD_TIMEOUT, { timeoutMs: this.config.timeoutMs }, error);
564
665
  }
@@ -761,6 +761,29 @@ export type ArchiveDecompressionResult = {
761
761
  } | {
762
762
  readonly status: "failed";
763
763
  };
764
+ /**
765
+ * Outcome of reading one ZIP entry under an explicit output bound.
766
+ *
767
+ * Separate from {@link ArchiveDecompressionResult} because the failures differ:
768
+ * a single-stream archive can fail for want of an external tool, while a ZIP
769
+ * entry can instead use a compression method this reader does not implement.
770
+ * Collapsing them would force one caller to handle a state it can never see.
771
+ *
772
+ * `too-large` is a distinct outcome rather than a corrupt-file error because it
773
+ * is a verdict about our limit, not about the archive: the entry may be
774
+ * perfectly well-formed, and telling the user it is damaged would send them to
775
+ * re-create a file that was never broken.
776
+ */
777
+ export type ArchiveEntryReadResult = {
778
+ readonly status: "ok";
779
+ readonly buffer: Buffer;
780
+ } | {
781
+ readonly status: "too-large";
782
+ } | {
783
+ readonly status: "unsupported-method";
784
+ } | {
785
+ readonly status: "corrupt";
786
+ };
764
787
  /**
765
788
  * Metadata about an individual entry within an archive.
766
789
  */
@@ -1861,6 +1861,14 @@ export type UpdateCheckResult = {
1861
1861
  latestVersion: string;
1862
1862
  updateAvailable: boolean;
1863
1863
  };
1864
+ /** Result of one local proxy health probe by the updater or fail-open guard. */
1865
+ export type ProxyHealthProbe = {
1866
+ healthy: boolean;
1867
+ durationMs: number;
1868
+ failure: "http_status" | "network" | "timeout" | null;
1869
+ statusCode: number | null;
1870
+ errorCode: string | null;
1871
+ };
1864
1872
  /** Parsed major.minor.patch components of a semver string. */
1865
1873
  export type SemVer = {
1866
1874
  major: number;
@@ -40,9 +40,6 @@ import { BaseFileProcessor } from "../base/BaseFileProcessor.js";
40
40
  import { SIZE_LIMITS_MB } from "../config/index.js";
41
41
  import { FileErrorCode } from "../errors/index.js";
42
42
  // =============================================================================
43
- // TYPES
44
- // =============================================================================
45
- // =============================================================================
46
43
  // SECURITY CONFIGURATION
47
44
  // =============================================================================
48
45
  /**
@@ -162,6 +159,60 @@ const SINGLE_STREAM_TOOLS = {
162
159
  xz: "xz",
163
160
  zst: "zstd",
164
161
  };
162
+ /**
163
+ * Read one ZIP entry's bytes without trusting the size it declares.
164
+ *
165
+ * `entry.getData()` cannot be used for this. It sizes its output buffer from
166
+ * the central-directory `size` field, which the archive author chooses, and
167
+ * adm-zip only arms its own guard when that field is positive:
168
+ *
169
+ * const option = version >= 15 && expectedLength > 0
170
+ * ? { maxOutputLength: expectedLength } : {};
171
+ *
172
+ * So an entry declaring 0 disables the bound and the caller's `size > maxSize`
173
+ * check in one move — `0 > 5MB` is false, and the inflate then runs uncapped.
174
+ * The declared size is the attack, so nothing here may depend on it: the cap
175
+ * comes from our own limit and is handed to the decoder.
176
+ *
177
+ * CRC is verified on both paths rather than dropped, so bypassing `getData()`
178
+ * does not also quietly lose its corruption check — a STORED entry is copied
179
+ * out rather than decoded, but it can be damaged just the same. It detects
180
+ * damage, not malice — the CRC field is attacker-controlled too.
181
+ */
182
+ function readZipEntryWithinLimit(entry, maxBytes, zlibModule) {
183
+ const compressed = entry.getCompressedData();
184
+ const matchesCrc = (data) => (zlibModule.crc32(data) >>> 0) === (entry.header.crc >>> 0);
185
+ // STORED: the bytes are already the payload, so its own length is the bound.
186
+ if (entry.header.method === ZIP_METHOD_STORED) {
187
+ if (compressed.length > maxBytes) {
188
+ return { status: "too-large" };
189
+ }
190
+ return matchesCrc(compressed)
191
+ ? { status: "ok", buffer: compressed }
192
+ : { status: "corrupt" };
193
+ }
194
+ if (entry.header.method !== ZIP_METHOD_DEFLATED) {
195
+ return { status: "unsupported-method" };
196
+ }
197
+ let inflated;
198
+ try {
199
+ inflated = zlibModule.inflateRawSync(compressed, {
200
+ maxOutputLength: maxBytes,
201
+ });
202
+ }
203
+ catch (error) {
204
+ if (isDecompressionBoundExceeded(error)) {
205
+ return { status: "too-large" };
206
+ }
207
+ return { status: "corrupt" };
208
+ }
209
+ return matchesCrc(inflated)
210
+ ? { status: "ok", buffer: inflated }
211
+ : { status: "corrupt" };
212
+ }
213
+ /** ZIP compression methods this reader handles (APPNOTE 4.4.5). */
214
+ const ZIP_METHOD_STORED = 0;
215
+ const ZIP_METHOD_DEFLATED = 8;
165
216
  /**
166
217
  * Whether a zlib rejection is the output bound firing rather than bad input.
167
218
  *
@@ -1552,12 +1603,29 @@ export class ArchiveProcessor extends BaseFileProcessor {
1552
1603
  if (targetEntry.isDirectory) {
1553
1604
  return `"${entryPath}" is a directory, not a file.`;
1554
1605
  }
1555
- // Security: size check
1606
+ // Security: size check.
1607
+ //
1608
+ // The declared size is a hint, not the bound — it is chosen by whoever
1609
+ // built the archive, and an entry claiming 0 passes this comparison while
1610
+ // switching off adm-zip's own cap (see readZipEntryWithinLimit). It is
1611
+ // still worth checking, because an honestly-declared oversized entry is
1612
+ // refused here without decompressing anything at all.
1556
1613
  const maxSize = 5 * 1024 * 1024; // 5 MB
1557
1614
  if (targetEntry.header.size > maxSize) {
1558
1615
  return `Entry "${entryPath}" is too large (${this.formatHumanReadableSize(targetEntry.header.size)}). Maximum extraction size is 5 MB.`;
1559
1616
  }
1560
- const data = targetEntry.getData();
1617
+ const zlibModule = await import("zlib");
1618
+ const read = readZipEntryWithinLimit(targetEntry, maxSize, zlibModule);
1619
+ if (read.status === "too-large") {
1620
+ return `Entry "${entryPath}" is too large. Maximum extraction size is 5 MB.`;
1621
+ }
1622
+ if (read.status === "unsupported-method") {
1623
+ return `Entry "${entryPath}" uses an unsupported compression method.`;
1624
+ }
1625
+ if (read.status === "corrupt") {
1626
+ return `Entry "${entryPath}" could not be read — the archive entry is corrupt.`;
1627
+ }
1628
+ const data = read.buffer;
1561
1629
  // Check if it looks like text
1562
1630
  const sampleSize = Math.min(data.length, 512);
1563
1631
  let printable = 0;
@@ -181,6 +181,25 @@ export declare abstract class BaseFileProcessor<T extends ProcessedFileBase> {
181
181
  * @throws Error if download fails
182
182
  */
183
183
  protected downloadFile(url: string, authHeaders?: Record<string, string>, timeout?: number): Promise<Buffer>;
184
+ /**
185
+ * Read a response body, refusing it the moment it passes `maxBytes`.
186
+ *
187
+ * `response.arrayBuffer()` buffers whatever the server sends before anyone
188
+ * can object, so the size check further up only ever ran against a body that
189
+ * had already been paid for in full. Metering the stream makes the ceiling
190
+ * the configured limit instead of the server's choice, and cancelling the
191
+ * reader stops the transfer rather than merely stopping our interest in it.
192
+ *
193
+ * Content-Length is deliberately not trusted as the bound — it is a hint that
194
+ * can be absent or a lie — but it is worth honouring when present, because it
195
+ * refuses the honest oversized case without reading a byte.
196
+ *
197
+ * @param response - Fetch response whose body should be read
198
+ * @param maxBytes - Ceiling for the body, in bytes
199
+ * @returns The body as a Buffer
200
+ * @throws When the body exceeds `maxBytes`
201
+ */
202
+ private readBodyWithinLimit;
184
203
  /**
185
204
  * Download file with retry logic for transient failures.
186
205
  *