Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
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.

bounded-gunzip.test.tsBlame165 lines · 1 contributor
303c5e2ccantynz-alt1/**
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
22import { describe, it, expect } from "bun:test";
23import { readFileSync } from "node:fs";
24import { join } from "node:path";
25import {
26 gunzipBounded,
27 isGzip,
28 DecompressedTooLargeError,
29 MAX_GIT_BODY_BYTES,
30} from "../lib/bounded-gunzip";
31
32describe("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
44describe("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
119describe("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")`);
149 const gunzipAt = handler.indexOf("gunzipBounded");
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.
161 expect(handler).toContain("parseReceivePackRefs");
162 const gunzipAt = handler.indexOf("gunzipBounded");
163 expect(handler.indexOf("parseReceivePackRefs")).toBeGreaterThan(gunzipAt);
164 });
165});