mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-08-24 03:55:49 -05:00
Fix potential decompression bomb
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user