Vectrum
Sign In

Merge the git argument-injection and header-trust fixes

Brings t3code/fix-security-report-issues onto the PocketBase and Actions
code: the SSH server runs receive-pack through spawnService (so pushes
get the pre-receive hook) while still starting workflows, session
cookies and image names use the configured origin, and the runner API
hands runners PUBLIC_URL instead of trusting X-Forwarded-* headers.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
JjohannSocommitted 2 parents 812d374 47dff0ecommitb6bb51e

Showing 19 changed files with 491 additions and 66 deletions.

Merge commit — showing changes against the first parent.

+7−3README.mdView File
@@ -28,7 +28,7 @@ git repositories on disk, and you push to them with plain git over **SSH** or
2828
2929 ## Getting started
3030
31- Requires Node.js 22+ and `git` on the `PATH`.
31+ Requires Node.js 22+ (24.7+ for post-quantum SSH key exchange) and `git` on the `PATH`.
3232
3333 ```bash
3434 bun install # also downloads the PocketBase server into bin/
@@ -51,7 +51,7 @@ The image runs the web UI on port 3000 and the SSH server on port 2222, with all
5151 docker compose exec -it vectrum node scripts/create-user.mjs <username> <email> [name]
5252 ```
5353
54- It asks for the password (or reads `VECTRUM_PASSWORD`). Outside Docker, run `node scripts/create-user.mjs` the same way while Vectrum runs. Clone URLs show `SSH_PORT`, so publish SSH on the same port on the host, and set `SSH_HOST` to your domain. Put a TLS reverse proxy in front of port 3000 if the server is reachable from the internet.
54+ It asks for the password (or reads `VECTRUM_PASSWORD`). Outside Docker, run `node scripts/create-user.mjs` the same way while Vectrum runs. Clone URLs show `SSH_PORT`, so publish SSH on the same port on the host, and set `SSH_HOST` to your domain. Put a TLS reverse proxy in front of port 3000 if the server is reachable from the internet, and set `PUBLIC_URL` to its address.
5555
5656 ### Pushing over SSH
5757
@@ -125,6 +125,8 @@ Runners poll the server over HTTPS (`/api/runner/*`), so they need no inbound po
125125
126126 | Variable | Default | Purpose |
127127 | ----------------- | ----------- | --------------------------------------------------------- |
128+ | `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 |
129+ | `SECURE_COOKIES` | — | Set to `1` to mark session cookies `Secure` without `PUBLIC_URL` |
128130 | `DATA_ROOT` | `./data` | Where repositories, the database and the SSH host key live |
129131 | `PB_PORT` | `8090` | Port of the PocketBase server Vectrum runs |
130132 | `PB_URL` | — | Use this PocketBase server instead of running one |
@@ -143,6 +145,7 @@ data/
143145 pb_data/ PocketBase: users, keys, tokens, repos, issues, workflow runs and logs
144146 pocketbase-superuser.json the generated PocketBase superuser login
145147 ssh_host_ed25519_key generated on first start
148+ hooks/ server-side git hooks for every push, rewritten on start
146149 repos/<owner>/<repo>.git bare git repositories
147150 registry.db container image manifests, tags and blob records (SQLite)
148151 registry/blobs/sha256/… container image layers, shared by all repositories
@@ -156,7 +159,8 @@ kept so pushes in progress don't lose their layers).
156159
157160 ## Security notes
158161
159- - Serve the web UI over HTTPS if it's reachable beyond localhost. Session cookies become `Secure` when `X-Forwarded-Proto: https` is set.
162+ - 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.
163+ - 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`.
160164 - Private repositories show a 404 page to anyone without access, so their existence isn't revealed.
161165 - Raw files are served with `nosniff` and a sandboxing CSP, so repository HTML/SVG can't run scripts in the app's origin.
162166
+2−1app/actions/files.tsView File
@@ -14,7 +14,7 @@ const str = (formData: FormData, key: string) => String(formData.get(key) ?? "")
1414
1515 /** Blob sha of a path at a commit, or null if it doesn't exist there. */
1616 async function blobAt(dir: string, commit: string, path: string) {
17- return (await git(dir, ["rev-parse", "--verify", "--quiet", `${commit}:${path}`]).catch(() => "")).trim() || null;
17+ return (await git(dir, ["rev-parse", "--verify", "--quiet", "--end-of-options", `${commit}:${path}`]).catch(() => "")).trim() || null;
1818 }
1919
2020 /**
@@ -33,6 +33,7 @@ async function commitFromForm(
3333 ) {
3434 const branch = str(formData, "branch");
3535 const baseSha = str(formData, "baseSha");
36+ if (baseSha && !/^[0-9a-f]{40,64}$/.test(baseSha)) throw new ActionError("Invalid base commit.");
3637 const head = await revParse(repo.dir, `refs/heads/${branch}`);
3738
3839 if (baseSha) {
+3−2app/api/runner/claim/route.tsView File
@@ -1,4 +1,5 @@
1- import { json, originOf, readBody, runnerFromRequest } from "@/lib/runner-api";
1+ import { serverOrigin } from "@/lib/request-origin";
2+ import { json, readBody, runnerFromRequest } from "@/lib/runner-api";
23 import { touchRunner } from "@/lib/runners";
34 import { claimJob } from "@/lib/runs";
45
@@ -14,7 +15,7 @@ const sleep = (ms: number, signal: AbortSignal) =>
1415 /** A runner asking for work: answers with a job as soon as one it can run is queued (long polling). */
1516 export async function POST(request: Request) {
1617 const info = await readBody(request);
17- const origin = originOf(request);
18+ const origin = await serverOrigin();
1819 const deadline = Date.now() + MAX_WAIT_MS;
1920
2021 for (;;) {
+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,6 +23,7 @@ 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 { SIGNUPS_ENABLED } from "@/lib/signups";
2828 import { sshCloneUrl } from "@/lib/ssh-config";
2929 import type { User } from "@/lib/users";
@@ -31,8 +31,7 @@ import type { User } from "@/lib/users";
3131 export default async function Home() {
3232 const viewer = await getViewer();
3333 if (viewer) return <Dashboard viewer={viewer} />;
34- const host = (await headers()).get("host") ?? "localhost:3000";
35- return <Landing sshUrl={sshCloneUrl(host, "you/project.git")} />;
34+ return <Landing sshUrl={sshCloneUrl(await serverHost(), "you/project.git")} />;
3635 }
3736
3837 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 = await 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";
@@ -11,6 +10,7 @@ import { Blankslate, Label, PageTitle, RefChip } from "@/components/ui";
1110 import { requireViewer } from "@/lib/auth";
1211 import { timeAgo } from "@/lib/format";
1312 import { canWrite, listReposForUser, repoAccess } from "@/lib/repos";
13+ import { serverOrigin } from "@/lib/request-origin";
1414 import { listTokens, TOKEN_PREFIX } from "@/lib/tokens";
1515 import { NewTokenForm } from "./form";
1616
@@ -26,8 +26,7 @@ export default async function TokensPage() {
2626 writable: canWrite(await repoAccess(r, viewer.id)),
2727 })),
2828 );
29- const h = await headers();
30- const origin = `${h.get("x-forwarded-proto") ?? "http"}://${h.get("host") ?? "localhost:3000"}`;
29+ const origin = await serverOrigin();
3130
3231 return (
3332 <div>
+3−0bun.lockView File
@@ -42,6 +42,9 @@
4242 "sharp",
4343 "unrs-resolver",
4444 ],
45+ "patchedDependencies": {
46+ "ssh2@1.17.0": "patches/ssh2@1.17.0.patch",
47+ },
4548 "packages": {
4649 "@alloc/quick-lru": ["@alloc/quick-lru@5.3.0", "", {}, "sha512-U4+70Pc5ZS9osnCBCE5Jha/ciHM+Yp+CNMNC/7HvYbNRk1Ldd+f7qO65W5qfhu/TCv+/ozljlXXe9Nj8419DMA=="],
4750
+2−0docker-compose.ymlView File
@@ -10,6 +10,8 @@ services:
1010 # Close registration: the sign-up page and button go away, existing users can still sign in.
1111 # Create accounts with: docker compose exec -it vectrum node scripts/create-user.mjs <username> <email>
1212 SIGNUPS_DISABLED: "0"
13+ # The address users reach Vectrum at, for clone URLs; https also makes session cookies Secure.
14+ # PUBLIC_URL: https://vectrum.example.com
1315 # Host name shown in SSH clone URLs (defaults to the host the web UI was reached on).
1416 # SSH_HOST: vectrum.example.com
1517 # Set to 1 to turn off the built-in SSH server (and remove the 2222 port above).
+3−3lib/auth.tsView File
@@ -1,9 +1,10 @@
11 import "server-only";
22 import { cache } from "react";
3- import { cookies, headers } from "next/headers";
3+ import { cookies } from "next/headers";
44 import { redirect } from "next/navigation";
55 import { ClientResponseError, getTokenPayload, isTokenExpired } from "pocketbase";
66 import { client, db } from "./db";
7+ import { SECURE_COOKIES } from "./request-origin";
78 import { authenticateUser, toUser } from "./users";
89
910 // Sessions are PocketBase auth tokens, kept in an httpOnly cookie. They expire after 30 days (the
@@ -13,11 +14,10 @@ const SESSION_COOKIE = "session";
1314 const SESSION_SECONDS = 30 * 86400;
1415
1516 async function setSessionCookie(token: string) {
16- const h = await headers();
1717 (await cookies()).set(SESSION_COOKIE, token, {
1818 httpOnly: true,
1919 sameSite: "lax",
20- secure: h.get("x-forwarded-proto") === "https",
20+ secure: SECURE_COOKIES,
2121 path: "/",
2222 expires: new Date(getTokenPayload(token).exp * 1000),
2323 });
+1−1lib/ci.tsView File
@@ -49,7 +49,7 @@ async function start(repo: RepoRecord, file: WorkflowFile, run: { event: RunEven
4949 /** Files changed between two commits, or null when that isn't known (a new branch). */
5050 async function changedFiles(dir: string, before: string | null, after: string) {
5151 if (!before) return null;
52- const out = await git(dir, ["diff", "--name-only", "-z", before, after]).catch(() => null);
52+ const out = await git(dir, ["diff", "--name-only", "-z", "--end-of-options", before, after]).catch(() => null);
5353 return out === null ? null : out.split("\0").filter(Boolean);
5454 }
5555
+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,7 +135,8 @@ 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 /** Every branch and tag with the commit it points to, to tell what a push changed. */
@@ -132,7 +152,7 @@ export async function snapshotRefs(dir: string) {
132152
133153 export async function listTags(dir: string) {
134154 const out = await git(dir, ["for-each-ref", "--format=%(refname:short)", "refs/tags"]);
135- return out.split("\n").filter(Boolean);
155+ return out.split("\n").filter((name) => name && isSafeRefName(name));
136156 }
137157
138158 /** The branch HEAD points to, or the first existing branch if HEAD dangles. */
@@ -162,7 +182,7 @@ export async function resolveRefAndPath(dir: string, segments: string[]) {
162182 }
163183
164184 async function commitExists(dir: string, ref: string) {
165- return git(dir, ["cat-file", "-e", `${ref}^{commit}`]).then(
185+ return git(dir, ["cat-file", "-e", "--end-of-options", `${rev(ref)}^{commit}`]).then(
166186 () => true,
167187 () => false,
168188 );
@@ -181,11 +201,11 @@ export type TreeEntry = {
181201 };
182202
183203 export async function objectType(dir: string, ref: string, filePath: string) {
184- return (await git(dir, ["cat-file", "-t", `${ref}:${filePath}`]).catch(() => "")).trim() || null;
204+ return (await git(dir, ["cat-file", "-t", "--end-of-options", `${rev(ref)}:${filePath}`]).catch(() => "")).trim() || null;
185205 }
186206
187207 export async function listTree(dir: string, ref: string, treePath: string): Promise<TreeEntry[]> {
188- const out = await git(dir, ["ls-tree", "-z", "-l", `${ref}:${treePath}`]);
208+ const out = await git(dir, ["ls-tree", "-z", "-l", "--end-of-options", `${rev(ref)}:${treePath}`]);
189209 const entries = out
190210 .split("\0")
191211 .filter(Boolean)
@@ -210,7 +230,7 @@ export async function listTree(dir: string, ref: string, treePath: string): Prom
210230
211231 /** Every file in the tree at `ref`, with its size, for language statistics. */
212232 export async function listFilesWithSizes(dir: string, ref: string) {
213- const out = await git(dir, ["ls-tree", "-r", "-z", "-l", ref]);
233+ const out = await git(dir, ["ls-tree", "-r", "-z", "-l", "--end-of-options", rev(ref)]);
214234 return out
215235 .split("\0")
216236 .filter(Boolean)
@@ -222,9 +242,11 @@ export async function listFilesWithSizes(dir: string, ref: string) {
222242 }
223243
224244 export async function readBlob(dir: string, ref: string, filePath: string) {
225- const size = Number((await git(dir, ["cat-file", "-s", `${ref}:${filePath}`])).trim());
245+ const size = Number((await git(dir, ["cat-file", "-s", "--end-of-options", `${rev(ref)}:${filePath}`])).trim());
226246 const tooLarge = size > 2 * 1024 * 1024;
227- const content = tooLarge ? Buffer.alloc(0) : await gitBuffer(dir, ["cat-file", "blob", `${ref}:${filePath}`]);
247+ const content = tooLarge
248+ ? Buffer.alloc(0)
249+ : await gitBuffer(dir, ["cat-file", "blob", "--end-of-options", `${rev(ref)}:${filePath}`]);
228250 const binary = content.subarray(0, 8000).includes(0);
229251 return { size, tooLarge, binary, content };
230252 }
@@ -273,7 +295,8 @@ export async function log(
273295 `--format=${COMMIT_FORMAT}`,
274296 `-n${opts.limit ?? 30}`,
275297 `--skip=${opts.skip ?? 0}`,
276- ref,
298+ "--end-of-options",
299+ rev(ref),
277300 "--",
278301 ];
279302 if (opts.path) args.push(opts.path);
@@ -285,14 +308,14 @@ export async function lastCommit(dir: string, ref: string, filePath?: string) {
285308 }
286309
287310 export async function commitCount(dir: string, ref: string) {
288- return Number((await git(dir, ["rev-list", "--count", ref])).trim());
311+ return Number((await git(dir, ["rev-list", "--count", "--end-of-options", rev(ref)])).trim());
289312 }
290313
291314 export type Contributor = { name: string; email: string; commits: number };
292315
293316 /** Commit authors reachable from `ref`, most active first. */
294317 export async function listContributors(dir: string, ref: string): Promise<Contributor[]> {
295- const out = await git(dir, ["shortlog", "-sne", ref, "--"]);
318+ const out = await git(dir, ["shortlog", "-sne", "--end-of-options", rev(ref), "--"]);
296319 return out
297320 .split("\n")
298321 .map((line) => line.match(/^\s*(\d+)\t(.*?)\s*<([^>]*)>$/))
@@ -302,7 +325,7 @@ export async function listContributors(dir: string, ref: string): Promise<Contri
302325
303326 export async function getCommit(dir: string, sha: string) {
304327 if (!/^[0-9a-f]{4,40}$/i.test(sha)) return null;
305- const out = await git(dir, ["show", "-s", `--format=${COMMIT_FORMAT}`, sha, "--"]).catch(() => null);
328+ const out = await git(dir, ["show", "-s", `--format=${COMMIT_FORMAT}`, "--end-of-options", sha, "--"]).catch(() => null);
306329 return out ? (parseCommits(out)[0] ?? null) : null;
307330 }
308331
@@ -323,7 +346,7 @@ export type FileDiff = {
323346 export async function diffCommit(dir: string, commit: Commit) {
324347 // Diff against the first parent, or the empty tree for root commits.
325348 const base = commit.parents[0] ?? (await git(dir, ["hash-object", "-t", "tree", "/dev/null"])).trim();
326- const out = await git(dir, ["diff", "--no-color", "-M", "--no-ext-diff", base, commit.sha]);
349+ const out = await git(dir, ["diff", "--no-color", "-M", "--no-ext-diff", "--end-of-options", rev(base), rev(commit.sha)]);
327350 return parseDiff(out);
328351 }
329352
@@ -380,12 +403,45 @@ export function parseDiff(out: string): FileDiff[] {
380403
381404 export type GitService = "git-upload-pack" | "git-receive-pack";
382405
406+ const HOOKS_DIR = path.join(DATA_ROOT, "hooks");
407+
408+ // Receives "<old> <new> <ref>" lines; refuses refs with a path component
409+ // starting with "-", which git commands could mistake for options.
410+ const PRE_RECEIVE_HOOK = `#!/bin/sh
411+ status=0
412+ while read -r old new ref; do
413+ case "/$ref" in
414+ */-*) echo "Vectrum: refusing $ref (ref name components must not start with '-')" >&2; status=1 ;;
415+ esac
416+ done
417+ exit $status
418+ `;
419+
420+ let hooksInstalled = false;
421+
422+ /** Server-side hooks for every repository, (re)written once per process so updates apply. */
423+ function hooksDir() {
424+ if (!hooksInstalled) {
425+ mkdirSync(HOOKS_DIR, { recursive: true });
426+ writeFileSync(path.join(HOOKS_DIR, "pre-receive"), PRE_RECEIVE_HOOK, { mode: 0o755 });
427+ hooksInstalled = true;
428+ }
429+ return HOOKS_DIR;
430+ }
431+
432+ /**
433+ * Starts upload-pack or receive-pack for a transport. HTTP uses stateless RPC;
434+ * SSH streams a whole session. Pushes always run Vectrum's hooks, whatever the
435+ * repository's own hook configuration.
436+ */
383437 export function spawnService(
384438 service: GitService,
385439 dir: string,
386- opts: { advertise?: boolean; protocol?: string | null } = {},
440+ opts: { stateless?: boolean; advertise?: boolean; protocol?: string | null } = {},
387441 ) {
388- const args = [service.slice(4), "--stateless-rpc"];
442+ const args = service === "git-receive-pack" ? ["-c", `core.hooksPath=${hooksDir()}`] : [];
443+ args.push(service.slice(4));
444+ if (opts.stateless ?? true) args.push("--stateless-rpc");
389445 if (opts.advertise) args.push("--advertise-refs");
390446 args.push(dir);
391447 return spawn("git", args, {
+4−10lib/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 /**
@@ -58,23 +58,17 @@ export async function getBranchAndPath(repo: Repo, segments: string[]) {
5858 return { branch: resolved.ref, path: resolved.path, exists: true };
5959 }
6060
61- async function requestHost() {
62- const h = await headers();
63- return { host: h.get("x-forwarded-host") ?? h.get("host") ?? "localhost:3000", proto: h.get("x-forwarded-proto") ?? "http" };
64- }
65-
6661 export async function cloneUrls(repo: RepoRecord) {
67- const { host, proto } = await requestHost();
62+ const [origin, host] = await Promise.all([serverOrigin(), serverHost()]);
6863 return {
69- https: `${proto}://${host}/${repo.owner}/${repo.name}.git`,
64+ https: `${origin}/${repo.owner}/${repo.name}.git`,
7065 ssh: sshCloneUrl(host, `${repo.owner}/${repo.name}.git`),
7166 };
7267 }
7368
7469 /** The repository's container image, e.g. vectrum.cc/alice/app. Image names are lowercase. */
7570 export async function imageName(repo: RepoRecord) {
76- const { host } = await requestHost();
77- return `${host}/${repo.owner}/${repo.name}`.toLowerCase();
71+ return `${await serverHost()}/${repo.owner}/${repo.name}`.toLowerCase();
7872 }
7973
8074 export type { RepoRecord };
+16−3lib/request-origin.tsView File
@@ -1,8 +1,21 @@
11 import "server-only";
22 import { headers } from "next/headers";
33
4- /** The address this request reached the server at, e.g. https://vectrum.cc. */
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. */
513 export async function serverOrigin() {
6- const h = await headers();
7- return `${h.get("x-forwarded-proto") ?? "http"}://${h.get("x-forwarded-host") ?? h.get("host") ?? "localhost:3000"}`;
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;
821 }
+0−8lib/runner-api.tsView File
@@ -26,11 +26,3 @@ export async function readBody(request: Request) {
2626 return {};
2727 }
2828 }
29-
30- /** The server's address as the runner reached it, for clone URLs. */
31- export function originOf(request: Request) {
32- const url = new URL(request.url);
33- const host = request.headers.get("x-forwarded-host") ?? request.headers.get("host") ?? url.host;
34- const proto = request.headers.get("x-forwarded-proto") ?? url.protocol.replace(":", "");
35- return `${proto}://${host}`;
36- }
+5−3lib/ssh-server.tsView File
@@ -1,9 +1,8 @@
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";
54 import { onRefsUpdated, startWorkflowsInBackground } from "./ci";
6- import { normalizeRepoName, snapshotRefs } from "./git";
5+ import { normalizeRepoName, snapshotRefs, spawnService } from "./git";
76 import { findKeyByBlob, touchSshKey } from "./keys";
87 import { DATA_ROOT } from "./paths";
98 import { canWrite, findRepo, markPushed, repoAccess } from "./repos";
@@ -87,7 +86,10 @@ function handleConnection(client: Connection) {
8786
8887 // Pushes start workflows for the refs they changed.
8988 const refsBefore = pushing ? await snapshotRefs(repo.dir) : null;
90- const child = spawn("git", [service, repo.dir], { env: { ...process.env, ...env } });
89+ const child = spawnService(pushing ? "git-receive-pack" : "git-upload-pack", repo.dir, {
90+ stateless: false,
91+ protocol: env.GIT_PROTOCOL,
92+ });
9193 channel.pipe(child.stdin);
9294 child.stdout.pipe(channel, { end: false });
9395 child.stderr.pipe(channel.stderr, { end: false });
+6−2package.jsonView File
@@ -8,7 +8,8 @@
88 "start": "next start",
99 "lint": "eslint",
1010 "postinstall": "node scripts/install-pocketbase.mjs",
11- "pocketbase:install": "node scripts/install-pocketbase.mjs"
11+ "pocketbase:install": "node scripts/install-pocketbase.mjs",
12+ "test": "bun test"
1213 },
1314 "dependencies": {
1415 "@codemirror/language": "^6.12.4",
@@ -50,5 +51,8 @@
5051 "trustedDependencies": [
5152 "sharp",
5253 "unrs-resolver"
53- ]
54+ ],
55+ "patchedDependencies": {
56+ "ssh2@1.17.0": "patches/ssh2@1.17.0.patch"
57+ }
5458 }
+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)