Blame · Line-by-line history
bounded-gunzip.test.ts
Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.
| 303c5e2 | 1 | /** |
| 2 | * Regression guard: a git push body must not be able to OOM or stall the | |
| 3 | * server, and must not be touched at all before the pusher is authorized. | |
| 4 | * | |
| 5 | * The bug in POST /:owner/:repo.git/git-receive-pack was an ordering one. | |
| 6 | * The handler did `await c.req.arrayBuffer()` and then `Bun.gunzipSync(...)` | |
| 7 | * — buffering the whole body and expanding it in full — and only AFTER that | |
| 8 | * checked whether the caller had write access. So any unauthenticated caller | |
| 9 | * who knew a repository name (public ones are listed on /explore) could post | |
| 10 | * a gzip bomb and have the server expand it before deciding they had no | |
| 11 | * right to push anything. gzip passes 1000:1 on repetitive input, so a | |
| 12 | * 10 MB upload becomes ~10 GB of resident memory. | |
| 13 | * | |
| 14 | * `gunzipSync` compounded it by being synchronous: decompression blocked the | |
| 15 | * event loop for its whole duration, stalling every other in-flight request. | |
| 16 | * That is a latency DoS on its own, independent of the memory one. | |
| 17 | * | |
| 18 | * Two things are asserted: the decompressor is bounded, and the handler | |
| 19 | * still reads the body only after the access gate. | |
| 20 | */ | |
| 21 | ||
| 22 | import { describe, it, expect } from "bun:test"; | |
| 23 | import { readFileSync } from "node:fs"; | |
| 24 | import { join } from "node:path"; | |
| 25 | import { | |
| 26 | gunzipBounded, | |
| 27 | isGzip, | |
| 28 | DecompressedTooLargeError, | |
| 29 | MAX_GIT_BODY_BYTES, | |
| 30 | } from "../lib/bounded-gunzip"; | |
| 31 | ||
| 32 | describe("isGzip", () => { | |
| 33 | it("detects the gzip magic number", () => { | |
| 34 | expect(isGzip(Bun.gzipSync(new Uint8Array([1, 2, 3])))).toBe(true); | |
| 35 | }); | |
| 36 | ||
| 37 | it("rejects plain and truncated input", () => { | |
| 38 | expect(isGzip(new Uint8Array([]))).toBe(false); | |
| 39 | expect(isGzip(new Uint8Array([0x1f]))).toBe(false); | |
| 40 | expect(isGzip(new TextEncoder().encode("0000"))).toBe(false); | |
| 41 | }); | |
| 42 | }); | |
| 43 | ||
| 44 | describe("gunzipBounded", () => { | |
| 45 | it("round-trips ordinary content unchanged", async () => { | |
| 46 | const original = new TextEncoder().encode( | |
| 47 | "0032want d4a1f9e0c2b3a4d5e6f70819a2b3c4d5e6f70819\n0000" | |
| 48 | ); | |
| 49 | const out = await gunzipBounded(Bun.gzipSync(original)); | |
| 50 | expect(new TextDecoder().decode(out)).toBe( | |
| 51 | new TextDecoder().decode(original) | |
| 52 | ); | |
| 53 | }); | |
| 54 | ||
| 55 | it("round-trips content spanning many internal chunks", async () => { | |
| 56 | // Incompressible-ish payload large enough that the stream yields more | |
| 57 | // than one chunk, exercising the concat path. | |
| 58 | const big = new Uint8Array(3 * 1024 * 1024); | |
| 59 | for (let i = 0; i < big.length; i++) big[i] = (i * 2654435761) & 0xff; | |
| 60 | const out = await gunzipBounded(Bun.gzipSync(big)); | |
| 61 | expect(out.byteLength).toBe(big.byteLength); | |
| 62 | expect(out[0]).toBe(big[0]); | |
| 63 | expect(out[big.length - 1]).toBe(big[big.length - 1]); | |
| 64 | }); | |
| 65 | ||
| 66 | it("refuses a gzip bomb instead of expanding it (the attack)", async () => { | |
| 67 | // 64 MiB of zeros compresses to a few tens of KB — a ~1000:1 ratio, the | |
| 68 | // exact shape of the attack. Compressed size is unremarkable; expanded | |
| 69 | // size is what would have killed the process. | |
| 70 | const bomb = Bun.gzipSync(new Uint8Array(64 * 1024 * 1024)); | |
| 71 | expect(bomb.byteLength).toBeLessThan(1024 * 1024); | |
| 72 | ||
| 73 | await expect(gunzipBounded(bomb, 1024 * 1024)).rejects.toBeInstanceOf( | |
| 74 | DecompressedTooLargeError | |
| 75 | ); | |
| 76 | }); | |
| 77 | ||
| 78 | it("reports the limit it enforced", async () => { | |
| 79 | const bomb = Bun.gzipSync(new Uint8Array(8 * 1024 * 1024)); | |
| 80 | try { | |
| 81 | await gunzipBounded(bomb, 4096); | |
| 82 | throw new Error("should have thrown"); | |
| 83 | } catch (err) { | |
| 84 | expect(err).toBeInstanceOf(DecompressedTooLargeError); | |
| 85 | expect((err as DecompressedTooLargeError).limit).toBe(4096); | |
| 86 | } | |
| 87 | }); | |
| 88 | ||
| 89 | it("accepts output that lands exactly on the limit", async () => { | |
| 90 | const payload = new Uint8Array(1024); | |
| 91 | const out = await gunzipBounded(Bun.gzipSync(payload), 1024); | |
| 92 | expect(out.byteLength).toBe(1024); | |
| 93 | }); | |
| 94 | ||
| 95 | it("rejects one byte over the limit", async () => { | |
| 96 | const payload = new Uint8Array(1025); | |
| 97 | await expect( | |
| 98 | gunzipBounded(Bun.gzipSync(payload), 1024) | |
| 99 | ).rejects.toBeInstanceOf(DecompressedTooLargeError); | |
| 100 | }); | |
| 101 | ||
| 102 | it("defaults to a bound that is generous but finite", () => { | |
| 103 | expect(MAX_GIT_BODY_BYTES).toBeGreaterThan(64 * 1024 * 1024); | |
| 104 | expect(Number.isFinite(MAX_GIT_BODY_BYTES)).toBe(true); | |
| 105 | }); | |
| 106 | ||
| 107 | it("surfaces corrupt gzip rather than hanging", async () => { | |
| 108 | const corrupt = Bun.gzipSync(new TextEncoder().encode("hello")); | |
| 109 | corrupt[corrupt.length - 4] ^= 0xff; // wreck the trailing CRC | |
| 110 | await expect(gunzipBounded(corrupt)).rejects.toBeDefined(); | |
| 111 | }); | |
| 112 | }); | |
| 113 | ||
| 114 | // --- ordering: authorize before touching the body -------------------------- | |
| 115 | // | |
| 116 | // Strip ONLY line comments. A block-comment regex eats route paths, which | |
| 117 | // contain a slash followed by a star — and this file is full of them. | |
| 118 | ||
| 119 | describe("git-receive-pack authorizes before reading the body", () => { | |
| 120 | const src = (() => { | |
| 121 | const text = readFileSync( | |
| 122 | join(import.meta.dir, "..", "routes", "git.ts"), | |
| 123 | "utf8" | |
| 124 | ); | |
| 125 | return text | |
| 126 | .split("\n") | |
| 127 | .filter((l) => !l.trim().startsWith("//")) | |
| 128 | .join("\n"); | |
| 129 | })(); | |
| 130 | ||
| 131 | const handler = (() => { | |
| 132 | const start = src.indexOf(`git.post("/:owner/:repo.git/git-receive-pack"`); | |
| 133 | expect(start).toBeGreaterThan(-1); | |
| 134 | const body = src.slice(start); | |
| 135 | expect(body.length).toBeGreaterThan(0); | |
| 136 | return body; | |
| 137 | })(); | |
| 138 | ||
| 139 | it("checks write access before arrayBuffer()", () => { | |
| 140 | const gateAt = handler.indexOf(`satisfiesAccess(access, "write")`); | |
| 141 | const readAt = handler.indexOf("c.req.arrayBuffer()"); | |
| 142 | expect(gateAt).toBeGreaterThan(-1); | |
| 143 | expect(readAt).toBeGreaterThan(-1); | |
| 144 | expect(gateAt).toBeLessThan(readAt); | |
| 145 | }); | |
| 146 | ||
| 147 | it("checks write access before any decompression", () => { | |
| 148 | const gateAt = handler.indexOf(`satisfiesAccess(access, "write")`); | |
| 6ae664d | 149 | const gunzipAt = handler.indexOf("gunzipBounded("); |
| 303c5e2 | 150 | expect(gunzipAt).toBeGreaterThan(-1); |
| 151 | expect(gateAt).toBeLessThan(gunzipAt); | |
| 152 | }); | |
| 153 | ||
| 154 | it("no longer uses the unbounded synchronous decompressor", () => { | |
| 155 | expect(handler).not.toContain("gunzipSync"); | |
| 156 | }); | |
| 157 | ||
| 158 | it("still parses refs, so policy and post-receive keep working", () => { | |
| 159 | // The reorder moved the body read; it must not have dropped ref parsing, | |
| 160 | // or pushes would silently skip the policy gate and the hook. | |
| 6ae664d | 161 | expect(handler).toContain("parseReceivePackRefs("); |
| 162 | const gunzipAt = handler.indexOf("gunzipBounded("); | |
| 163 | expect(handler.indexOf("parseReceivePackRefs(")).toBeGreaterThan(gunzipAt); | |
| 303c5e2 | 164 | }); |
| 165 | }); |