Vectrum
Sign In

Harden git calls against ref-name argument injection

Fixes the findings in the security review:

- A branch named like "--output=/path" made server-side git commands
  write attacker-controlled commit text to arbitrary files. Pushes of refs
  with a component starting with "-" are now refused by a pre-receive hook
  that both transports run (core.hooksPath under DATA_ROOT/hooks); such
  refs are ignored when listing branches and tags; and every revision
  argument follows --end-of-options and is rejected if it starts with "-"
  (git shortlog still parses options after a single --end-of-options).
  Web edits validate the submitted base commit.
- Session cookies are Secure based on PUBLIC_URL (or SECURE_COOKIES=1)
  instead of the client-supplied X-Forwarded-Proto header.
- Clone URLs and SSH instructions use PUBLIC_URL instead of trusting
  Host / X-Forwarded-Host.
- The SSH server offers the post-quantum hybrid mlkem768x25519-sha256 key
  exchange (patched into ssh2 from mscdex/ssh2#1471; needs Node >= 24.7).

Adds `bun test` with a regression test for the injection.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
JjohannSocommitted 1 parent aecb85ccommit47dff0e

Showing 15 changed files with 487 additions and 45 deletions.

+5−1README.mdView File
@@ -61,6 +61,8 @@ Anonymous users can clone public repositories without a token.
6161
6262 | Variable | Default | Purpose |
6363 | ----------------- | ----------- | --------------------------------------------------------- |
64+ | `PUBLIC_URL` | request host | Canonical address, e.g. `https://vectrum.cc`. Used for clone URLs; `https` also makes session cookies `Secure`. Set it in production |
65+ | `SECURE_COOKIES` | — | Set to `1` to mark session cookies `Secure` without `PUBLIC_URL` |
6466 | `DATA_ROOT` | `./data` | Where repositories, the database and the SSH host key live |
6567 | `SSH_PORT` | `2222` | Port of the built-in SSH server |
6668 | `SSH_LISTEN_HOST` | `0.0.0.0` | Interface the SSH server binds to |
@@ -73,12 +75,14 @@ Data layout:
7375 data/
7476 app.db users, sessions, keys, tokens, repos, issues (SQLite)
7577 ssh_host_ed25519_key generated on first start
78+ hooks/ server-side git hooks for every push, rewritten on start
7679 repos/<owner>/<repo>.git bare git repositories
7780 ```
7881
7982 ## Security notes
8083
81- - Serve the web UI over HTTPS if it's reachable beyond localhost. Session cookies become `Secure` when `X-Forwarded-Proto: https` is set.
84+ - Serve the web UI over HTTPS if it's reachable beyond localhost, and set `PUBLIC_URL`. `Host` and `X-Forwarded-*` headers are client-controlled, so they never decide cookie security, and only stand in for `PUBLIC_URL` in development.
85+ - Ref names are never passed to git where they could be read as options: pushes of refs with a component starting with `-` are refused by a pre-receive hook, such refs are ignored when reading, and every revision argument follows `--end-of-options`.
8286 - Private repositories show a 404 page to anyone without access, so their existence isn't revealed.
8387 - Raw files are served with `nosniff` and a sandboxing CSP, so repository HTML/SVG can't run scripts in the app's origin.
8488
+2−1app/actions/files.tsView File
@@ -13,7 +13,7 @@ const str = (formData: FormData, key: string) => String(formData.get(key) ?? "")
1313
1414 /** Blob sha of a path at a commit, or null if it doesn't exist there. */
1515 async function blobAt(dir: string, commit: string, path: string) {
16- return (await git(dir, ["rev-parse", "--verify", "--quiet", `${commit}:${path}`]).catch(() => "")).trim() || null;
16+ return (await git(dir, ["rev-parse", "--verify", "--quiet", "--end-of-options", `${commit}:${path}`]).catch(() => "")).trim() || null;
1717 }
1818
1919 /**
@@ -32,6 +32,7 @@ async function commitFromForm(
3232 ) {
3333 const branch = str(formData, "branch");
3434 const baseSha = str(formData, "baseSha");
35+ if (baseSha && !/^[0-9a-f]{40,64}$/.test(baseSha)) throw new ActionError("Invalid base commit.");
3536 const head = await revParse(repo.dir, `refs/heads/${branch}`);
3637
3738 if (baseSha) {
+2−3app/page.tsxView File
@@ -1,4 +1,3 @@
1- import { headers } from "next/headers";
21 import Link from "next/link";
32 import { Card, Surface } from "@unlocalhosted/metalui";
43 import { BRAND } from "@/components/brand";
@@ -24,14 +23,14 @@ import { getViewer } from "@/lib/auth";
2423 import { shortSha, timeAgo } from "@/lib/format";
2524 import { recentActivity, withActivity } from "@/lib/listing";
2625 import { listReposForUser, searchRepos } from "@/lib/repos";
26+ import { serverHost } from "@/lib/request-origin";
2727 import { sshCloneUrl } from "@/lib/ssh-config";
2828 import type { User } from "@/lib/users";
2929
3030 export default async function Home() {
3131 const viewer = await getViewer();
3232 if (viewer) return <Dashboard viewer={viewer} />;
33- const host = (await headers()).get("host") ?? "localhost:3000";
34- return <Landing sshUrl={sshCloneUrl(host, "you/project.git")} />;
33+ return <Landing sshUrl={sshCloneUrl(await serverHost(), "you/project.git")} />;
3534 }
3635
3736 const features = [
+2−3app/settings/keys/page.tsxView File
@@ -1,5 +1,4 @@
11 import type { Metadata } from "next";
2- import { headers } from "next/headers";
32 import { Accordion, Label as MuLabel } from "@unlocalhosted/metalui";
43 import { deleteSshKeyAction } from "@/app/actions/settings";
54 import { ConfirmButton } from "@/components/confirm-button";
@@ -11,6 +10,7 @@ import { Alert, Blankslate, Box, Label, PageTitle } from "@/components/ui";
1110 import { requireViewer } from "@/lib/auth";
1211 import { formatDate, timeAgo } from "@/lib/format";
1312 import { listSshKeys } from "@/lib/keys";
13+ import { serverHost } from "@/lib/request-origin";
1414 import { SSH_ENABLED, sshTestCommand } from "@/lib/ssh-config";
1515 import { AddKeyForm } from "./form";
1616
@@ -19,8 +19,7 @@ export const metadata: Metadata = { title: "SSH keys" };
1919 export default async function KeysPage() {
2020 const viewer = await requireViewer();
2121 const keys = listSshKeys(viewer.id);
22- const host = (await headers()).get("host") ?? "localhost:3000";
23- const test = sshTestCommand(host);
22+ const test = sshTestCommand(await serverHost());
2423
2524 return (
2625 <div>
+2−3app/settings/tokens/page.tsxView File
@@ -1,5 +1,4 @@
11 import type { Metadata } from "next";
2- import { headers } from "next/headers";
32 import { Label as MuLabel } from "@unlocalhosted/metalui";
43 import { deleteTokenAction } from "@/app/actions/settings";
54 import { ConfirmButton } from "@/components/confirm-button";
@@ -10,6 +9,7 @@ import { Blankslate, Label, PageTitle, RefChip } from "@/components/ui";
109 import { requireViewer } from "@/lib/auth";
1110 import { formatDate, timeAgo } from "@/lib/format";
1211 import { canWrite, listReposForUser, repoAccess } from "@/lib/repos";
12+ import { serverOrigin } from "@/lib/request-origin";
1313 import { listTokens, TOKEN_PREFIX } from "@/lib/tokens";
1414 import { NewTokenForm } from "./form";
1515
@@ -38,8 +38,7 @@ export default async function TokensPage() {
3838 fullName: `${r.owner}/${r.name}`,
3939 writable: canWrite(repoAccess(r, viewer.id)),
4040 }));
41- const h = await headers();
42- const origin = `${h.get("x-forwarded-proto") ?? "http"}://${h.get("host") ?? "localhost:3000"}`;
41+ const origin = await serverOrigin();
4342
4443 return (
4544 <div>
+3−0bun.lockView File
@@ -40,6 +40,9 @@
4040 "sharp",
4141 "unrs-resolver",
4242 ],
43+ "patchedDependencies": {
44+ "ssh2@1.17.0": "patches/ssh2@1.17.0.patch",
45+ },
4346 "packages": {
4447 "@alloc/quick-lru": ["@alloc/quick-lru@5.3.0", "", {}, "sha512-U4+70Pc5ZS9osnCBCE5Jha/ciHM+Yp+CNMNC/7HvYbNRk1Ldd+f7qO65W5qfhu/TCv+/ozljlXXe9Nj8419DMA=="],
4548
+3−3lib/auth.tsView File
@@ -1,9 +1,10 @@
11 import "server-only";
22 import { createHash, randomBytes } from "node:crypto";
33 import { cache } from "react";
4- import { cookies, headers } from "next/headers";
4+ import { cookies } from "next/headers";
55 import { redirect } from "next/navigation";
66 import { db } from "./db";
7+ import { SECURE_COOKIES } from "./request-origin";
78 import { getUserById } from "./users";
89
910 const SESSION_COOKIE = "session";
@@ -21,11 +22,10 @@ export async function startSession(userId: number) {
2122 now,
2223 expires,
2324 );
24- const h = await headers();
2525 (await cookies()).set(SESSION_COOKIE, token, {
2626 httpOnly: true,
2727 sameSite: "lax",
28- secure: h.get("x-forwarded-proto") === "https",
28+ secure: SECURE_COOKIES,
2929 path: "/",
3030 expires: new Date(expires),
3131 });
+2−2lib/git-write.tsView File
@@ -48,7 +48,7 @@ export async function commitFiles(
4848 GIT_COMMITTER_EMAIL: opts.author.email,
4949 };
5050 try {
51- if (opts.parent) await git(dir, ["read-tree", opts.parent], { env });
51+ if (opts.parent) await git(dir, ["read-tree", "--end-of-options", opts.parent], { env });
5252 else await git(dir, ["read-tree", "--empty"], { env });
5353
5454 for (const change of opts.changes) {
@@ -66,7 +66,7 @@ export async function commitFiles(
6666
6767 const tree = (await git(dir, ["write-tree"], { env })).trim();
6868 if (opts.parent) {
69- const parentTree = (await git(dir, ["rev-parse", `${opts.parent}^{tree}`])).trim();
69+ const parentTree = (await git(dir, ["rev-parse", "--end-of-options", `${opts.parent}^{tree}`])).trim();
7070 if (parentTree === tree) throw new GitError("No changes to commit.");
7171 }
7272 const args = ["commit-tree", tree];
+73−0lib/git.test.tsaddedView File
@@ -0,0 +1,73 @@
1+ // Regression tests for git argument injection through ref names (SEC_REPORT #1).
2+ // Run with `bun test`.
3+ import assert from "node:assert/strict";
4+ import { execFileSync } from "node:child_process";
5+ import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
6+ import os from "node:os";
7+ import path from "node:path";
8+ import { after, before, test } from "node:test";
9+
10+ const tmp = mkdtempSync(path.join(os.tmpdir(), "vectrum-git-test-"));
11+ process.env.DATA_ROOT = path.join(tmp, "data");
12+ const target = path.join(tmp, "PWNED");
13+ const evilBranch = `--output=${target}`;
14+
15+ const sh = (cwd: string, ...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8", stdio: "pipe" });
16+
17+ let git: typeof import("./git");
18+ let repo: string;
19+ let work: string;
20+
21+ before(async () => {
22+ git = await import("./git");
23+ repo = path.join(tmp, "srv.git");
24+ await git.initBareRepo(repo);
25+ work = path.join(tmp, "work");
26+ sh(tmp, "init", "-q", "-b", "main", work);
27+ sh(work, "-c", "user.name=a", "-c", "user.email=a@x", "commit", "-q", "--allow-empty", "-m", "ssh-ed25519 AAAA attacker");
28+ });
29+
30+ after(() => rmSync(tmp, { recursive: true, force: true }));
31+
32+ /** Pushes through spawnService, the way the SSH server runs receive-pack. */
33+ function push(refspec: string) {
34+ const wrapper = path.join(tmp, "receive-pack.ts");
35+ writeFileSync(
36+ wrapper,
37+ `const { spawnService } = await import(${JSON.stringify(path.join(import.meta.dirname, "git.ts"))});
38+ const child = spawnService("git-receive-pack", process.argv[2], { stateless: false });
39+ process.stdin.pipe(child.stdin);
40+ child.stdout.pipe(process.stdout);
41+ child.stderr.pipe(process.stderr);
42+ child.on("close", (code) => process.exit(code ?? 1));`,
43+ );
44+ return sh(work, "push", "--porcelain", `--receive-pack=${process.execPath} ${wrapper}`, repo, refspec);
45+ }
46+
47+ test("pushes of dash-prefixed ref names are refused", () => {
48+ push("HEAD:refs/heads/main");
49+ const declined = (e: { stdout?: string }) => /pre-receive hook declined/.test(e.stdout ?? "");
50+ assert.throws(() => push(`HEAD:refs/heads/${evilBranch}`), declined);
51+ assert.throws(() => push("HEAD:refs/tags/-x"), declined);
52+ assert.equal(sh(repo, "for-each-ref", "--format=%(refname)").trim(), "refs/heads/main");
53+ });
54+
55+ test("dash-prefixed refs that already exist are ignored and never reach git as options", async () => {
56+ // Simulate a branch pushed before the hook existed.
57+ sh(repo, "update-ref", `refs/heads/${evilBranch}`, "refs/heads/main");
58+ sh(repo, "symbolic-ref", "HEAD", `refs/heads/${evilBranch}`);
59+
60+ assert.deepEqual((await git.listBranches(repo)).map((b) => b.name), ["main"]);
61+ assert.equal(await git.defaultBranch(repo), "main");
62+ assert.equal(await git.resolveRefAndPath(repo, [evilBranch]), null);
63+
64+ await assert.rejects(git.log(repo, evilBranch), git.GitError);
65+ await assert.rejects(git.commitCount(repo, evilBranch), git.GitError);
66+ await assert.rejects(git.listContributors(repo, evilBranch), git.GitError);
67+ await assert.rejects(git.listTree(repo, evilBranch, ""), git.GitError);
68+ assert.equal(existsSync(target), false);
69+
70+ // Normal refs still work.
71+ assert.equal((await git.log(repo, "main"))[0].subject, "ssh-ed25519 AAAA attacker");
72+ assert.equal(await git.commitCount(repo, "main"), 1);
73+ });
+75−19lib/git.tsView File
@@ -1,7 +1,8 @@
11 import { execFile, spawn } from "node:child_process";
2+ import { mkdirSync, writeFileSync } from "node:fs";
23 import { access } from "node:fs/promises";
34 import path from "node:path";
4- import { REPOS_ROOT } from "./paths";
5+ import { DATA_ROOT, REPOS_ROOT } from "./paths";
56
67 // Note: this module is also used by the SSH server (outside of React), so it
78 // must not import "server-only" or anything from next/*.
@@ -41,6 +42,21 @@ export async function pathExists(p: string) {
4142
4243 export class GitError extends Error {}
4344
45+ /**
46+ * Ref names may start with "-" (e.g. a pushed branch named "--output=/etc/x"),
47+ * and git would parse such a revision as an option. Every revision argument
48+ * goes through this and follows "--end-of-options". The check is needed on top
49+ * of the marker: `git shortlog` consumes the marker itself and still parses a
50+ * following "--output=…" as an option.
51+ */
52+ function rev(revision: string) {
53+ if (revision.startsWith("-")) throw new GitError(`Invalid revision: ${revision}`);
54+ return revision;
55+ }
56+
57+ /** Ref names whose short form starts with "-" are refused on push and ignored when reading. */
58+ const isSafeRefName = (name: string) => !name.startsWith("-");
59+
4460 type GitOptions = { env?: Record<string, string>; input?: string | Buffer; maxBuffer?: number };
4561
4662 function run<T extends "utf8" | "buffer">(dir: string, args: string[], encoding: T, opts: GitOptions) {
@@ -88,14 +104,17 @@ export async function lastActivity(dir: string) {
88104 }
89105
90106 export async function isBranch(dir: string, name: string) {
91- return git(dir, ["show-ref", "--verify", "--quiet", `refs/heads/${name}`]).then(
107+ return git(dir, ["show-ref", "--verify", "--quiet", "--end-of-options", `refs/heads/${name}`]).then(
92108 () => true,
93109 () => false,
94110 );
95111 }
96112
97- export async function revParse(dir: string, rev: string) {
98- return (await git(dir, ["rev-parse", "--verify", "--quiet", `${rev}^{commit}`]).catch(() => "")).trim() || null;
113+ export async function revParse(dir: string, revision: string) {
114+ const out = await git(dir, ["rev-parse", "--verify", "--quiet", "--end-of-options", `${rev(revision)}^{commit}`]).catch(
115+ () => "",
116+ );
117+ return out.trim() || null;
99118 }
100119
101120 // ---------------------------------------------------------------------------
@@ -116,12 +135,13 @@ export async function listBranches(dir: string): Promise<Branch[]> {
116135 .map((line) => {
117136 const [name, sha, date, subject] = line.split("\x1f");
118137 return { name, sha, date: Number(date) * 1000, subject };
119- });
138+ })
139+ .filter((b) => isSafeRefName(b.name));
120140 }
121141
122142 export async function listTags(dir: string) {
123143 const out = await git(dir, ["for-each-ref", "--format=%(refname:short)", "refs/tags"]);
124- return out.split("\n").filter(Boolean);
144+ return out.split("\n").filter((name) => name && isSafeRefName(name));
125145 }
126146
127147 /** The branch HEAD points to, or the first existing branch if HEAD dangles. */
@@ -151,7 +171,7 @@ export async function resolveRefAndPath(dir: string, segments: string[]) {
151171 }
152172
153173 async function commitExists(dir: string, ref: string) {
154- return git(dir, ["cat-file", "-e", `${ref}^{commit}`]).then(
174+ return git(dir, ["cat-file", "-e", "--end-of-options", `${rev(ref)}^{commit}`]).then(
155175 () => true,
156176 () => false,
157177 );
@@ -170,11 +190,11 @@ export type TreeEntry = {
170190 };
171191
172192 export async function objectType(dir: string, ref: string, filePath: string) {
173- return (await git(dir, ["cat-file", "-t", `${ref}:${filePath}`]).catch(() => "")).trim() || null;
193+ return (await git(dir, ["cat-file", "-t", "--end-of-options", `${rev(ref)}:${filePath}`]).catch(() => "")).trim() || null;
174194 }
175195
176196 export async function listTree(dir: string, ref: string, treePath: string): Promise<TreeEntry[]> {
177- const out = await git(dir, ["ls-tree", "-z", "-l", `${ref}:${treePath}`]);
197+ const out = await git(dir, ["ls-tree", "-z", "-l", "--end-of-options", `${rev(ref)}:${treePath}`]);
178198 const entries = out
179199 .split("\0")
180200 .filter(Boolean)
@@ -199,7 +219,7 @@ export async function listTree(dir: string, ref: string, treePath: string): Prom
199219
200220 /** Every file in the tree at `ref`, with its size, for language statistics. */
201221 export async function listFilesWithSizes(dir: string, ref: string) {
202- const out = await git(dir, ["ls-tree", "-r", "-z", "-l", ref]);
222+ const out = await git(dir, ["ls-tree", "-r", "-z", "-l", "--end-of-options", rev(ref)]);
203223 return out
204224 .split("\0")
205225 .filter(Boolean)
@@ -211,9 +231,11 @@ export async function listFilesWithSizes(dir: string, ref: string) {
211231 }
212232
213233 export async function readBlob(dir: string, ref: string, filePath: string) {
214- const size = Number((await git(dir, ["cat-file", "-s", `${ref}:${filePath}`])).trim());
234+ const size = Number((await git(dir, ["cat-file", "-s", "--end-of-options", `${rev(ref)}:${filePath}`])).trim());
215235 const tooLarge = size > 2 * 1024 * 1024;
216- const content = tooLarge ? Buffer.alloc(0) : await gitBuffer(dir, ["cat-file", "blob", `${ref}:${filePath}`]);
236+ const content = tooLarge
237+ ? Buffer.alloc(0)
238+ : await gitBuffer(dir, ["cat-file", "blob", "--end-of-options", `${rev(ref)}:${filePath}`]);
217239 const binary = content.subarray(0, 8000).includes(0);
218240 return { size, tooLarge, binary, content };
219241 }
@@ -262,7 +284,8 @@ export async function log(
262284 `--format=${COMMIT_FORMAT}`,
263285 `-n${opts.limit ?? 30}`,
264286 `--skip=${opts.skip ?? 0}`,
265- ref,
287+ "--end-of-options",
288+ rev(ref),
266289 "--",
267290 ];
268291 if (opts.path) args.push(opts.path);
@@ -274,14 +297,14 @@ export async function lastCommit(dir: string, ref: string, filePath?: string) {
274297 }
275298
276299 export async function commitCount(dir: string, ref: string) {
277- return Number((await git(dir, ["rev-list", "--count", ref])).trim());
300+ return Number((await git(dir, ["rev-list", "--count", "--end-of-options", rev(ref)])).trim());
278301 }
279302
280303 export type Contributor = { name: string; email: string; commits: number };
281304
282305 /** Commit authors reachable from `ref`, most active first. */
283306 export async function listContributors(dir: string, ref: string): Promise<Contributor[]> {
284- const out = await git(dir, ["shortlog", "-sne", ref, "--"]);
307+ const out = await git(dir, ["shortlog", "-sne", "--end-of-options", rev(ref), "--"]);
285308 return out
286309 .split("\n")
287310 .map((line) => line.match(/^\s*(\d+)\t(.*?)\s*<([^>]*)>$/))
@@ -291,7 +314,7 @@ export async function listContributors(dir: string, ref: string): Promise<Contri
291314
292315 export async function getCommit(dir: string, sha: string) {
293316 if (!/^[0-9a-f]{4,40}$/i.test(sha)) return null;
294- const out = await git(dir, ["show", "-s", `--format=${COMMIT_FORMAT}`, sha, "--"]).catch(() => null);
317+ const out = await git(dir, ["show", "-s", `--format=${COMMIT_FORMAT}`, "--end-of-options", sha, "--"]).catch(() => null);
295318 return out ? (parseCommits(out)[0] ?? null) : null;
296319 }
297320
@@ -312,7 +335,7 @@ export type FileDiff = {
312335 export async function diffCommit(dir: string, commit: Commit) {
313336 // Diff against the first parent, or the empty tree for root commits.
314337 const base = commit.parents[0] ?? (await git(dir, ["hash-object", "-t", "tree", "/dev/null"])).trim();
315- const out = await git(dir, ["diff", "--no-color", "-M", "--no-ext-diff", base, commit.sha]);
338+ const out = await git(dir, ["diff", "--no-color", "-M", "--no-ext-diff", "--end-of-options", rev(base), rev(commit.sha)]);
316339 return parseDiff(out);
317340 }
318341
@@ -369,12 +392,45 @@ export function parseDiff(out: string): FileDiff[] {
369392
370393 export type GitService = "git-upload-pack" | "git-receive-pack";
371394
395+ const HOOKS_DIR = path.join(DATA_ROOT, "hooks");
396+
397+ // Receives "<old> <new> <ref>" lines; refuses refs with a path component
398+ // starting with "-", which git commands could mistake for options.
399+ const PRE_RECEIVE_HOOK = `#!/bin/sh
400+ status=0
401+ while read -r old new ref; do
402+ case "/$ref" in
403+ */-*) echo "Vectrum: refusing $ref (ref name components must not start with '-')" >&2; status=1 ;;
404+ esac
405+ done
406+ exit $status
407+ `;
408+
409+ let hooksInstalled = false;
410+
411+ /** Server-side hooks for every repository, (re)written once per process so updates apply. */
412+ function hooksDir() {
413+ if (!hooksInstalled) {
414+ mkdirSync(HOOKS_DIR, { recursive: true });
415+ writeFileSync(path.join(HOOKS_DIR, "pre-receive"), PRE_RECEIVE_HOOK, { mode: 0o755 });
416+ hooksInstalled = true;
417+ }
418+ return HOOKS_DIR;
419+ }
420+
421+ /**
422+ * Starts upload-pack or receive-pack for a transport. HTTP uses stateless RPC;
423+ * SSH streams a whole session. Pushes always run Vectrum's hooks, whatever the
424+ * repository's own hook configuration.
425+ */
372426 export function spawnService(
373427 service: GitService,
374428 dir: string,
375- opts: { advertise?: boolean; protocol?: string | null } = {},
429+ opts: { stateless?: boolean; advertise?: boolean; protocol?: string | null } = {},
376430 ) {
377- const args = [service.slice(4), "--stateless-rpc"];
431+ const args = service === "git-receive-pack" ? ["-c", `core.hooksPath=${hooksDir()}`] : [];
432+ args.push(service.slice(4));
433+ if (opts.stateless ?? true) args.push("--stateless-rpc");
378434 if (opts.advertise) args.push("--advertise-refs");
379435 args.push(dir);
380436 return spawn("git", args, {
+3−5lib/repo.tsView File
@@ -1,10 +1,10 @@
11 import "server-only";
22 import { cache } from "react";
3- import { headers } from "next/headers";
43 import { forbidden, notFound } from "next/navigation";
54 import { getViewer } from "./auth";
65 import { defaultBranch, isBranch, normalizeRepoName, resolveRefAndPath } from "./git";
76 import { canWrite, findRepo, repoAccess, type RepoRecord } from "./repos";
7+ import { serverHost, serverOrigin } from "./request-origin";
88 import { sshCloneUrl } from "./ssh-config";
99
1010 /**
@@ -59,11 +59,9 @@ export async function getBranchAndPath(repo: Repo, segments: string[]) {
5959 }
6060
6161 export async function cloneUrls(repo: RepoRecord) {
62- const h = await headers();
63- const host = h.get("x-forwarded-host") ?? h.get("host") ?? "localhost:3000";
64- const proto = h.get("x-forwarded-proto") ?? "http";
62+ const [origin, host] = await Promise.all([serverOrigin(), serverHost()]);
6563 return {
66- https: `${proto}://${host}/${repo.owner}/${repo.name}.git`,
64+ https: `${origin}/${repo.owner}/${repo.name}.git`,
6765 ssh: sshCloneUrl(host, `${repo.owner}/${repo.name}.git`),
6866 };
6967 }
+21−0lib/request-origin.tsaddedView File
@@ -0,0 +1,21 @@
1+ import "server-only";
2+ import { headers } from "next/headers";
3+
4+ // Host and X-Forwarded-* headers are client-controlled, so the canonical
5+ // address comes from configuration. Without PUBLIC_URL (local development),
6+ // URLs fall back to the request's Host header over plain HTTP.
7+ const PUBLIC_URL = process.env.PUBLIC_URL ? new URL(process.env.PUBLIC_URL) : null;
8+
9+ /** Session cookies are marked Secure when the instance is served over HTTPS (or SECURE_COOKIES=1). */
10+ export const SECURE_COOKIES = process.env.SECURE_COOKIES === "1" || PUBLIC_URL?.protocol === "https:";
11+
12+ /** The address users reach this server at, e.g. https://vectrum.cc. */
13+ export async function serverOrigin() {
14+ if (PUBLIC_URL) return PUBLIC_URL.origin;
15+ return `http://${(await headers()).get("host") ?? "localhost:3000"}`;
16+ }
17+
18+ /** Host (and port) part of serverOrigin(), e.g. vectrum.cc. */
19+ export async function serverHost() {
20+ return new URL(await serverOrigin()).host;
21+ }
+5−3lib/ssh-server.tsView File
@@ -1,8 +1,7 @@
1- import { spawn } from "node:child_process";
21 import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
32 import path from "node:path";
43 import { Server, utils, type Connection } from "ssh2";
5- import { normalizeRepoName } from "./git";
4+ import { normalizeRepoName, spawnService } from "./git";
65 import { findKeyByBlob, touchSshKey } from "./keys";
76 import { DATA_ROOT } from "./paths";
87 import { canWrite, findRepo, markPushed, repoAccess } from "./repos";
@@ -83,7 +82,10 @@ function handleConnection(client: Connection) {
8382 return fail(`Permission to ${repo.owner}/${repo.name} denied to ${user.username}.`);
8483 }
8584
86- const child = spawn("git", [service, repo.dir], { env: { ...process.env, ...env } });
85+ const child = spawnService(pushing ? "git-receive-pack" : "git-upload-pack", repo.dir, {
86+ stateless: false,
87+ protocol: env.GIT_PROTOCOL,
88+ });
8789 channel.pipe(child.stdin);
8890 child.stdout.pipe(channel, { end: false });
8991 child.stderr.pipe(channel.stderr, { end: false });
+6−2package.jsonView File
@@ -6,7 +6,8 @@
66 "dev": "next dev",
77 "build": "next build",
88 "start": "next start",
9- "lint": "eslint"
9+ "lint": "eslint",
10+ "test": "bun test"
1011 },
1112 "dependencies": {
1213 "@codemirror/language": "^6.12.4",
@@ -46,5 +47,8 @@
4647 "trustedDependencies": [
4748 "sharp",
4849 "unrs-resolver"
49- ]
50+ ],
51+ "patchedDependencies": {
52+ "ssh2@1.17.0": "patches/ssh2@1.17.0.patch"
53+ }
5054 }
+283−0patches/ssh2@1.17.0.patchaddedView File
@@ -0,0 +1,283 @@
1+ diff --git a/lib/protocol/constants.js b/lib/protocol/constants.js
2+ index ad7759257d6bdbcafa353dec8946258d64c14eb0..6fb70b8720f6d7cf8ae3692a0fa14fbeb35eb16f 100644
3+ --- a/lib/protocol/constants.js
4+ +++ b/lib/protocol/constants.js
5+ @@ -32,6 +32,29 @@ const curve25519Supported = (typeof crypto.diffieHellman === 'function'
6+ && typeof crypto.generateKeyPairSync === 'function'
7+ && typeof crypto.createPublicKey === 'function');
8+
9+ +const mlkemSupported = (() => {
10+ + try {
11+ + if (!curve25519Supported
12+ + || typeof crypto.encapsulate !== 'function'
13+ + || typeof crypto.decapsulate !== 'function') {
14+ + return false;
15+ + }
16+ + const keys = crypto.generateKeyPairSync('ml-kem-768');
17+ + const { ciphertext } = crypto.encapsulate(
18+ + keys.publicKey
19+ + );
20+ +
21+ + crypto.decapsulate(
22+ + keys.privateKey,
23+ + ciphertext
24+ + );
25+ +
26+ + return true;
27+ + } catch {
28+ + return false;
29+ + }
30+ +})();
31+ +
32+ const DEFAULT_KEX = [
33+ // https://tools.ietf.org/html/rfc5656#section-10.1
34+ 'ecdh-sha2-nistp256',
35+ @@ -52,6 +75,11 @@ if (curve25519Supported) {
36+ DEFAULT_KEX.unshift('curve25519-sha256');
37+ DEFAULT_KEX.unshift('curve25519-sha256@libssh.org');
38+ }
39+ +
40+ +if (mlkemSupported)
41+ + DEFAULT_KEX.unshift('mlkem768x25519-sha256');
42+ +
43+ +
44+ const SUPPORTED_KEX = DEFAULT_KEX.concat([
45+ // https://tools.ietf.org/html/rfc4419#section-4
46+ 'diffie-hellman-group-exchange-sha1',
47+ @@ -189,6 +217,9 @@ module.exports = {
48+ KEXECDH_INIT: 30,
49+ KEXECDH_REPLY: 31,
50+
51+ + KEX_HYBRID_INIT: 30,
52+ + KEX_HYBRID_REPLY: 31,
53+ +
54+ // User auth protocol -- generic (50-59)
55+ USERAUTH_REQUEST: 50,
56+ USERAUTH_FAILURE: 51,
57+ @@ -349,6 +380,7 @@ module.exports = {
58+
59+ curve25519Supported,
60+ eddsaSupported,
61+ + mlkemSupported,
62+ };
63+
64+ module.exports.DISCONNECT_REASON_BY_VALUE =
65+ diff --git a/lib/protocol/kex.js b/lib/protocol/kex.js
66+ index 811e631b8ab78d32776e540919d25aae56d628f7..75252c5632c55ae7b9f86cc58011a64254742a2e 100644
67+ --- a/lib/protocol/kex.js
68+ +++ b/lib/protocol/kex.js
69+ @@ -6,7 +6,9 @@ const {
70+ createECDH,
71+ createHash,
72+ createPublicKey,
73+ + decapsulate,
74+ diffieHellman,
75+ + encapsulate,
76+ generateKeyPairSync,
77+ randomFillSync,
78+ } = require('crypto');
79+ @@ -23,6 +25,7 @@ const {
80+ DEFAULT_COMPRESSION,
81+ DISCONNECT_REASON,
82+ MESSAGE,
83+ + mlkemSupported,
84+ } = require('./constants.js');
85+ const {
86+ CIPHER_INFO,
87+ @@ -1580,6 +1583,184 @@ const createKeyExchange = (() => {
88+ }
89+ }
90+
91+ + class MLKEMx25519Exchange extends KeyExchange {
92+ + // NOTE: based on the following draft
93+ + // https://datatracker.ietf.org/doc/html/draft-ietf-sshm-mlkem-hybrid-kex-03.html
94+ + constructor(hashName, ...args) {
95+ + super(...args);
96+ +
97+ + this.type = 'mlkem768x25519';
98+ + this.hashName = hashName;
99+ +
100+ + this._x25519Keys = null;
101+ + this._mlkemKeys = null;
102+ +
103+ + this._S_CT2 = null; // needed for server
104+ + }
105+ +
106+ + generateKeys() {
107+ + if (!this._x25519Keys)
108+ + this._x25519Keys = generateKeyPairSync('x25519');
109+ +
110+ + if (!this._mlkemKeys)
111+ + this._mlkemKeys = generateKeyPairSync('ml-kem-768');
112+ + }
113+ +
114+ + convertPublicKey(key) {
115+ + // We need to override this because the key
116+ + // is not an mpint (compare section 2.1 on the draft
117+ + // to section 8 on rfc4253)
118+ + return key;
119+ + }
120+ +
121+ + getPublicKey() {
122+ + this.generateKeys();
123+ +
124+ + const isServer = this._protocol._server;
125+ +
126+ + if (!isServer) {
127+ + // client
128+ + const C_PK1 = this._x25519Keys.publicKey
129+ + .export({ type: 'spki', format: 'der' })
130+ + .slice(-32);
131+ +
132+ + const C_PK2 = this._mlkemKeys.publicKey
133+ + .export({ type: 'spki', format: 'der' })
134+ + .slice(-1184);
135+ +
136+ + return Buffer.concat([C_PK2, C_PK1]);
137+ + }
138+ +
139+ + // server
140+ +
141+ + // NOTE: S_CT2 should already have been
142+ + // calculated by now, since client is the
143+ + // one that initiates the kex
144+ + const S_PK1 = this._x25519Keys.publicKey
145+ + .export({ type: 'spki', format: 'der' })
146+ + .slice(-32);
147+ +
148+ + return Buffer.concat([this._S_CT2, S_PK1]);
149+ + }
150+ +
151+ + buildX25519SPKI(key) {
152+ + const asnWriter = new Ber.Writer();
153+ + asnWriter.startSequence();
154+ + // algorithm
155+ + asnWriter.startSequence();
156+ + asnWriter.writeOID('1.3.101.110'); // id-X25519
157+ + asnWriter.endSequence();
158+ +
159+ + // PublicKey
160+ + asnWriter.startSequence(Ber.BitString);
161+ + asnWriter.writeByte(0x00);
162+ + // XXX: hack to write a raw buffer without a tag -- yuck
163+ + asnWriter._ensure(key.length);
164+ + key.copy(
165+ + asnWriter._buf,
166+ + asnWriter._offset,
167+ + 0,
168+ + key.length
169+ + );
170+ + asnWriter._offset += key.length;
171+ + asnWriter.endSequence();
172+ + asnWriter.endSequence();
173+ +
174+ + return asnWriter;
175+ + }
176+ +
177+ + computeSecret(otherPublicKey) {
178+ + try {
179+ + this.generateKeys();
180+ + const isServer = this._protocol._server;
181+ + if (isServer && !this._S_CT2) {
182+ + // server
183+ + const C_PK2 = otherPublicKey.slice(0, 1184);
184+ + const C_PK1 = otherPublicKey.slice(1184);
185+ +
186+ + if (C_PK1.length !== 32) {
187+ + const got = C_PK1.length;
188+ + throw new Error(
189+ + `Invalid C_PK1 length: expected 32, got ${got}`
190+ + );
191+ + }
192+ +
193+ + const x25519SPKI = this.buildX25519SPKI(C_PK1);
194+ +
195+ + const K_CL = diffieHellman({
196+ + privateKey: this._x25519Keys.privateKey,
197+ + publicKey: createPublicKey({
198+ + key: x25519SPKI.buffer,
199+ + type: 'spki',
200+ + format: 'der',
201+ + }),
202+ + });
203+ +
204+ + const templateDER = this._mlkemKeys.publicKey
205+ + .export({ type: 'spki', format: 'der' });
206+ +
207+ + const C_PK2_DER = Buffer.allocUnsafe(templateDER.length);
208+ +
209+ + templateDER.copy(C_PK2_DER, 0, 0, templateDER.length - 1184);
210+ +
211+ + C_PK2.copy(C_PK2_DER, templateDER.length - 1184);
212+ +
213+ + const { sharedKey: K_PQ, ciphertext: S_CT2 } = encapsulate(
214+ + createPublicKey({
215+ + key: C_PK2_DER,
216+ + type: 'spki',
217+ + format: 'der',
218+ + })
219+ + );
220+ +
221+ + this._S_CT2 = S_CT2;
222+ +
223+ + // K = HASH(K_PQ || K_CL) from 2.4 on draft
224+ + const K = createHash('sha256')
225+ + .update(K_PQ)
226+ + .update(K_CL)
227+ + .digest();
228+ +
229+ + return K;
230+ + }
231+ + // client
232+ + const S_CT2 = otherPublicKey.slice(0, 1088);
233+ + const S_PK1 = otherPublicKey.slice(1088);
234+ +
235+ + if (S_PK1.length !== 32) {
236+ + const got = S_PK1.length;
237+ + throw new Error(
238+ + `Invalid S_PK1 length: expected 32, got ${got}`
239+ + );
240+ + }
241+ +
242+ + const x25519SPKI = this.buildX25519SPKI(S_PK1);
243+ +
244+ + const K_CL = diffieHellman({
245+ + privateKey: this._x25519Keys.privateKey,
246+ + publicKey: createPublicKey({
247+ + key: x25519SPKI.buffer,
248+ + type: 'spki',
249+ + format: 'der',
250+ + }),
251+ + });
252+ +
253+ + const K_PQ = decapsulate(this._mlkemKeys.privateKey, S_CT2);
254+ +
255+ + // K = HASH(K_PQ || K_CL) from 2.4 on draft
256+ + const K = createHash('sha256')
257+ + .update(K_PQ)
258+ + .update(K_CL)
259+ + .digest();
260+ +
261+ + return K;
262+ +
263+ + } catch (error) {
264+ + return error;
265+ + }
266+ + }
267+ + }
268+ +
269+ return (negotiated, ...args) => {
270+ if (typeof negotiated !== 'object' || negotiated === null)
271+ throw new Error('Invalid negotiated argument');
272+ @@ -1587,6 +1768,11 @@ const createKeyExchange = (() => {
273+ if (typeof kexType === 'string') {
274+ args = [negotiated, ...args];
275+ switch (kexType) {
276+ + case 'mlkem768x25519-sha256':
277+ + if (!mlkemSupported)
278+ + break;
279+ + return new MLKEMx25519Exchange('sha256', ...args);
280+ +
281+ case 'curve25519-sha256':
282+ case 'curve25519-sha256@libssh.org':
283+ if (!curve25519Supported)