CodeIssuesPull RequestsActionsSecurityInsights
✨ AI
More
Settings

fix: clone URLs without ".git", and file writes that silently corrupted non-base64 content #5559

Merged⚡ AI-generatedXSccantynz wants to mergefix/clone-url-and-content-encodingmainopened 5d ago
13 changed files+529−45
Modifiedscripts/ci-tests.sh+16−0View fileUnifiedSplit
5151# File headers included so every (fail) line is attributable — run #2's
5252# log had the failures but not which files they lived in.
5353grep -E '^src/.*\.test\.ts:$|\(fail\)' "$out" | grep -B1 '(fail)' | head -150 || echo "(none)"
54
55# A red build with no diagnosis is worse than a red build. bun counts an
56# uncaught exception or unhandled rejection as an "error", NOT as a (fail) —
57# so the grep above prints "(none)" and the summary reads "0 fail, 1 error"
58# while the step exits 1. That happened on this branch: the log said every
59# test passed, said nothing had failed, and failed anyway. Diagnosing it
60# needed a local re-run of the whole suite, which is exactly the work CI
61# exists to have already done.
62#
63# So: whenever the run is red but no (fail) line explains why, dump the tail
64# of the real output instead of leaving the operator with "(none)".
65if [ "$status" -ne 0 ] && ! grep -q '(fail)' "$out"; then
66 echo "--- exited $status with no (fail) lines; last 120 lines of raw output ---"
67 tail -120 "$out"
68fi
69
5470echo "--- summary ---"
5571tail -25 "$out"
5672rm -f "$out"
Modifiedsrc/__tests__/bounded-gunzip.test.ts+24−5View fileUnifiedSplit
128128 .join("\n");
129129 })();
130130
131 // Match the registration by its ROUTE PATH, not by the call idiom. The
132 // original anchor was the literal `git.post("/:owner/:repo.git/...`, so
133 // rewriting the route to `git.on("POST", [<both spellings>], …)` — needed
134 // because git omits the ".git" suffix — silently stopped finding it.
135 //
136 // Two things made that worse than a broken assertion. The anchor lived in
137 // a module-scope IIFE, so its expect() threw BETWEEN tests: bun counted it
138 // as an "error" rather than a "(fail)", and CI's log filter greps only for
139 // (fail) lines. The step went red reporting "failures: (none)" and a
140 // summary of "0 fail" — a security-ordering check silently stopped running
141 // and said nothing. Hence: tolerant regex, and the not-found case is a
142 // named test below instead of a throw out here.
143 const REGISTRATION =
144 /git\.(?:post|on)\(\s*(?:["']POST["']\s*,\s*)?\[?\s*["']\/:owner\/:repo\.git\/git-receive-pack["']/;
145
131146 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;
147 const m = src.match(REGISTRATION);
148 return m?.index === undefined ? "" : src.slice(m.index);
137149 })();
138150
151 it("locates the git-receive-pack registration", () => {
152 // If this fails, every ordering assertion below is vacuous — they would
153 // all be searching an empty string. Fail here, by name, so the reason is
154 // legible in CI rather than arriving as an unattributable error.
155 expect(handler.length).toBeGreaterThan(0);
156 });
157
139158 it("checks write access before arrayBuffer()", () => {
140159 const gateAt = handler.indexOf(`satisfiesAccess(access, "write")`);
141160 const readAt = handler.indexOf("c.req.arrayBuffer()");
Addedsrc/__tests__/content-encoding-mis-decode.test.ts+106−0View fileUnifiedSplit
1/**
2 * `content` on the file-write APIs must never silently mis-decode.
3 *
4 * THE BUG. Both PUT /api/v2/repos/:o/:r/contents/:path and its GitHub-compat
5 * twin guarded base64 like this:
6 *
7 * try { bytes = Buffer.from(body.content, "base64") } catch { return 400 }
8 *
9 * `Buffer.from(s, "base64")` does not throw on invalid input — it drops every
10 * character outside the base64 alphabet and decodes the remainder. The catch
11 * was dead code, so a caller sending a plain UTF-8 string (which at least one
12 * live integration does, and which POST /git/blobs explicitly supports) got
13 * 201 Created and a file of binary garbage. The publish reported success.
14 *
15 * The first test below is the one that matters: it asserts the *decoder*
16 * rejects real-world source text rather than mangling it. If someone ever
17 * reverts to a bare Buffer.from, it fails.
18 */
19
20import { describe, it, expect } from "bun:test";
21import { decodeContent, parseContentEncoding } from "../lib/content-encoding";
22
23describe("decodeContent — base64", () => {
24 it("rejects plain UTF-8 source text instead of mangling it", () => {
25 // Exactly the payload shape a generated-site publisher sends.
26 const html = '<!doctype html>\n<html><body><h1>Hello</h1></body></html>\n';
27
28 // Proof the old code path was silently destructive: no throw, and the
29 // byte count does not survive.
30 const legacy = Buffer.from(html, "base64");
31 expect(legacy.length).not.toBe(Buffer.byteLength(html));
32
33 const r = decodeContent(html, "base64");
34 expect(r.ok).toBe(false);
35 if (!r.ok) expect(r.error).toContain("utf-8");
36 });
37
38 it("rejects other common source shapes", () => {
39 for (const s of [
40 '{"name":"app","version":"1.0.0"}',
41 "export default function App() { return <div/>; }",
42 "body { margin: 0; }",
43 "# Title\n\nSome prose.\n",
44 ]) {
45 expect(decodeContent(s, "base64").ok).toBe(false);
46 }
47 });
48
49 it("still decodes valid base64 byte-for-byte", () => {
50 const original = "<!doctype html>\n<h1>Hi</h1>\n";
51 const b64 = Buffer.from(original, "utf8").toString("base64");
52 const r = decodeContent(b64, "base64");
53 expect(r.ok).toBe(true);
54 if (r.ok) expect(Buffer.from(r.bytes).toString("utf8")).toBe(original);
55 });
56
57 it("accepts base64 wrapped across lines and unpadded base64", () => {
58 const original = "a".repeat(300);
59 const padded = Buffer.from(original, "utf8").toString("base64");
60 const wrapped = padded.replace(/(.{60})/g, "$1\n");
61 expect(decodeContent(wrapped, "base64").ok).toBe(true);
62
63 const unpadded = Buffer.from("abcde", "utf8")
64 .toString("base64")
65 .replace(/=+$/, "");
66 const r = decodeContent(unpadded, "base64");
67 expect(r.ok).toBe(true);
68 if (r.ok) expect(Buffer.from(r.bytes).toString("utf8")).toBe("abcde");
69 });
70
71 it("handles binary content, which is the reason base64 is the default", () => {
72 const png = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
73 const r = decodeContent(Buffer.from(png).toString("base64"), "base64");
74 expect(r.ok).toBe(true);
75 if (r.ok) expect(Array.from(r.bytes)).toEqual(Array.from(png));
76 });
77});
78
79describe("decodeContent — utf-8", () => {
80 it("writes the string through unchanged", () => {
81 const s = '<!doctype html>\n<h1>café — ümläut</h1>\n';
82 const r = decodeContent(s, "utf-8");
83 expect(r.ok).toBe(true);
84 if (r.ok) {
85 expect(Buffer.from(r.bytes).toString("utf8")).toBe(s);
86 expect(r.bytes.length).toBe(Buffer.byteLength(s, "utf8"));
87 }
88 });
89});
90
91describe("parseContentEncoding", () => {
92 it("defaults when absent and accepts both spellings of utf-8", () => {
93 expect(parseContentEncoding(undefined, "base64")).toBe("base64");
94 expect(parseContentEncoding(null, "utf-8")).toBe("utf-8");
95 // POST /git/blobs accepts "utf8"; accepting it on one route and 400ing it
96 // on the next is its own trap.
97 expect(parseContentEncoding("utf8", "base64")).toBe("utf-8");
98 expect(parseContentEncoding("UTF-8", "base64")).toBe("utf-8");
99 expect(parseContentEncoding("base64", "utf-8")).toBe("base64");
100 });
101
102 it("rejects anything else rather than falling back silently", () => {
103 expect(parseContentEncoding("hex", "base64")).toBeNull();
104 expect(parseContentEncoding(7, "base64")).toBeNull();
105 });
106});
Addedsrc/__tests__/git-suffixless-clone-url.test.ts+126−0View fileUnifiedSplit
1/**
2 * Git clone URLs must work WITHOUT the ".git" suffix.
3 *
4 * Git never appends ".git" to a remote URL. `git clone https://host/o/r`
5 * requests `/o/r/info/refs?service=git-upload-pack` verbatim, so a server that
6 * only registers `/:owner/:repo.git/info/refs` 404s the most natural URL a
7 * human can copy out of the browser address bar. GitHub and Gitea both serve
8 * both spellings; we served only the suffixed one.
9 *
10 * Measured on a downstream Gluecron instance (2026-08-26) before the fix:
11 * GET /ccantynz-alt/Vapron.git/info/refs?service=git-upload-pack → 401
12 * GET /ccantynz-alt/Vapron/info/refs?service=git-upload-pack → 404
13 * An origin configured without the suffix therefore failed every push with
14 * "repository not found", which is what stranded work on that workstation.
15 *
16 * THE REGRESSION THESE TESTS EXIST TO CATCH is not the 404 — it's the fix.
17 * app.tsx mounts a private-repo gate on `/:owner/:repo/*` that returns an
18 * HTML 404 for a repo the viewer cannot see. Git traffic used to miss that
19 * gate purely by URL shape (":repo" captured "name.git", which resolves to no
20 * repo). Suffix-less git paths DO match it. If the exemption in that gate is
21 * ever removed, an unauthenticated clone of a private repo receives an HTML
22 * 404 instead of the 401 challenge git needs in order to retry with
23 * credentials — clone and push break for legitimate collaborators, and no
24 * test that only checks "private repo is not disclosed" would notice, because
25 * a 404 still hides the repo. Hence the content-type assertions below: the
26 * git handler answers in text/plain, the web gate answers in HTML.
27 */
28
29import { describe, it, expect, beforeAll, afterAll } from "bun:test";
30import { join } from "path";
31import { rm, mkdir } from "fs/promises";
32import app from "../app";
33import { isGitProtocolPath } from "../lib/git-http-paths";
34
35const TEST_REPOS = join(import.meta.dir, "../../.test-repos-suffixless");
36
37beforeAll(async () => {
38 process.env.GIT_REPOS_PATH = TEST_REPOS;
39 process.env.DATABASE_URL = process.env.DATABASE_URL || "";
40 await mkdir(TEST_REPOS, { recursive: true });
41});
42
43afterAll(async () => {
44 await rm(TEST_REPOS, { recursive: true, force: true });
45});
46
47describe("suffix-less git Smart HTTP routes", () => {
48 // A 400 here proves the request reached the git handler's service check.
49 // Before the fix this was a 404 from the web router.
50 it("serves info/refs without the .git suffix", async () => {
51 const res = await app.request("/test/repo/info/refs?service=invalid");
52 expect(res.status).toBe(400);
53 });
54
55 it("still serves info/refs with the .git suffix", async () => {
56 const res = await app.request("/test/repo.git/info/refs?service=invalid");
57 expect(res.status).toBe(400);
58 });
59
60 it("answers a missing repo from the git handler, not the web 404 page", async () => {
61 const res = await app.request(
62 "/nobody/nothing/info/refs?service=git-upload-pack"
63 );
64 expect(res.status).toBe(404);
65 // text/plain = git handler. HTML would mean the web router answered and
66 // the git route never matched.
67 expect(res.headers.get("content-type") ?? "").toContain("text/plain");
68 });
69
70 it("routes upload-pack and receive-pack without the suffix", async () => {
71 for (const service of ["git-upload-pack", "git-receive-pack"]) {
72 const res = await app.request(`/nobody/nothing/${service}`, {
73 method: "POST",
74 });
75 // Reaches the git handler → plain-text 404 for the missing repo.
76 // A 404 with an HTML body would mean the POST fell through to the web
77 // router, i.e. the route is not registered.
78 expect(res.status).toBe(404);
79 expect(res.headers.get("content-type") ?? "").toContain("text/plain");
80 }
81 });
82});
83
84describe("isGitProtocolPath", () => {
85 it("matches both spellings of every git endpoint", () => {
86 for (const p of [
87 "/o/r.git/info/refs",
88 "/o/r/info/refs",
89 "/o/r.git/HEAD",
90 "/o/r/HEAD",
91 "/o/r.git/git-upload-pack",
92 "/o/r/git-upload-pack",
93 "/o/r.git/git-receive-pack",
94 "/o/r/git-receive-pack",
95 "/o/r.git/info/lfs/objects/batch",
96 "/o/r/info/lfs/objects/batch",
97 "/ccantynz/Gluecron.com/info/refs",
98 "/ccantynz/Gluecron.com.git/info/refs",
99 ]) {
100 expect(isGitProtocolPath(p)).toBe(true);
101 }
102 });
103
104 it("does not match ordinary repo pages", () => {
105 // These share the /:owner/:repo/* shape and must keep hitting the
106 // private-repo gate — exempting any of them would leak private repos.
107 for (const p of [
108 "/ccantynz/Gluecron.com",
109 "/ccantynz/Gluecron.com/tree/main",
110 "/ccantynz/Gluecron.com/issues",
111 "/ccantynz/Gluecron.com/settings",
112 "/ccantynz/Gluecron.com/insights/engineering",
113 "/settings/tokens",
114 "/orgs/new",
115 "/",
116 ]) {
117 expect(isGitProtocolPath(p)).toBe(false);
118 }
119 });
120
121 it("does not match a path that merely contains a .git segment", () => {
122 // The old `path.includes(".git/")` check fired on a file browser showing
123 // a vendored ".git/" directory, silently skipping auth logging for it.
124 expect(isGitProtocolPath("/o/r/tree/main/vendor/.git/config")).toBe(false);
125 });
126});
Modifiedsrc/__tests__/no-unauthenticated-writes.test.ts+52−10View fileUnifiedSplit
5858 "POST /reset-password": "password reset completion; authenticates via token",
5959
6060 // Git Smart HTTP — authenticates itself via lib/git-push-auth.ts.
61 // Both URL spellings: git never appends ".git" to a remote URL, so the
62 // suffix-less path carries real clone traffic and is the same handler
63 // behind the same gitAccessGate. Anonymous read of a public repo passes;
64 // a private repo 404s without credentials.
6165 "POST /:owner/:repo.git/git-upload-pack": "git protocol, own auth",
62 // git-receive-pack is deliberately absent: its write-access check now runs
63 // before it touches the request body, so the scanner sees the guard and
64 // the route no longer belongs on this allowlist.
66 "POST /:owner/:repo/git-upload-pack": "git protocol, own auth (suffix-less clone URL)",
67 // git-receive-pack is deliberately absent, in both spellings: its
68 // write-access check now runs before it touches the request body, so the
69 // scanner sees the guard and the route no longer belongs on this allowlist.
6570
6671 // Third-party webhook receivers — authenticate by signature, not session.
6772 "POST /api/hooks/gatetest": "GateTest callback",
143148 else prefixGuards.push(m[2]);
144149 }
145150
146 const re = /\.(post|put|patch|delete)\(\s*(["'`])([^"'`\n]*)\2/g;
147 let m;
148 while ((m = re.exec(src))) {
149 const path = m[3];
150 if (!path.startsWith("/")) continue;
151 const window = src.slice(m.index, m.index + 1200);
151 const classify = (path: string, at: number) => {
152 const window = src.slice(at, at + 1200);
152153 // Middleware sits between the path literal and the handler arrow.
153154 const arrow = window.indexOf("=>");
154155 const sig = arrow > -1 ? window.slice(0, arrow) : window.slice(0, 300);
158159 else if (guardAll) guard = "router-*";
159160 else if (prefixGuards.some((g) => { const p = g.replace(/\*$/, ""); return p && path.startsWith(p); })) guard = "router-prefix";
160161 else if (HANDLER_GUARD.test(window)) guard = "in-handler";
161 routes.push({ method: m[1].toUpperCase(), path, file: rel, guard });
162 return guard;
163 };
164
165 const re = /\.(post|put|patch|delete)\(\s*(["'`])([^"'`\n]*)\2/g;
166 let m;
167 while ((m = re.exec(src))) {
168 const path = m[3];
169 if (!path.startsWith("/")) continue;
170 routes.push({
171 method: m[1].toUpperCase(),
172 path,
173 file: rel,
174 guard: classify(path, m.index),
175 });
176 }
177
178 // `router.on(METHOD, path, …)`, including the array forms — which is how
179 // a handler registered on more than one path spelling is written. The git
180 // Smart HTTP and LFS routes use it to serve both "/:owner/:repo.git/…"
181 // and the suffix-less URL git actually requests.
182 //
183 // The scan above matches only `.post("…")`, so this form was invisible to
184 // it. That is precisely the failure this test exists to prevent: rewriting
185 // four routes from `.post(` to `.on(` silently removed them from the
186 // enumerated write surface while they kept serving traffic, and the
187 // allowlist went on "explaining" routes nobody was checking any more. A
188 // scan that only sees one registration idiom reports full coverage of a
189 // surface it is no longer reading.
190 const strings = (blob: string) =>
191 [...blob.matchAll(/["'`]([^"'`]*)["'`]/g)].map((x) => x[1]);
192 const onRe = /\.on\(\s*(\[[^\]]*\]|["'`][^"'`\n]*["'`])\s*,\s*(\[[^\]]*\]|["'`][^"'`\n]*["'`])/g;
193 while ((m = onRe.exec(src))) {
194 const methods = strings(m[1])
195 .map((x) => x.toUpperCase())
196 .filter((x) => ["POST", "PUT", "PATCH", "DELETE"].includes(x));
197 if (!methods.length) continue;
198 for (const path of strings(m[2]).filter((x) => x.startsWith("/"))) {
199 const guard = classify(path, m.index);
200 for (const method of methods) {
201 routes.push({ method, path, file: rel, guard });
202 }
203 }
162204 }
163205 }
164206 return routes;
Modifiedsrc/app.tsx+25−6View fileUnifiedSplit
99import { reportError } from "./lib/observability";
1010import { requestContext } from "./middleware/request-context";
1111import { rateLimit } from "./middleware/rate-limit";
12import { isGitProtocolPath } from "./lib/git-http-paths";
1213import gitRoutes from "./routes/git";
1314import lfsRoutes from "./routes/lfs";
1415import apiRoutes from "./routes/api";
251252app.use("*", async (c, next) => {
252253 const p = c.req.path;
253254 if (
254 p.includes(".git/") ||
255 isGitProtocolPath(p) ||
255256 p.startsWith("/live-events") ||
256257 p.startsWith("/api/events/deploy") ||
257258 p.startsWith("/admin/status") ||
339340});
340341// Logger only on non-git routes to avoid overhead on clone/push
341342app.use("*", async (c, next) => {
342 if (c.req.path.includes(".git/")) return next();
343 if (isGitProtocolPath(c.req.path)) return next();
343344 return logger()(c, next);
344345});
345346app.use("/api/*", cors());
446447app.use("/login/*", authRateLimit);
447448app.use("/register", authRateLimit);
448449
449// Rate limit git operations
450// Rate limit git operations. Both URL spellings — git never appends ".git"
451// to a clone URL, so the suffix-less routes carry real clone/push traffic and
452// would otherwise run with no limiter at all.
450453app.use("/:owner/:repo.git/*", gitRateLimit);
454app.use("/:owner/:repo/*", async (c, next) => {
455 if (!isGitProtocolPath(c.req.path)) return next();
456 return gitRateLimit(c, next);
457});
451458
452459// Rate limit search.
453460//
517524 * Safe against false positives. Paths like /settings/tokens or /orgs/new also
518525 * match `/:owner/:repo`, but loadRepoByPath returns null for them and the
519526 * request falls straight through — exactly as it does for a repo that does
520 * not exist, so each handler keeps emitting its own 404. `/:owner/:repo.git/*`
521 * (git Smart HTTP) resolves `:repo` as "name.git", also null, so the git
522 * protocol's own auth is untouched.
527 * not exist, so each handler keeps emitting its own 404.
528 *
529 * Git Smart HTTP is exempted explicitly in the body below. It used to be
530 * exempt by accident: `/:owner/:repo.git/*` resolves `:repo` as "name.git",
531 * which is also null. That stopped being true once the git routes started
532 * accepting the suffix-less spelling git actually requests, so the exemption
533 * is now stated rather than inherited from a URL quirk.
523534 *
524535 * Registered before every repo router and after softAuth, so c.get("user")
525536 * is populated and owners/collaborators are unaffected.
529540 const repo = c.req.param("repo");
530541 if (!owner || !repo) return next();
531542
543 // Git protocol traffic is exempt — it runs its own gitAccessGate, which
544 // 404s a private repo for a viewer without access exactly as this gate
545 // does. Suffix-less clone URLs (`/:owner/:repo/info/refs`) DO match this
546 // route, unlike the ".git" spelling, and an HTML 404 here would replace
547 // the 401 challenge a git client needs in order to retry with credentials
548 // — breaking clone and push of private repos for real collaborators.
549 if (isGitProtocolPath(c.req.path)) return next();
550
532551 let row: { id: string; isPrivate: boolean } | null = null;
533552 try {
534553 const { loadRepoByPath } = await import("./lib/namespace");
Addedsrc/lib/content-encoding.ts+92−0View fileUnifiedSplit
1/**
2 * Decoding the `content` field of the file-write APIs.
3 *
4 * THE BUG THIS EXISTS TO KILL. Both `PUT /api/v2/repos/:o/:r/contents/:path`
5 * and its GitHub-compat twin did this:
6 *
7 * try { bytes = new Uint8Array(Buffer.from(body.content, "base64")); }
8 * catch { return 400 "content is not valid base64"; }
9 *
10 * `Buffer.from(s, "base64")` NEVER throws. It silently discards every
11 * character outside the base64 alphabet and decodes whatever is left. So the
12 * catch was unreachable and the 400 could not fire. A client sending a plain
13 * UTF-8 string — the natural thing to send, and what at least one live
14 * integration does — got a 201 Created and a file full of binary garbage:
15 *
16 * "<!doctype html>\n<html>…" (57 bytes in) → 29 bytes of "v\xb7-\xca…"
17 *
18 * The write "succeeded", so the caller reported a successful publish and
19 * nobody found out until someone opened the file. Silent corruption reported
20 * as success is the worst available failure mode, and it was reachable from
21 * a customer-facing publish path.
22 *
23 * THE FIX, and why it is shaped this way. `content` stays base64 by default:
24 * that is GitHub's semantics, our own API docs say it, and flipping the
25 * default would silently corrupt the callers who are currently CORRECT (their
26 * base64 would be written out as literal base64 text). Instead:
27 *
28 * - callers may state `encoding: "utf-8" | "base64"` explicitly, the same
29 * vocabulary `POST /git/blobs` already accepts;
30 * - when base64 is used, the payload is VALIDATED, so a plain string is a
31 * loud 400 telling the caller to pass encoding:"utf-8" instead of a
32 * quiet mis-decode.
33 *
34 * No currently-working caller can break: anything that round-tripped before
35 * was valid base64, and valid base64 still decodes identically.
36 */
37
38/** Characters MIME base64 permits as insignificant whitespace. */
39const WHITESPACE = /[\s]/g;
40const BASE64_ALPHABET = /^[A-Za-z0-9+/]*={0,2}$/;
41
42export type ContentEncoding = "utf-8" | "base64";
43
44export type DecodeResult =
45 | { ok: true; bytes: Uint8Array }
46 | { ok: false; error: string };
47
48/**
49 * Normalise the `encoding` field. Accepts "utf8" as a spelling of "utf-8"
50 * because `POST /git/blobs` already does, and an API that accepts a spelling
51 * on one route and 400s it on the next is its own kind of trap.
52 */
53export function parseContentEncoding(
54 raw: unknown,
55 fallback: ContentEncoding
56): ContentEncoding | null {
57 if (raw === undefined || raw === null) return fallback;
58 if (typeof raw !== "string") return null;
59 const v = raw.toLowerCase();
60 if (v === "utf-8" || v === "utf8") return "utf-8";
61 if (v === "base64") return "base64";
62 return null;
63}
64
65/**
66 * Decode `content` to bytes, rejecting a payload that is not actually base64
67 * rather than silently mangling it.
68 *
69 * Unpadded base64 is accepted — plenty of encoders omit the "=" and
70 * `Buffer.from` has always handled it, so rejecting it would break working
71 * callers for no safety gain. A length of 4n+1 is impossible for valid
72 * base64, so it is rejected.
73 */
74export function decodeContent(
75 content: string,
76 encoding: ContentEncoding
77): DecodeResult {
78 if (encoding === "utf-8") {
79 return { ok: true, bytes: new Uint8Array(Buffer.from(content, "utf8")) };
80 }
81
82 const compact = content.replace(WHITESPACE, "");
83 if (!BASE64_ALPHABET.test(compact) || compact.length % 4 === 1) {
84 return {
85 ok: false,
86 error:
87 "content is not valid base64. Send base64, or set encoding to " +
88 '"utf-8" to write the string as text.',
89 };
90 }
91 return { ok: true, bytes: new Uint8Array(Buffer.from(compact, "base64")) };
92}
Addedsrc/lib/git-http-paths.ts+39−0View fileUnifiedSplit
1/**
2 * Which request paths belong to the git Smart HTTP / LFS protocol.
3 *
4 * Git never appends ".git" to a clone URL — `git clone https://host/o/r`
5 * requests `/o/r/info/refs?service=git-upload-pack` verbatim. GitHub and
6 * Gitea serve both spellings; we used to serve only the suffixed one, so the
7 * URL a human copies out of the browser bar 404'd on every clone and push.
8 * The routes now accept both, which means every middleware that used to
9 * detect git traffic with `path.includes(".git/")` would miss half of it.
10 *
11 * Two consequences, both load-bearing:
12 * - ETag/logger/compression skips: suffixed-only checks would start
13 * etagging pack responses and logging clone traffic again.
14 * - The private-repo gate on `/:owner/:repo/*` (app.tsx) returns an HTML
15 * 404 for a repo the viewer can't see. Git traffic previously missed it
16 * because ":repo" resolved as "name.git" — no such repo. Suffix-less git
17 * paths DO match it, so an unauthenticated clone of a private repo would
18 * get an HTML 404 instead of the 401 challenge it needs to retry with
19 * credentials, breaking clone for legitimate collaborators. The git
20 * routes run their own gitAccessGate (404 for private + unauthorized),
21 * so exempting them here preserves the privacy behaviour exactly.
22 */
23
24/** Path suffixes that only ever belong to the git protocol. */
25const GIT_ENDPOINT =
26 "(?:info\/refs|HEAD|git-upload-pack|git-receive-pack|info\/lfs(?:\/|$))";
27
28/** `/:owner/:repo[.git]/<git endpoint>` — both spellings. */
29const GIT_PATH = new RegExp(`^\/[^/]+\/[^/]+?(?:\.git)?\/${GIT_ENDPOINT}`);
30
31/**
32 * True for git Smart HTTP and LFS requests, with or without the ".git"
33 * suffix. Use this instead of `path.includes(".git/")` — the latter silently
34 * misses suffix-less clones (and false-positives on any path that merely
35 * contains ".git/", e.g. a file browser showing a vendored ".git/" folder).
36 */
37export function isGitProtocolPath(path: string): boolean {
38 return GIT_PATH.test(path);
39}
Modifiedsrc/lib/surface-registry.ts+3−0View fileUnifiedSplit
3131 * page, which is worse than admitting the route was not checked.
3232 */
3333
34import { isGitProtocolPath } from "./git-http-paths";
35
3436export type SurfaceGroup =
3537 | "global"
3638 | "profile"
133135 // `/readyz` are already covered by the core 14-check suite; duplicating
134136 // them here would double-count the same signal on the spine board.
135137 match: (p) =>
138 isGitProtocolPath(p) ||
136139 p.includes(".git/") ||
137140 p.includes("/info/refs") ||
138141 ["/healthz", "/readyz", "/metrics"].includes(p),
Modifiedsrc/routes/api-v2.ts+18−7View fileUnifiedSplit
8484 summarizeCostsForRepo,
8585 startOfUtcMonth,
8686} from "../lib/ai-cost-tracker";
87import { decodeContent, parseContentEncoding } from "../lib/content-encoding";
8788import {
8889 generateCommitMessage,
8990 DIFF_BYTE_CAP,
17291730 let body: {
17301731 message?: string;
17311732 content?: string;
1733 encoding?: string;
17321734 branch?: string;
17331735 sha?: string | null;
17341736 } = {};
17401742
17411743 const message = body.message?.trim();
17421744 const branch = body.branch?.trim();
1743 const base64 = body.content;
1745 const content = body.content;
17441746
17451747 if (!message) return c.json({ error: "message is required" }, 400);
17461748 if (!branch) return c.json({ error: "branch is required" }, 400);
1747 if (typeof base64 !== "string") return c.json({ error: "content (base64) is required" }, 400);
1749 if (typeof content !== "string") {
1750 return c.json({ error: "content is required" }, 400);
1751 }
17481752
1749 let bytes: Uint8Array;
1750 try {
1751 bytes = new Uint8Array(Buffer.from(base64, "base64"));
1752 } catch {
1753 return c.json({ error: "content is not valid base64" }, 400);
1753 // `content` defaults to base64 (GitHub's semantics, and what our own docs
1754 // promise), but callers may say so explicitly with the same `encoding`
1755 // vocabulary POST /git/blobs accepts. decodeContent VALIDATES base64
1756 // rather than letting Buffer.from silently discard every non-alphabet
1757 // character — that used to turn a plain UTF-8 body into a 201 Created
1758 // full of binary garbage. See src/lib/content-encoding.ts.
1759 const encoding = parseContentEncoding(body.encoding, "base64");
1760 if (!encoding) {
1761 return c.json({ error: "encoding must be 'utf-8' or 'base64'" }, 400);
17541762 }
1763 const decoded = decodeContent(content, encoding);
1764 if (!decoded.ok) return c.json({ error: decoded.error }, 400);
1765 const bytes = decoded.bytes;
17551766 if (bytes.length > CONTENTS_MAX_BYTES) {
17561767 return c.json({ error: "File too large" }, 413);
17571768 }
Modifiedsrc/routes/git.ts+4−4View fileUnifiedSplit
5353}
5454
5555// Discovery: GET /:owner/:repo.git/info/refs?service=...
56git.get("/:owner/:repo.git/info/refs", async (c) => {
56git.on("GET", ["/:owner/:repo.git/info/refs", "/:owner/:repo/info/refs"], async (c) => {
5757 // gitParams strips the ".git" suffix regardless of how the Hono version in
5858 // play names the ":repo.git" param. Everything downstream (repoExists,
5959 // getRepoPath, getInfoRefs) expects the bare repo NAME — passing the raw
8585});
8686
8787// GET /:owner/:repo.git/HEAD
88git.get("/:owner/:repo.git/HEAD", async (c) => {
88git.on("GET", ["/:owner/:repo.git/HEAD", "/:owner/:repo/HEAD"], async (c) => {
8989 const { owner, repo } = gitParams(c);
9090 if (!(await repoExists(owner, repo))) {
9191 return c.text("Repository not found", 404);
101101});
102102
103103// Upload pack (clone/fetch)
104git.post("/:owner/:repo.git/git-upload-pack", async (c) => {
104git.on("POST", ["/:owner/:repo.git/git-upload-pack", "/:owner/:repo/git-upload-pack"], async (c) => {
105105 const { owner, repo } = gitParams(c);
106106 if (!(await repoExists(owner, repo))) {
107107 return c.text("Repository not found", 404);
125125});
126126
127127// Receive pack (push)
128git.post("/:owner/:repo.git/git-receive-pack", async (c) => {
128git.on("POST", ["/:owner/:repo.git/git-receive-pack", "/:owner/:repo/git-receive-pack"], async (c) => {
129129 const { owner, repo } = gitParams(c);
130130 if (!(await repoExists(owner, repo))) {
131131 return c.text("Repository not found", 404);
Modifiedsrc/routes/github-compat.ts+18−7View fileUnifiedSplit
6969import { parseIdNumber } from "../lib/route-params";
7070import { config } from "../lib/config";
7171import { logActivity } from "../lib/notify";
72import { decodeContent, parseContentEncoding } from "../lib/content-encoding";
7273import {
7374 ghUser,
7475 ghRepo,
516517 if (!user) return ghErr(c, 401, "Requires authentication");
517518 if (!hasScope(c, "repo")) return ghErr(c, 403, "Insufficient scope. Required: repo");
518519
519 let body: { message?: string; content?: string; branch?: string; sha?: string | null } = {};
520 let body: {
521 message?: string;
522 content?: string;
523 encoding?: string;
524 branch?: string;
525 sha?: string | null;
526 } = {};
520527 try {
521528 body = await c.req.json();
522529 } catch {
525532 const message = body.message?.trim();
526533 if (!message) return ghErr(c, 422, "Invalid request. message is required");
527534 if (typeof body.content !== "string") {
528 return ghErr(c, 422, "Invalid request. content (base64) is required");
535 return ghErr(c, 422, "Invalid request. content is required");
529536 }
530 let bytes: Uint8Array;
531 try {
532 bytes = new Uint8Array(Buffer.from(body.content, "base64"));
533 } catch {
534 return ghErr(c, 422, "content is not valid base64");
537 // Same silent-mis-decode fix as api-v2's PUT: Buffer.from(s, "base64")
538 // never throws, so the catch here could not fire and a plain UTF-8 body
539 // was written as binary garbage under a 2xx. See lib/content-encoding.ts.
540 const encoding = parseContentEncoding(body.encoding, "base64");
541 if (!encoding) {
542 return ghErr(c, 422, "encoding must be 'utf-8' or 'base64'");
535543 }
544 const decoded = decodeContent(body.content, encoding);
545 if (!decoded.ok) return ghErr(c, 422, decoded.error);
546 const bytes = decoded.bytes;
536547 if (bytes.length > CONTENTS_MAX_BYTES) return ghErr(c, 422, "File too large");
537548
538549 const resolved = await resolveRepo(owner, repo);
Modifiedsrc/routes/lfs.ts+6−6View fileUnifiedSplit
195195}
196196
197197// POST /:owner/:repo.git/info/lfs/objects/batch
198lfs.post("/:owner/:repo.git/info/lfs/objects/batch", async (c) => {
198lfs.on("POST", ["/:owner/:repo.git/info/lfs/objects/batch", "/:owner/:repo/info/lfs/objects/batch"], async (c) => {
199199 const { owner, repo } = lfsParams(c);
200200 if (!(await repoExists(owner, repo))) {
201201 return lfsJson(c, 404, { message: "Repository not found" });
316316});
317317
318318// PUT /:owner/:repo.git/info/lfs/objects/:oid — basic-transfer upload
319lfs.put("/:owner/:repo.git/info/lfs/objects/:oid", async (c) => {
319lfs.on("PUT", ["/:owner/:repo.git/info/lfs/objects/:oid", "/:owner/:repo/info/lfs/objects/:oid"], async (c) => {
320320 const { owner, repo } = lfsParams(c);
321321 if (!(await repoExists(owner, repo))) {
322322 return lfsJson(c, 404, { message: "Repository not found" });
368368});
369369
370370// GET /:owner/:repo.git/info/lfs/objects/:oid — basic-transfer download
371lfs.get("/:owner/:repo.git/info/lfs/objects/:oid", async (c) => {
371lfs.on("GET", ["/:owner/:repo.git/info/lfs/objects/:oid", "/:owner/:repo/info/lfs/objects/:oid"], async (c) => {
372372 const { owner, repo } = lfsParams(c);
373373 if (!(await repoExists(owner, repo))) {
374374 return lfsJson(c, 404, { message: "Repository not found" });
394394});
395395
396396// POST /:owner/:repo.git/info/lfs/objects/:oid/verify — post-upload check
397lfs.post("/:owner/:repo.git/info/lfs/objects/:oid/verify", async (c) => {
397lfs.on("POST", ["/:owner/:repo.git/info/lfs/objects/:oid/verify", "/:owner/:repo/info/lfs/objects/:oid/verify"], async (c) => {
398398 const { owner, repo } = lfsParams(c);
399399 if (!(await repoExists(owner, repo))) {
400400 return lfsJson(c, 404, { message: "Repository not found" });
434434
435435// GET /:owner/:repo.git/info/lfs/locks — locking is not implemented; the
436436// empty shape keeps `git lfs push` from erroring on its pre-push lock probe.
437lfs.get("/:owner/:repo.git/info/lfs/locks", async (c) => {
437lfs.on("GET", ["/:owner/:repo.git/info/lfs/locks", "/:owner/:repo/info/lfs/locks"], async (c) => {
438438 const { owner, repo } = lfsParams(c);
439439 if (!(await repoExists(owner, repo))) {
440440 return lfsJson(c, 404, { message: "Repository not found" });
447447// POST /:owner/:repo.git/info/lfs/locks/verify — same: always "no locks".
448448// Read gate, not write: the probe runs before any object transfer, and a
449449// read-only caller learning "no locks exist" discloses nothing.
450lfs.post("/:owner/:repo.git/info/lfs/locks/verify", async (c) => {
450lfs.on("POST", ["/:owner/:repo.git/info/lfs/locks/verify", "/:owner/:repo/info/lfs/locks/verify"], async (c) => {
451451 const { owner, repo } = lfsParams(c);
452452 if (!(await repoExists(owner, repo))) {
453453 return lfsJson(c, 404, { message: "Repository not found" });
454454
c comment · e edit title · m merge · a approve · r request changes · ? shortcuts