@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.
- package/CHANGELOG.md +13 -0
- package/dist/browser/neurolink.min.js +363 -363
- package/dist/cli/commands/proxy.d.ts +2 -1
- package/dist/cli/commands/proxy.js +47 -8
- package/dist/lib/processors/archive/ArchiveProcessor.js +73 -5
- package/dist/lib/processors/base/BaseFileProcessor.d.ts +19 -0
- package/dist/lib/processors/base/BaseFileProcessor.js +104 -3
- package/dist/lib/types/processor.d.ts +23 -0
- package/dist/lib/types/proxy.d.ts +8 -0
- package/dist/processors/archive/ArchiveProcessor.js +73 -5
- package/dist/processors/base/BaseFileProcessor.d.ts +19 -0
- package/dist/processors/base/BaseFileProcessor.js +104 -3
- package/dist/types/processor.d.ts +23 -0
- package/dist/types/proxy.d.ts +8 -0
- package/package.json +4 -3
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
393
|
-
let buffer =
|
|
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
|
-
|
|
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
|
|
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
|
*
|