diff --git a/app/modules/search-params/search-params.test.ts b/app/modules/search-params/search-params.test.ts index 9bfa7150f..b5f9613de 100644 --- a/app/modules/search-params/search-params.test.ts +++ b/app/modules/search-params/search-params.test.ts @@ -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, diff --git a/app/modules/search-params/search-params.ts b/app/modules/search-params/search-params.ts index 5afbb7a73..2337dd49e 100644 --- a/app/modules/search-params/search-params.ts +++ b/app/modules/search-params/search-params.ts @@ -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; } diff --git a/app/utils/compression.test.ts b/app/utils/compression.test.ts index f72229b9e..a9ae49758 100644 --- a/app/utils/compression.test.ts +++ b/app/utils/compression.test.ts @@ -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); + }); }); diff --git a/app/utils/compression.ts b/app/utils/compression.ts index 213f2d8b0..210c9e199 100644 --- a/app/utils/compression.ts +++ b/app/utils/compression.ts @@ -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 = []; + 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) { + 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; +} diff --git a/docs/dev/search-params.md b/docs/dev/search-params.md index 219783054..5fec52ebe 100644 --- a/docs/dev/search-params.md +++ b/docs/dev/search-params.md @@ -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