Fix potential decompression bomb

This commit is contained in:
Kalle
2026-08-03 17:36:44 +03:00
parent 32be402dbb
commit 6922efaed1
5 changed files with 74 additions and 5 deletions

View File

@@ -254,6 +254,16 @@ describe("SearchParams compression", () => {
).toEqual({ text: "" });
});
it("resolves a compression bomb to the default", () => {
const bomb = SearchParams.compressTransportValue(
JSON.stringify({ text: "a".repeat(10 * 1024 * 1024) }),
);
expect(SearchParams.decodeParam(testDefinition.shape.blob, [bomb])).toEqual(
{ text: "" },
);
});
it("compresses on demand only when it shortens the value", () => {
const longFilters = {
minValue: 1,

View File

@@ -6,6 +6,7 @@ import { compressToBase64, decompressFromBase64 } from "~/utils/compression";
const COMPRESSED_PREFIX = "lz~";
const ESCAPED_PREFIX = "lz~~";
const DECODE_CACHE_MAX_SIZE = 300;
const MAX_DECOMPRESSED_VALUE_BYTES = 64 * 1024;
const DECODE_FAILED = Symbol("DECODE_FAILED");
@@ -564,6 +565,7 @@ function unwrapValue(raw: string): string | typeof DECODE_FAILED {
if (raw.startsWith(COMPRESSED_PREFIX)) {
const decompressed = decompressFromBase64(
raw.slice(COMPRESSED_PREFIX.length),
{ maxDecompressedBytes: MAX_DECOMPRESSED_VALUE_BYTES },
);
return decompressed === null ? DECODE_FAILED : decompressed;
}

View File

@@ -33,4 +33,23 @@ describe("compressToBase64 & decompressFromBase64", () => {
expect(decompressFromBase64(compressed.slice(0, 4))).toBeNull();
});
it("returns null for a value inflating past maxDecompressedBytes", () => {
const bomb = compressToBase64("a".repeat(10 * 1024 * 1024));
expect(bomb.length).toBeLessThan(64 * 1024);
expect(
decompressFromBase64(bomb, { maxDecompressedBytes: 64 * 1024 }),
).toBeNull();
});
it("allows a value exactly at maxDecompressedBytes", () => {
const value = "a".repeat(1024);
expect(
decompressFromBase64(compressToBase64(value), {
maxDecompressedBytes: 1024,
}),
).toBe(value);
});
});

View File

@@ -1,4 +1,4 @@
import { deflateRaw, inflateRaw } from "pako";
import { deflateRaw, Inflate } from "pako";
/**
* Compresses a string with raw deflate and encodes the result as base64.
@@ -23,15 +23,38 @@ export function compressToBase64(
/**
* Decompresses a base64 encoded (standard or URL-safe alphabet) raw deflate
* string. Returns `null` if the input is corrupt.
* string. Returns `null` if the input is corrupt or, when
* `maxDecompressedBytes` is given, if it inflates past that limit (a
* decompression bomb guard for attacker controlled input).
*/
export function decompressFromBase64(compressed: string) {
export function decompressFromBase64(
compressed: string,
options?: { maxDecompressedBytes?: number },
) {
const maxDecompressedBytes =
options?.maxDecompressedBytes ?? Number.POSITIVE_INFINITY;
try {
const base64 = compressed.replace(/-/g, "+").replace(/_/g, "/");
const value = inflateRaw(
const inflator = new Inflate({ raw: true });
const chunks: Array<Uint8Array> = [];
let decompressedBytes = 0;
inflator.onData = (chunk) => {
decompressedBytes += chunk.length;
if (decompressedBytes > maxDecompressedBytes) {
throw new Error("Decompressed value over the maximum size");
}
chunks.push(chunk);
};
const succeeded = inflator.push(
Uint8Array.from(atob(base64), (c) => c.charCodeAt(0)),
{ toText: true },
true,
);
if (!succeeded) return null;
const value = new TextDecoder().decode(concatChunks(chunks));
if (!value) return null;
@@ -40,3 +63,16 @@ export function decompressFromBase64(compressed: string) {
return null;
}
}
function concatChunks(chunks: Array<Uint8Array>) {
const totalBytes = chunks.reduce((total, chunk) => total + chunk.length, 0);
const result = new Uint8Array(totalBytes);
let offset = 0;
for (const chunk of chunks) {
result.set(chunk, offset);
offset += chunk.length;
}
return result;
}

View File

@@ -61,6 +61,8 @@ Note: schemas built with `z.preprocess` (like `weaponSplId`, `stageId` in `app/u
Any param can arrive compressed (an `lz~` prefix followed by a deflate + base64url payload) without declaring anything — decode transparently decompresses first. Encoding stays human-readable except for `compress: true` params and on-demand compact links via `definition.href(path, values, { compress: true })` (QR codes, share links). A value is only compressed when that actually shortens it, compared as percent-encoded since that is what ends up in the URL.
Since decoding happens before the value schema ever runs, a compressed arrival that inflates past 64 KiB is rejected mid-inflate and resolves to the default, so a hand-crafted URL cannot inflate to an arbitrarily large string on the server.
When the href is already built and the definition behind it is not known (e.g. the QR code of `ImageExportDialog`, which compacts whatever path it is given), `SearchParams.compactHref(href)` re-encodes every param of an existing href the same way.
## Loader API