@juspay/neurolink 10.12.0 → 10.12.2
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 +12 -0
- package/dist/browser/neurolink.min.js +339 -339
- package/dist/lib/processors/archive/ArchiveProcessor.js +45 -24
- package/dist/lib/proxy/accountQuota.d.ts +6 -5
- package/dist/lib/proxy/accountQuota.js +10 -4
- package/dist/lib/proxy/proxyAnalysis.js +4 -0
- package/dist/lib/server/routes/claudeProxyRoutes.d.ts +3 -2
- package/dist/lib/server/routes/claudeProxyRoutes.js +37 -29
- package/dist/lib/types/proxy.d.ts +4 -0
- package/dist/processors/archive/ArchiveProcessor.js +45 -24
- package/dist/proxy/accountQuota.d.ts +6 -5
- package/dist/proxy/accountQuota.js +10 -4
- package/dist/proxy/proxyAnalysis.js +4 -0
- package/dist/server/routes/claudeProxyRoutes.d.ts +3 -2
- package/dist/server/routes/claudeProxyRoutes.js +37 -29
- package/dist/types/proxy.d.ts +4 -0
- package/package.json +1 -1
|
@@ -162,6 +162,17 @@ const SINGLE_STREAM_TOOLS = {
|
|
|
162
162
|
xz: "xz",
|
|
163
163
|
zst: "zstd",
|
|
164
164
|
};
|
|
165
|
+
/**
|
|
166
|
+
* Whether a zlib rejection is the output bound firing rather than bad input.
|
|
167
|
+
*
|
|
168
|
+
* `maxOutputLength` aborts an inflate the moment its output would pass the cap,
|
|
169
|
+
* which is the whole point — but it surfaces as a plain `RangeError`, and a
|
|
170
|
+
* bomb reported as "failed to decompress" reads as a corrupt upload and invites
|
|
171
|
+
* the user to send it again. It will fail identically every time.
|
|
172
|
+
*
|
|
173
|
+
* Keyed on `code`, not the message: the message embeds a byte count.
|
|
174
|
+
*/
|
|
175
|
+
const isDecompressionBoundExceeded = (error) => error?.code === "ERR_BUFFER_TOO_LARGE";
|
|
165
176
|
/** File extensions recognized as archive formats */
|
|
166
177
|
const SUPPORTED_ARCHIVE_EXTENSIONS = [".zip", ".tar", ".gz", ".tgz", ".bz2", ".tbz2", ".jar", ".xz", ".txz", ".zst", ".tzst"];
|
|
167
178
|
// =============================================================================
|
|
@@ -761,19 +772,16 @@ export class ArchiveProcessor extends BaseFileProcessor {
|
|
|
761
772
|
const zlib = await import("zlib");
|
|
762
773
|
const { promisify } = await import("util");
|
|
763
774
|
const gunzip = promisify(zlib.gunzip);
|
|
764
|
-
|
|
775
|
+
// Bounded at the decoder, matching the zstd path. Checking the length
|
|
776
|
+
// afterwards only reports a bomb once it has already been paid for: 40KB
|
|
777
|
+
// of gzip inflates to 40MB, and the allocation is the damage, not the
|
|
778
|
+
// number. `maxOutputLength` abandons the inflate at the cap instead, so
|
|
779
|
+
// the ceiling on memory is the limit rather than whatever the attacker
|
|
780
|
+
// chose. The overflow is classified in the catch below.
|
|
781
|
+
const decompressed = await gunzip(buffer, {
|
|
782
|
+
maxOutputLength: ARCHIVE_SECURITY.MAX_DECOMPRESSED_SIZE,
|
|
783
|
+
});
|
|
765
784
|
const tarBuffer = Buffer.from(decompressed);
|
|
766
|
-
// Security: check decompressed size
|
|
767
|
-
if (tarBuffer.length > ARCHIVE_SECURITY.MAX_DECOMPRESSED_SIZE) {
|
|
768
|
-
return {
|
|
769
|
-
success: false,
|
|
770
|
-
entries: [],
|
|
771
|
-
securityWarnings: [],
|
|
772
|
-
error: this.createError(FileErrorCode.SECURITY_VALIDATION_FAILED, {
|
|
773
|
-
reason: `Decompressed TAR size (${this.formatSizeMB(tarBuffer.length)} MB) exceeds limit (${this.formatSizeMB(ARCHIVE_SECURITY.MAX_DECOMPRESSED_SIZE)} MB)`,
|
|
774
|
-
}),
|
|
775
|
-
};
|
|
776
|
-
}
|
|
777
785
|
// Security: check compression ratio
|
|
778
786
|
if (buffer.length > 0) {
|
|
779
787
|
const ratio = tarBuffer.length / buffer.length;
|
|
@@ -793,6 +801,16 @@ export class ArchiveProcessor extends BaseFileProcessor {
|
|
|
793
801
|
return await this.parseTarStream(tarStream, tarBuffer);
|
|
794
802
|
}
|
|
795
803
|
catch (error) {
|
|
804
|
+
if (isDecompressionBoundExceeded(error)) {
|
|
805
|
+
return {
|
|
806
|
+
success: false,
|
|
807
|
+
entries: [],
|
|
808
|
+
securityWarnings: [],
|
|
809
|
+
error: this.createError(FileErrorCode.SECURITY_VALIDATION_FAILED, {
|
|
810
|
+
reason: `Decompressed TAR size exceeds limit (${this.formatSizeMB(ARCHIVE_SECURITY.MAX_DECOMPRESSED_SIZE)} MB)`,
|
|
811
|
+
}),
|
|
812
|
+
};
|
|
813
|
+
}
|
|
796
814
|
// Check if the error is one we already created (security validation)
|
|
797
815
|
if (error &&
|
|
798
816
|
typeof error === "object" &&
|
|
@@ -976,18 +994,11 @@ export class ArchiveProcessor extends BaseFileProcessor {
|
|
|
976
994
|
const zlib = await import("zlib");
|
|
977
995
|
const { promisify } = await import("util");
|
|
978
996
|
const gunzip = promisify(zlib.gunzip);
|
|
979
|
-
|
|
980
|
-
//
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
entries: [],
|
|
985
|
-
securityWarnings: [],
|
|
986
|
-
error: this.createError(FileErrorCode.SECURITY_VALIDATION_FAILED, {
|
|
987
|
-
reason: `Decompressed size (${this.formatSizeMB(decompressed.length)} MB) exceeds limit (${this.formatSizeMB(ARCHIVE_SECURITY.MAX_DECOMPRESSED_SIZE)} MB)`,
|
|
988
|
-
}),
|
|
989
|
-
};
|
|
990
|
-
}
|
|
997
|
+
// Bounded at the decoder — see the matching call in extractTarGzEntries.
|
|
998
|
+
// The overflow is classified in the catch below.
|
|
999
|
+
const decompressed = await gunzip(buffer, {
|
|
1000
|
+
maxOutputLength: ARCHIVE_SECURITY.MAX_DECOMPRESSED_SIZE,
|
|
1001
|
+
});
|
|
991
1002
|
// Security: compression ratio
|
|
992
1003
|
if (buffer.length > 0) {
|
|
993
1004
|
const ratio = decompressed.length / buffer.length;
|
|
@@ -1031,6 +1042,16 @@ export class ArchiveProcessor extends BaseFileProcessor {
|
|
|
1031
1042
|
return { success: true, entries, securityWarnings, contents };
|
|
1032
1043
|
}
|
|
1033
1044
|
catch (error) {
|
|
1045
|
+
if (isDecompressionBoundExceeded(error)) {
|
|
1046
|
+
return {
|
|
1047
|
+
success: false,
|
|
1048
|
+
entries: [],
|
|
1049
|
+
securityWarnings: [],
|
|
1050
|
+
error: this.createError(FileErrorCode.SECURITY_VALIDATION_FAILED, {
|
|
1051
|
+
reason: `Decompressed size exceeds limit (${this.formatSizeMB(ARCHIVE_SECURITY.MAX_DECOMPRESSED_SIZE)} MB)`,
|
|
1052
|
+
}),
|
|
1053
|
+
};
|
|
1054
|
+
}
|
|
1034
1055
|
return {
|
|
1035
1056
|
success: false,
|
|
1036
1057
|
entries: [],
|
|
@@ -14,12 +14,13 @@ import type { AccountQuota } from "../types/index.js";
|
|
|
14
14
|
export declare function getUnifiedRateLimitStatus(headers: Headers | Record<string, string>): string | undefined;
|
|
15
15
|
/**
|
|
16
16
|
* Whether Anthropic explicitly permits a request to use overage after a
|
|
17
|
-
* subscription window is exhausted.
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
* an allowed overage status, which is the
|
|
17
|
+
* subscription window is exhausted. An active overage signal is authoritative;
|
|
18
|
+
* otherwise fresh responses require explicit fallback and upgrade-path signals.
|
|
19
|
+
* Older persisted snapshots predate those raw fields, but retain a positive
|
|
20
|
+
* fallback percentage together with an allowed overage status, which is the
|
|
21
|
+
* equivalent provider state.
|
|
21
22
|
*/
|
|
22
|
-
export declare function isQuotaOverageAvailable(quota: Pick<AccountQuota, "fallbackPercentage" | "fallbackStatus" | "overageStatus" | "upgradePaths"> | null | undefined): boolean;
|
|
23
|
+
export declare function isQuotaOverageAvailable(quota: Pick<AccountQuota, "fallbackPercentage" | "fallbackStatus" | "overageStatus" | "overageInUse" | "upgradePaths"> | null | undefined): boolean;
|
|
23
24
|
/**
|
|
24
25
|
* Parse Anthropic rate-limit / quota headers into an `AccountQuota`.
|
|
25
26
|
* Returns `null` when key headers are absent.
|
|
@@ -41,15 +41,19 @@ export function getUnifiedRateLimitStatus(headers) {
|
|
|
41
41
|
}
|
|
42
42
|
/**
|
|
43
43
|
* Whether Anthropic explicitly permits a request to use overage after a
|
|
44
|
-
* subscription window is exhausted.
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
* an allowed overage status, which is the
|
|
44
|
+
* subscription window is exhausted. An active overage signal is authoritative;
|
|
45
|
+
* otherwise fresh responses require explicit fallback and upgrade-path signals.
|
|
46
|
+
* Older persisted snapshots predate those raw fields, but retain a positive
|
|
47
|
+
* fallback percentage together with an allowed overage status, which is the
|
|
48
|
+
* equivalent provider state.
|
|
48
49
|
*/
|
|
49
50
|
export function isQuotaOverageAvailable(quota) {
|
|
50
51
|
if (quota?.overageStatus?.trim().toLowerCase() !== "allowed") {
|
|
51
52
|
return false;
|
|
52
53
|
}
|
|
54
|
+
if (quota.overageInUse === true) {
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
53
57
|
const explicitFallback = quota.fallbackStatus?.trim().toLowerCase();
|
|
54
58
|
const hasExplicitOveragePath = (quota.upgradePaths ?? "")
|
|
55
59
|
.split(",")
|
|
@@ -93,6 +97,8 @@ export function parseQuotaHeaders(headers) {
|
|
|
93
97
|
fallbackStatus: getHeader(headers, `${P}unified-fallback`) ?? "unknown",
|
|
94
98
|
upgradePaths: getHeader(headers, `${P}unified-upgrade-paths`),
|
|
95
99
|
overageStatus: getHeader(headers, `${P}unified-overage-status`) ?? "unknown",
|
|
100
|
+
overageInUse: getHeader(headers, `${P}unified-overage-in-use`)?.trim().toLowerCase() ===
|
|
101
|
+
"true",
|
|
96
102
|
lastUpdated: Date.now(),
|
|
97
103
|
source: "headers",
|
|
98
104
|
};
|
|
@@ -101,6 +101,9 @@ function routingCandidateValue(value) {
|
|
|
101
101
|
optionalNullableStringFields.some((field) => field in candidate &&
|
|
102
102
|
candidate[field] !== undefined &&
|
|
103
103
|
!isNullableString(candidate[field])) ||
|
|
104
|
+
("quotaStale" in candidate &&
|
|
105
|
+
candidate.quotaStale !== undefined &&
|
|
106
|
+
typeof candidate.quotaStale !== "boolean") ||
|
|
104
107
|
("overageEligible" in candidate &&
|
|
105
108
|
candidate.overageEligible !== undefined &&
|
|
106
109
|
typeof candidate.overageEligible !== "boolean") ||
|
|
@@ -118,6 +121,7 @@ function routingCandidateValue(value) {
|
|
|
118
121
|
usable: candidate.usable,
|
|
119
122
|
saturated: candidate.saturated,
|
|
120
123
|
quotaObserved: candidate.quotaObserved,
|
|
124
|
+
quotaStale: candidate.quotaStale === true,
|
|
121
125
|
quotaLastUpdated: candidate.quotaLastUpdated,
|
|
122
126
|
quotaAgeMs: candidate.quotaAgeMs,
|
|
123
127
|
coolingActive: candidate.coolingActive,
|
|
@@ -70,8 +70,9 @@ declare function reconcileCooldownFromQuota(state: RuntimeAccountState, quota: A
|
|
|
70
70
|
* proxy restart: all accounts tie, selection falls back to token-store
|
|
71
71
|
* enumeration order, and the first account served becomes self-reinforcing
|
|
72
72
|
* (it alone has data) — starving the others regardless of their resets.
|
|
73
|
-
* Never overwrites fresher in-memory quota
|
|
74
|
-
*
|
|
73
|
+
* Never overwrites fresher in-memory quota. Persisted quota cannot create or
|
|
74
|
+
* clear a cooldown: only an existing cooldown or fresh upstream response
|
|
75
|
+
* headers can change admission state.
|
|
75
76
|
*/
|
|
76
77
|
declare function seedRuntimeQuotasFromDisk(accounts: ProxyPassthroughAccount[]): Promise<void>;
|
|
77
78
|
/**
|
|
@@ -110,6 +110,9 @@ function fetchAnthropicUpstream(url, init) {
|
|
|
110
110
|
});
|
|
111
111
|
}
|
|
112
112
|
const accountRuntimeState = new Map();
|
|
113
|
+
/** Persisted quota is advisory after a restart. Older snapshots cannot reject
|
|
114
|
+
* an account or create a new cooldown because the provider may have reset it. */
|
|
115
|
+
const QUOTA_SNAPSHOT_FRESHNESS_MS = 15 * 60 * 1000;
|
|
113
116
|
/** Shared across requests so a concurrent burst gets at most two retries for
|
|
114
117
|
* the account/window, rather than every request starting its own retry chain. */
|
|
115
118
|
const transientRateLimitRetryBudgets = new Map();
|
|
@@ -603,8 +606,9 @@ function reconcileCooldownFromQuota(state, quota, now) {
|
|
|
603
606
|
* proxy restart: all accounts tie, selection falls back to token-store
|
|
604
607
|
* enumeration order, and the first account served becomes self-reinforcing
|
|
605
608
|
* (it alone has data) — starving the others regardless of their resets.
|
|
606
|
-
* Never overwrites fresher in-memory quota
|
|
607
|
-
*
|
|
609
|
+
* Never overwrites fresher in-memory quota. Persisted quota cannot create or
|
|
610
|
+
* clear a cooldown: only an existing cooldown or fresh upstream response
|
|
611
|
+
* headers can change admission state.
|
|
608
612
|
*/
|
|
609
613
|
async function seedRuntimeQuotasFromDisk(accounts) {
|
|
610
614
|
try {
|
|
@@ -625,15 +629,6 @@ async function seedRuntimeQuotasFromDisk(accounts) {
|
|
|
625
629
|
state.coolingUntil = persistedCooldown.coolingUntil;
|
|
626
630
|
state.coolingReason = persistedCooldown.reason;
|
|
627
631
|
}
|
|
628
|
-
if (state.quota) {
|
|
629
|
-
const cooldownUpdate = reconcileCooldownFromQuota(state, state.quota, now);
|
|
630
|
-
if (cooldownUpdate?.kind === "cooled") {
|
|
631
|
-
await saveAccountCooldown(account.key, cooldownUpdate.coolingUntil, cooldownUpdate.coolingReason);
|
|
632
|
-
}
|
|
633
|
-
else if (cooldownUpdate?.kind === "cleared") {
|
|
634
|
-
await clearAccountCooldown(account.key, cooldownUpdate.coolingUntil);
|
|
635
|
-
}
|
|
636
|
-
}
|
|
637
632
|
}
|
|
638
633
|
}
|
|
639
634
|
catch {
|
|
@@ -802,47 +797,59 @@ function getSessionResetToleranceMs() {
|
|
|
802
797
|
function accountSortMetrics(accountKey, now, sessionSoftLimit, sessionResetToleranceMs) {
|
|
803
798
|
const st = accountRuntimeState.get(accountKey);
|
|
804
799
|
const q = st?.quota;
|
|
800
|
+
const quotaLastUpdated = q && Number.isFinite(q.lastUpdated) ? q.lastUpdated : null;
|
|
801
|
+
const quotaAgeMs = quotaLastUpdated === null ? null : Math.max(0, now - quotaLastUpdated);
|
|
802
|
+
const quotaStale = quotaAgeMs !== null && quotaAgeMs > QUOTA_SNAPSHOT_FRESHNESS_MS;
|
|
803
|
+
const routingQuota = quotaStale ? undefined : q;
|
|
805
804
|
const coolingActive = !!st?.coolingUntil && now < st.coolingUntil;
|
|
806
805
|
// resetEpochToMs returns undefined for absent OR passed resets, so a
|
|
807
806
|
// ticking window is exactly "reset !== undefined".
|
|
808
|
-
const weeklyReset = resetEpochToMs(
|
|
809
|
-
const sessionReset = resetEpochToMs(
|
|
807
|
+
const weeklyReset = resetEpochToMs(routingQuota?.weeklyResetAt, now);
|
|
808
|
+
const sessionReset = resetEpochToMs(routingQuota?.sessionResetAt, now);
|
|
810
809
|
const sessionTicking = sessionReset !== undefined;
|
|
811
810
|
const weeklyTicking = weeklyReset !== undefined;
|
|
812
|
-
const sessionUsed =
|
|
813
|
-
const weeklyUsed = q ? (weeklyTicking ? (q.weeklyUsed ?? null) : 0) : null;
|
|
814
|
-
const sessionStatus = q
|
|
811
|
+
const sessionUsed = routingQuota
|
|
815
812
|
? sessionTicking
|
|
816
|
-
? (
|
|
813
|
+
? (routingQuota.sessionUsed ?? 0)
|
|
814
|
+
: 0
|
|
815
|
+
: null;
|
|
816
|
+
const weeklyUsed = routingQuota
|
|
817
|
+
? weeklyTicking
|
|
818
|
+
? (routingQuota.weeklyUsed ?? null)
|
|
819
|
+
: 0
|
|
820
|
+
: null;
|
|
821
|
+
const sessionStatus = routingQuota
|
|
822
|
+
? sessionTicking
|
|
823
|
+
? (routingQuota.sessionStatus ?? "unknown")
|
|
817
824
|
: "allowed"
|
|
818
825
|
: null;
|
|
819
|
-
const weeklyStatus =
|
|
826
|
+
const weeklyStatus = routingQuota
|
|
820
827
|
? weeklyTicking
|
|
821
|
-
? (
|
|
828
|
+
? (routingQuota.weeklyStatus ?? "unknown")
|
|
822
829
|
: "allowed"
|
|
823
830
|
: null;
|
|
824
|
-
const overageEligible = isQuotaOverageAvailable(
|
|
831
|
+
const overageEligible = isQuotaOverageAvailable(routingQuota);
|
|
825
832
|
const saturated = sessionStatus === "throttled" ||
|
|
826
833
|
(sessionTicking && (sessionUsed ?? 0) >= sessionSoftLimit);
|
|
827
|
-
const quotaLastUpdated = q && Number.isFinite(q.lastUpdated) ? q.lastUpdated : null;
|
|
828
834
|
return {
|
|
829
835
|
usable: !coolingActive &&
|
|
830
836
|
weeklyStatus !== "rejected" &&
|
|
831
837
|
(sessionStatus !== "rejected" || overageEligible) &&
|
|
832
|
-
(
|
|
838
|
+
(routingQuota?.unifiedStatus?.trim().toLowerCase() !== "rejected" ||
|
|
833
839
|
overageEligible),
|
|
834
|
-
saturated,
|
|
835
|
-
hasQuota: !!
|
|
840
|
+
saturated: !quotaStale && saturated,
|
|
841
|
+
hasQuota: !!routingQuota,
|
|
842
|
+
quotaStale,
|
|
836
843
|
quotaLastUpdated,
|
|
837
|
-
quotaAgeMs
|
|
844
|
+
quotaAgeMs,
|
|
838
845
|
coolingActive,
|
|
839
846
|
coolingReason: st?.coolingReason ?? null,
|
|
840
847
|
coolingUntil: st?.coolingUntil ?? 0,
|
|
841
|
-
unifiedStatus:
|
|
842
|
-
fallbackStatus:
|
|
843
|
-
upgradePaths:
|
|
848
|
+
unifiedStatus: routingQuota?.unifiedStatus ?? null,
|
|
849
|
+
fallbackStatus: routingQuota?.fallbackStatus ?? null,
|
|
850
|
+
upgradePaths: routingQuota?.upgradePaths ?? null,
|
|
844
851
|
overageEligible,
|
|
845
|
-
overageStatus:
|
|
852
|
+
overageStatus: routingQuota?.overageStatus ?? null,
|
|
846
853
|
sessionStatus,
|
|
847
854
|
sessionUsed,
|
|
848
855
|
sessionResetBucket: sessionTicking
|
|
@@ -960,6 +967,7 @@ function buildRoutingDecision(args) {
|
|
|
960
967
|
usable: metrics.usable,
|
|
961
968
|
saturated: metrics.saturated,
|
|
962
969
|
quotaObserved: metrics.hasQuota,
|
|
970
|
+
quotaStale: metrics.quotaStale,
|
|
963
971
|
quotaLastUpdated: metrics.quotaLastUpdated,
|
|
964
972
|
quotaAgeMs: metrics.quotaAgeMs,
|
|
965
973
|
coolingActive: metrics.coolingActive,
|
|
@@ -439,6 +439,7 @@ export type ProxyAccountRoutingCandidate = {
|
|
|
439
439
|
usable: boolean;
|
|
440
440
|
saturated: boolean;
|
|
441
441
|
quotaObserved: boolean;
|
|
442
|
+
quotaStale: boolean;
|
|
442
443
|
quotaLastUpdated: number | null;
|
|
443
444
|
quotaAgeMs: number | null;
|
|
444
445
|
coolingActive: boolean;
|
|
@@ -477,6 +478,7 @@ export type ProxyAccountSortMetrics = {
|
|
|
477
478
|
usable: boolean;
|
|
478
479
|
saturated: boolean;
|
|
479
480
|
hasQuota: boolean;
|
|
481
|
+
quotaStale: boolean;
|
|
480
482
|
quotaLastUpdated: number | null;
|
|
481
483
|
quotaAgeMs: number | null;
|
|
482
484
|
coolingActive: boolean;
|
|
@@ -941,6 +943,8 @@ export type AccountQuota = {
|
|
|
941
943
|
upgradePaths?: string;
|
|
942
944
|
/** "allowed" | "rejected" */
|
|
943
945
|
overageStatus: string;
|
|
946
|
+
/** Whether Anthropic reports that paid overage is actively serving traffic. */
|
|
947
|
+
overageInUse?: boolean;
|
|
944
948
|
/** Epoch ms when we last captured this data */
|
|
945
949
|
lastUpdated: number;
|
|
946
950
|
/** Dynamic per-plan limit buckets from the usage API `limits[]` array
|
|
@@ -162,6 +162,17 @@ const SINGLE_STREAM_TOOLS = {
|
|
|
162
162
|
xz: "xz",
|
|
163
163
|
zst: "zstd",
|
|
164
164
|
};
|
|
165
|
+
/**
|
|
166
|
+
* Whether a zlib rejection is the output bound firing rather than bad input.
|
|
167
|
+
*
|
|
168
|
+
* `maxOutputLength` aborts an inflate the moment its output would pass the cap,
|
|
169
|
+
* which is the whole point — but it surfaces as a plain `RangeError`, and a
|
|
170
|
+
* bomb reported as "failed to decompress" reads as a corrupt upload and invites
|
|
171
|
+
* the user to send it again. It will fail identically every time.
|
|
172
|
+
*
|
|
173
|
+
* Keyed on `code`, not the message: the message embeds a byte count.
|
|
174
|
+
*/
|
|
175
|
+
const isDecompressionBoundExceeded = (error) => error?.code === "ERR_BUFFER_TOO_LARGE";
|
|
165
176
|
/** File extensions recognized as archive formats */
|
|
166
177
|
const SUPPORTED_ARCHIVE_EXTENSIONS = [".zip", ".tar", ".gz", ".tgz", ".bz2", ".tbz2", ".jar", ".xz", ".txz", ".zst", ".tzst"];
|
|
167
178
|
// =============================================================================
|
|
@@ -761,19 +772,16 @@ export class ArchiveProcessor extends BaseFileProcessor {
|
|
|
761
772
|
const zlib = await import("zlib");
|
|
762
773
|
const { promisify } = await import("util");
|
|
763
774
|
const gunzip = promisify(zlib.gunzip);
|
|
764
|
-
|
|
775
|
+
// Bounded at the decoder, matching the zstd path. Checking the length
|
|
776
|
+
// afterwards only reports a bomb once it has already been paid for: 40KB
|
|
777
|
+
// of gzip inflates to 40MB, and the allocation is the damage, not the
|
|
778
|
+
// number. `maxOutputLength` abandons the inflate at the cap instead, so
|
|
779
|
+
// the ceiling on memory is the limit rather than whatever the attacker
|
|
780
|
+
// chose. The overflow is classified in the catch below.
|
|
781
|
+
const decompressed = await gunzip(buffer, {
|
|
782
|
+
maxOutputLength: ARCHIVE_SECURITY.MAX_DECOMPRESSED_SIZE,
|
|
783
|
+
});
|
|
765
784
|
const tarBuffer = Buffer.from(decompressed);
|
|
766
|
-
// Security: check decompressed size
|
|
767
|
-
if (tarBuffer.length > ARCHIVE_SECURITY.MAX_DECOMPRESSED_SIZE) {
|
|
768
|
-
return {
|
|
769
|
-
success: false,
|
|
770
|
-
entries: [],
|
|
771
|
-
securityWarnings: [],
|
|
772
|
-
error: this.createError(FileErrorCode.SECURITY_VALIDATION_FAILED, {
|
|
773
|
-
reason: `Decompressed TAR size (${this.formatSizeMB(tarBuffer.length)} MB) exceeds limit (${this.formatSizeMB(ARCHIVE_SECURITY.MAX_DECOMPRESSED_SIZE)} MB)`,
|
|
774
|
-
}),
|
|
775
|
-
};
|
|
776
|
-
}
|
|
777
785
|
// Security: check compression ratio
|
|
778
786
|
if (buffer.length > 0) {
|
|
779
787
|
const ratio = tarBuffer.length / buffer.length;
|
|
@@ -793,6 +801,16 @@ export class ArchiveProcessor extends BaseFileProcessor {
|
|
|
793
801
|
return await this.parseTarStream(tarStream, tarBuffer);
|
|
794
802
|
}
|
|
795
803
|
catch (error) {
|
|
804
|
+
if (isDecompressionBoundExceeded(error)) {
|
|
805
|
+
return {
|
|
806
|
+
success: false,
|
|
807
|
+
entries: [],
|
|
808
|
+
securityWarnings: [],
|
|
809
|
+
error: this.createError(FileErrorCode.SECURITY_VALIDATION_FAILED, {
|
|
810
|
+
reason: `Decompressed TAR size exceeds limit (${this.formatSizeMB(ARCHIVE_SECURITY.MAX_DECOMPRESSED_SIZE)} MB)`,
|
|
811
|
+
}),
|
|
812
|
+
};
|
|
813
|
+
}
|
|
796
814
|
// Check if the error is one we already created (security validation)
|
|
797
815
|
if (error &&
|
|
798
816
|
typeof error === "object" &&
|
|
@@ -976,18 +994,11 @@ export class ArchiveProcessor extends BaseFileProcessor {
|
|
|
976
994
|
const zlib = await import("zlib");
|
|
977
995
|
const { promisify } = await import("util");
|
|
978
996
|
const gunzip = promisify(zlib.gunzip);
|
|
979
|
-
|
|
980
|
-
//
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
entries: [],
|
|
985
|
-
securityWarnings: [],
|
|
986
|
-
error: this.createError(FileErrorCode.SECURITY_VALIDATION_FAILED, {
|
|
987
|
-
reason: `Decompressed size (${this.formatSizeMB(decompressed.length)} MB) exceeds limit (${this.formatSizeMB(ARCHIVE_SECURITY.MAX_DECOMPRESSED_SIZE)} MB)`,
|
|
988
|
-
}),
|
|
989
|
-
};
|
|
990
|
-
}
|
|
997
|
+
// Bounded at the decoder — see the matching call in extractTarGzEntries.
|
|
998
|
+
// The overflow is classified in the catch below.
|
|
999
|
+
const decompressed = await gunzip(buffer, {
|
|
1000
|
+
maxOutputLength: ARCHIVE_SECURITY.MAX_DECOMPRESSED_SIZE,
|
|
1001
|
+
});
|
|
991
1002
|
// Security: compression ratio
|
|
992
1003
|
if (buffer.length > 0) {
|
|
993
1004
|
const ratio = decompressed.length / buffer.length;
|
|
@@ -1031,6 +1042,16 @@ export class ArchiveProcessor extends BaseFileProcessor {
|
|
|
1031
1042
|
return { success: true, entries, securityWarnings, contents };
|
|
1032
1043
|
}
|
|
1033
1044
|
catch (error) {
|
|
1045
|
+
if (isDecompressionBoundExceeded(error)) {
|
|
1046
|
+
return {
|
|
1047
|
+
success: false,
|
|
1048
|
+
entries: [],
|
|
1049
|
+
securityWarnings: [],
|
|
1050
|
+
error: this.createError(FileErrorCode.SECURITY_VALIDATION_FAILED, {
|
|
1051
|
+
reason: `Decompressed size exceeds limit (${this.formatSizeMB(ARCHIVE_SECURITY.MAX_DECOMPRESSED_SIZE)} MB)`,
|
|
1052
|
+
}),
|
|
1053
|
+
};
|
|
1054
|
+
}
|
|
1034
1055
|
return {
|
|
1035
1056
|
success: false,
|
|
1036
1057
|
entries: [],
|
|
@@ -14,12 +14,13 @@ import type { AccountQuota } from "../types/index.js";
|
|
|
14
14
|
export declare function getUnifiedRateLimitStatus(headers: Headers | Record<string, string>): string | undefined;
|
|
15
15
|
/**
|
|
16
16
|
* Whether Anthropic explicitly permits a request to use overage after a
|
|
17
|
-
* subscription window is exhausted.
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
* an allowed overage status, which is the
|
|
17
|
+
* subscription window is exhausted. An active overage signal is authoritative;
|
|
18
|
+
* otherwise fresh responses require explicit fallback and upgrade-path signals.
|
|
19
|
+
* Older persisted snapshots predate those raw fields, but retain a positive
|
|
20
|
+
* fallback percentage together with an allowed overage status, which is the
|
|
21
|
+
* equivalent provider state.
|
|
21
22
|
*/
|
|
22
|
-
export declare function isQuotaOverageAvailable(quota: Pick<AccountQuota, "fallbackPercentage" | "fallbackStatus" | "overageStatus" | "upgradePaths"> | null | undefined): boolean;
|
|
23
|
+
export declare function isQuotaOverageAvailable(quota: Pick<AccountQuota, "fallbackPercentage" | "fallbackStatus" | "overageStatus" | "overageInUse" | "upgradePaths"> | null | undefined): boolean;
|
|
23
24
|
/**
|
|
24
25
|
* Parse Anthropic rate-limit / quota headers into an `AccountQuota`.
|
|
25
26
|
* Returns `null` when key headers are absent.
|
|
@@ -41,15 +41,19 @@ export function getUnifiedRateLimitStatus(headers) {
|
|
|
41
41
|
}
|
|
42
42
|
/**
|
|
43
43
|
* Whether Anthropic explicitly permits a request to use overage after a
|
|
44
|
-
* subscription window is exhausted.
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
* an allowed overage status, which is the
|
|
44
|
+
* subscription window is exhausted. An active overage signal is authoritative;
|
|
45
|
+
* otherwise fresh responses require explicit fallback and upgrade-path signals.
|
|
46
|
+
* Older persisted snapshots predate those raw fields, but retain a positive
|
|
47
|
+
* fallback percentage together with an allowed overage status, which is the
|
|
48
|
+
* equivalent provider state.
|
|
48
49
|
*/
|
|
49
50
|
export function isQuotaOverageAvailable(quota) {
|
|
50
51
|
if (quota?.overageStatus?.trim().toLowerCase() !== "allowed") {
|
|
51
52
|
return false;
|
|
52
53
|
}
|
|
54
|
+
if (quota.overageInUse === true) {
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
53
57
|
const explicitFallback = quota.fallbackStatus?.trim().toLowerCase();
|
|
54
58
|
const hasExplicitOveragePath = (quota.upgradePaths ?? "")
|
|
55
59
|
.split(",")
|
|
@@ -93,6 +97,8 @@ export function parseQuotaHeaders(headers) {
|
|
|
93
97
|
fallbackStatus: getHeader(headers, `${P}unified-fallback`) ?? "unknown",
|
|
94
98
|
upgradePaths: getHeader(headers, `${P}unified-upgrade-paths`),
|
|
95
99
|
overageStatus: getHeader(headers, `${P}unified-overage-status`) ?? "unknown",
|
|
100
|
+
overageInUse: getHeader(headers, `${P}unified-overage-in-use`)?.trim().toLowerCase() ===
|
|
101
|
+
"true",
|
|
96
102
|
lastUpdated: Date.now(),
|
|
97
103
|
source: "headers",
|
|
98
104
|
};
|
|
@@ -101,6 +101,9 @@ function routingCandidateValue(value) {
|
|
|
101
101
|
optionalNullableStringFields.some((field) => field in candidate &&
|
|
102
102
|
candidate[field] !== undefined &&
|
|
103
103
|
!isNullableString(candidate[field])) ||
|
|
104
|
+
("quotaStale" in candidate &&
|
|
105
|
+
candidate.quotaStale !== undefined &&
|
|
106
|
+
typeof candidate.quotaStale !== "boolean") ||
|
|
104
107
|
("overageEligible" in candidate &&
|
|
105
108
|
candidate.overageEligible !== undefined &&
|
|
106
109
|
typeof candidate.overageEligible !== "boolean") ||
|
|
@@ -118,6 +121,7 @@ function routingCandidateValue(value) {
|
|
|
118
121
|
usable: candidate.usable,
|
|
119
122
|
saturated: candidate.saturated,
|
|
120
123
|
quotaObserved: candidate.quotaObserved,
|
|
124
|
+
quotaStale: candidate.quotaStale === true,
|
|
121
125
|
quotaLastUpdated: candidate.quotaLastUpdated,
|
|
122
126
|
quotaAgeMs: candidate.quotaAgeMs,
|
|
123
127
|
coolingActive: candidate.coolingActive,
|
|
@@ -70,8 +70,9 @@ declare function reconcileCooldownFromQuota(state: RuntimeAccountState, quota: A
|
|
|
70
70
|
* proxy restart: all accounts tie, selection falls back to token-store
|
|
71
71
|
* enumeration order, and the first account served becomes self-reinforcing
|
|
72
72
|
* (it alone has data) — starving the others regardless of their resets.
|
|
73
|
-
* Never overwrites fresher in-memory quota
|
|
74
|
-
*
|
|
73
|
+
* Never overwrites fresher in-memory quota. Persisted quota cannot create or
|
|
74
|
+
* clear a cooldown: only an existing cooldown or fresh upstream response
|
|
75
|
+
* headers can change admission state.
|
|
75
76
|
*/
|
|
76
77
|
declare function seedRuntimeQuotasFromDisk(accounts: ProxyPassthroughAccount[]): Promise<void>;
|
|
77
78
|
/**
|