Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
claude/adoring-hopper-5x74bqclaude/affectionate-feynman-ykrf1hclaude/architecture-audit-design-wxprenclaude/build-status-update-3MXsfclaude/charming-meitner-mllb5rclaude/compare-gate-gluecron-s4mFQclaude/confident-faraday-tikcwbclaude/continue-work-XMTlIclaude/crontech-gluecron-deploy-7MIECclaude/crontech-platform-setup-SeKfwclaude/design-2026claude/ecstatic-ptolemy-jMdigclaude/enhance-github-integration-QNHdGclaude/fix-aa-loop-issue-PonMQclaude/fix-actions-and-processclaude/fix-desktop-errors-XqoW8claude/fix-red-workflowsclaude/fix-website-access-6FKJNclaude/gatetest-integration-hardeningclaude/github-audit-improvements-bDFr9claude/gluecron-launch-status-FoMRlclaude/hopeful-lamport-olfCTclaude/issue-to-pr-and-protectionsclaude/jolly-heisenberg-2sg1Qclaude/launch-preparation-QmTb6claude/new-session-xk1l7claude/plan-platform-architecture-kkN4yclaude/platform-analysis-roadmap-1nUGLclaude/platform-launch-assessment-8dWV8claude/polish-platform-release-AeDrUclaude/resume-previous-work-KzyLwclaude/review-crontech-handoff-qYEVqclaude/review-project-completeness-lHhS2claude/review-readme-docs-ulqPKclaude/serene-edison-rj87weclaude/setup-multi-repo-dev-BCwNQclaude/ship-fixes-and-tests-Jvz1cclaude/site-audit-competitive-pctlwgclaude/site-migration-vercel-XstpKclaude/standalone-product-repos-XHFTDcopilot/feat-smart-empty-states-keyboard-first-enhancementcopilot/feat-smart-morning-digest-review-context-restorecopilot/fix-and-process-workflowscopilot/update-ai-powered-code-reviewfeat/debt-mapfeat/push-policy-codeowners-hardeningfeat/smart-digest-contextfeat/stage-impactfeat/t1-secret-migrationfeat/u-polishfeat/w-self-hostfeat/w2-claude-configfix/agent-journey-orphan-sweepfix/audit-sweep-2026-07-26gatetest/auto-fix-1776586424172gatetest/auto-fix-1776586534814gatetest/auto-fix-1776590685143gatetest/auto-fix-1776590808199mainops/redeploy-retriggerstyle/dxt-cta-themeworktree-agent-a3377aad30d55da26worktree-agent-a7ef607b7ee1d6c74
bounded-gunzip.test.ts6.2 KB · 165 lines
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
/**
 * Regression guard: a git push body must not be able to OOM or stall the
 * server, and must not be touched at all before the pusher is authorized.
 *
 * The bug in POST /:owner/:repo.git/git-receive-pack was an ordering one.
 * The handler did `await c.req.arrayBuffer()` and then `Bun.gunzipSync(...)`
 * — buffering the whole body and expanding it in full — and only AFTER that
 * checked whether the caller had write access. So any unauthenticated caller
 * who knew a repository name (public ones are listed on /explore) could post
 * a gzip bomb and have the server expand it before deciding they had no
 * right to push anything. gzip passes 1000:1 on repetitive input, so a
 * 10 MB upload becomes ~10 GB of resident memory.
 *
 * `gunzipSync` compounded it by being synchronous: decompression blocked the
 * event loop for its whole duration, stalling every other in-flight request.
 * That is a latency DoS on its own, independent of the memory one.
 *
 * Two things are asserted: the decompressor is bounded, and the handler
 * still reads the body only after the access gate.
 */

import { describe, it, expect } from "bun:test";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import {
  gunzipBounded,
  isGzip,
  DecompressedTooLargeError,
  MAX_GIT_BODY_BYTES,
} from "../lib/bounded-gunzip";

describe("isGzip", () => {
  it("detects the gzip magic number", () => {
    expect(isGzip(Bun.gzipSync(new Uint8Array([1, 2, 3])))).toBe(true);
  });

  it("rejects plain and truncated input", () => {
    expect(isGzip(new Uint8Array([]))).toBe(false);
    expect(isGzip(new Uint8Array([0x1f]))).toBe(false);
    expect(isGzip(new TextEncoder().encode("0000"))).toBe(false);
  });
});

