# Vectrum — Security Assessment
**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)
---
## 1. Executive summary
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` |
---
## 2. Critical — Git argument injection → arbitrary file overwrite (RCE)
### 2.1 Summary
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.
### 2.2 Affected code
`lib/git.ts` — ref value is placed before `--` (or with no barrier at all):
```ts
// 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:
1. **No ref-name validation on push.** `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=…`.
2. **No leading-dash guard in ref resolution.** `resolveRefAndPath()` and `defaultBranch()`
in `lib/git.ts` match the attacker's branch name against the real ref list and return it
verbatim.
3. **No barrier at the sink.** The commands above interpret the returned ref as an option.
### 2.3 Reachable sinks (web routes)
- `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.
### 2.4 Proof of concept (verified against git 2.55.0)
Pushing a dash-prefixed ref is accepted:
```console
$ 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):
```console
$ 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
```
### 2.5 End-to-end attack (any authenticated user)
1. Create a repository (allowed for any signed-in user) and push a `main` branch whose
latest commit **subject** is the file contents you want to write.
2. Push a second branch named, e.g., `--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.
3. Request `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.
### 2.6 Live validation
- SSH to the running instance authenticates and reaches the push handler:
`ssh -T -p 2222 git@localhost` → `Hi johannso! You've successfully authenticated…`
- Web interface healthy (`GET :3000/api/health` → `{"ok":true}`), signups enabled.
- Source confirms **no** ref-name validation on the container's push path and **no**
leading-dash guard in ref resolution — identical to the proven PoC code path.
- Writing a file *inside the running container* was **not** performed: the host blocks it
(Docker socket access denied, no passwordless sudo, `/data` not host-visible). This is
host hygiene only; it does not mitigate the flaw in Vectrum.
### 2.7 Impact
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.
### 2.8 Remediation (defense in depth — apply all)
1. **Barrier at every sink.** In `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:
```ts
["log", `--format=${COMMIT_FORMAT}`, `-n${limit}`, `--skip=${skip}`,
"--end-of-options", ref, "--", ...(path ? [path] : [])]
```
2. **Reject dash-prefixed refs on read.** In `resolveRefAndPath`, `listBranches`,
`listTags`, and `defaultBranch`, drop/deny any ref whose name starts with `-`.
3. **Validate refs on push.** Refuse ref updates whose names start with `-` or otherwise
fail `git check-ref-format`, in the receive-pack path (`lib/git-http.ts`,
`lib/ssh-server.ts`).
4. Add a regression test using the PoC (push `--output=…`, assert no file is written and
the request 404s).
---
## 3. Low-severity findings
### 3.1 Session-cookie `Secure` flag depends on a client-supplied header
`lib/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.
### 3.2 Host-derived URLs trust `Host` / `X-Forwarded-Host`
`lib/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.
---
## 4. Informational
### 4.1 Runner tokens never expire
`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.
### 4.2 SSH server lacks post-quantum key exchange
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.
---
## 5. Reviewed and found solid
- **PocketBase authorization:** all collection rules are superuser-only (`null`) except
`users.viewRule = "id = @request.auth.id"`; the Next.js server mediates every access.
- **Session handling:** sessions are PocketBase auth tokens in an `httpOnly` cookie,
re-validated by reading the user's own record with their token (`lib/auth.ts`).
- **Server-action authorization:** `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/*`).
- **Secrets:** API, runner, and job tokens are 30-byte random values stored only as SHA-256
hashes; passwords via PocketBase bcrypt; login avoids username-enumeration timing
(`lib/tokens.ts`, `lib/runners.ts`, `lib/runs.ts`, `lib/users.ts`).
- **Markdown XSS:** `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.
- **Path traversal:** `isValidName` / `isValidOwner` / `repoPath` constrain on-disk repo
paths; `cleanRepoPath` rejects `..`, `.git`, and control characters for web edits
(`lib/git.ts`, `lib/git-write.ts`).
- **Blob/image routes** set `X-Content-Type-Options: nosniff` and a sandboxing CSP,
preventing stored XSS via uploaded blobs/SVGs (`app/[owner]/[repo]/raw`, `/image`).
- **Repo images** are validated by magic bytes, not the browser-reported MIME type
(`lib/repos.ts` `sniffImageType`).
---
## 6. Priority
1. **Immediately:** Fix finding #1 (git argument injection) — small, localized change in
`lib/git.ts` plus ref validation on the push/resolve paths; add the regression test.
2. Then: findings #2 and #3 (header-trust hardening).
3. Backlog: #4 and #5.
🤖 Generated with [Claude Code](https://claude.com/claude-code)