describe("gunzipBounded", () => {
  it("round-trips ordinary content unchanged", async () => {
    const original = new TextEncoder().encode(
      "0032want d4a1f9e0c2b3a4d5e6f70819a2b3c4d5e6f70819\n0000"
    );
    const out = await gunzipBounded(Bun.gzipSync(original));
    expect(new TextDecoder().decode(out)).toBe(
      new TextDecoder().decode(original)
    );
  });

  it("round-trips content spanning many internal chunks", async () => {
    // Incompressible-ish payload large enough that the stream yields more
    // than one chunk, exercising the concat path.
    const big = new Uint8Array(3 * 1024 * 1024);
    for (let i = 0; i < big.length; i++) big[i] = (i * 2654435761) & 0xff;
    const out = await gunzipBounded(Bun.gzipSync(big));
    expect(out.byteLength).toBe(big.byteLength);
    expect(out[0]).toBe(big[0]);
    expect(out[big.length - 1]).toBe(big[big.length - 1]);
  });

  it("refuses a gzip bomb instead of expanding it (the attack)", async () => {
    // 64 MiB of zeros compresses to a few tens of KB — a ~1000:1 ratio, the
    // exact shape of the attack. Compressed size is unremarkable; expanded
    // size is what would have killed the process.
    const bomb = Bun.gzipSync(new Uint8Array(64 * 1024 * 1024));
    expect(bomb.byteLength).toBeLessThan(1024 * 1024);

    await expect(gunzipBounded(bomb, 1024 * 1024)).rejects.toBeInstanceOf(
      DecompressedTooLargeError
    );
  });

  it("reports the limit it enforced", async () => {
    const bomb = Bun.gzipSync(new Uint8Array(8 * 1024 * 1024));
    try {
      await gunzipBounded(bomb, 4096);
      throw new Error("should have thrown");
    } catch (err) {
      expect(err).toBeInstanceOf(DecompressedTooLargeError);
      expect((err as DecompressedTooLargeError).limit).toBe(4096);
    }
  });

  it("accepts output that lands exactly on the limit", async () => {
    const payload = new Uint8Array(1024);
    const out = await gunzipBounded(Bun.gzipSync(payload), 1024);
    expect(out.byteLength).toBe(1024);
  });

  it("rejects one byte over the limit", async () => {
    const payload = new Uint8Array(1025);
    await expect(
      gunzipBounded(Bun.gzipSync(payload), 1024)
    ).rejects.toBeInstanceOf(DecompressedTooLargeError);
  });

  it("defaults to a bound that is generous but finite", () => {
    expect(MAX_GIT_BODY_BYTES).toBeGreaterThan(64 * 1024 * 1024);
    expect(Number.isFinite(MAX_GIT_BODY_BYTES)).toBe(true);
  });

  it("surfaces corrupt gzip rather than hanging", async () => {
    const corrupt = Bun.gzipSync(new TextEncoder().encode("hello"));
    corrupt[corrupt.length - 4] ^= 0xff; // wreck the trailing CRC
    await expect(gunzipBounded(corrupt)).rejects.toBeDefined();
  });
});

// --- ordering: authorize before touching the body --------------------------
//
// Strip ONLY line comments. A block-comment regex eats route paths, which
// contain a slash followed by a star — and this file is full of them.

describe("git-receive-pack authorizes before reading the body", () => {
  const src = (() => {
    const text = readFileSync(
      join(import.meta.dir, "..", "routes", "git.ts"),
      "utf8"
    );
    return text
      .split("\n")
      .filter((l) => !l.trim().startsWith("//"))
      .join("\n");
  })();

  const handler = (() => {
    const start = src.indexOf(`git.post("/:owner/:repo.git/git-receive-pack"`);
    expect(start).toBeGreaterThan(-1);
    const body = src.slice(start);
    expect(body.length).toBeGreaterThan(0);
    return body;
  })();

  it("checks write access before arrayBuffer()", () => {
    const gateAt = handler.indexOf(`satisfiesAccess(access, "write")`);
    const readAt = handler.indexOf("c.req.arrayBuffer()");
    expect(gateAt).toBeGreaterThan(-1);
    expect(readAt).toBeGreaterThan(-1);
    expect(gateAt).toBeLessThan(readAt);
  });

  it("checks write access before any decompression", () => {
    const gateAt = handler.indexOf(`satisfiesAccess(access, "write")`);
    const gunzipAt = handler.indexOf("gunzipBounded");
    expect(gunzipAt).toBeGreaterThan(-1);
    expect(gateAt).toBeLessThan(gunzipAt);
  });

  it("no longer uses the unbounded synchronous decompressor", () => {
    expect(handler).not.toContain("gunzipSync");
  });

  it("still parses refs, so policy and post-receive keep working", () => {
    // The reorder moved the body read; it must not have dropped ref parsing,
    // or pushes would silently skip the policy gate and the hook.
    expect(handler).toContain("parseReceivePackRefs");
    const gunzipAt = handler.indexOf("gunzipBounded");
    expect(handler.indexOf("parseReceivePackRefs")).toBeGreaterThan(gunzipAt);
  });
});