Skip to content

fix(security): Add security headers and harden external link rendering - #13

Open
euxaristia wants to merge 7 commits into
Gitlawb:mainfrom
euxaristia:fix/harden-external-links-and-headers
Open

fix(security): Add security headers and harden external link rendering#13
euxaristia wants to merge 7 commits into
Gitlawb:mainfrom
euxaristia:fix/harden-external-links-and-headers

Conversation

@euxaristia

Copy link
Copy Markdown

Summary

  • Add missing security headers (CSP, X-Frame-Options, HSTS, Referrer-Policy, Permissions-Policy) via vercel.json
  • Reject javascript:/other unsafe URL schemes before rendering peer/repo URLs as <a href>, since those values come from the gossip network and node API, not this app
  • Update dependencies within semver to patch known vulnerabilities (dompurify, react-router-dom, postcss, nanoid)

Changes

  • vercel.json: adds a headers block applying CSP, X-Frame-Options: DENY, X-Content-Type-Options: nosniff, Referrer-Policy, Permissions-Policy, and HSTS to every route. script-src allows 'self' plus a sha256 hash for the one inline anti-FOUC theme script in index.html; style-src keeps 'unsafe-inline' for React's inline style attributes and Google Fonts.
  • index.html: comment noting that editing the inline theme script requires recomputing its CSP hash, or it silently stops running.
  • src/lib/api.ts: new isHttpUrl() guard.
  • src/components/peers/PeerList.tsx: peer.http_url (from GET /api/v1/peers, i.e. other nodes on the gossip network) now only renders as a link when it's http(s); otherwise falls back to plain text. React doesn't scheme-filter href the way the DOMPurify-based markdown pipeline does for links.
  • src/components/repo-detail/ClonePanel.tsx, src/pages/HomePage.tsx: same guard on clone_url before rendering the "clone over https" link.
  • bun.lock, package.json: bun update (semver-respecting). Notably dompurify 3.4.11 → 3.4.14 and react-router-dom 7.17.0 → 7.18.2, both of which had open advisories.

Not included: sharp (via @vercel/og, used for OG image generation) and undici (via jsdom, test-only) both need a breaking major-version bump to clear their advisories — out of scope for a semver update.

Test plan

  • bun run lint
  • bun run test — 158 passed
  • bun run build — typecheck + production build succeed
  • Confirmed the built dist/index.html's inline theme script still matches the CSP sha256- hash
  • bun audit — dompurify, react-router-dom, postcss, nanoid advisories cleared; sharp and undici remain (documented above)

vercel.json had no Content-Security-Policy, X-Frame-Options, HSTS,
Referrer-Policy, or Permissions-Policy, leaving the site framable and
CSP-less as a backstop against markdown/link injection. PeerList,
ClonePanel, and HomePage also rendered peer.http_url and clone_url
(both sourced from the gossip network / node API, not this app)
directly into <a href>, which React does not scheme-filter the way
DOMPurify does for the markdown pipeline — a javascript: URL from
a malicious peer would have executed on click.
bun update within existing semver ranges. Fixes advisories in dompurify
(XSS via detached-subtree hook removal, the markdown pipeline's
sanitizer), react-router-dom (open redirect, protocol-validation XSS,
inefficient-route-matching DoS), postcss, and nanoid.

Not fixed here: sharp (via @vercel/og, server-side OG image
generation) and undici (via jsdom, test-only) both need a breaking
major-version bump outside this update's semver ranges.
@euxaristia
euxaristia marked this pull request as ready for review August 19, 2026 13:15
@euxaristia

Copy link
Copy Markdown
Author

@coderabbitai full review

@Vasanthdev2004

Copy link
Copy Markdown
Contributor

The isHttpUrl guard is worth having — http_url and clone_url arrive from other nodes over gossip, and they were reaching <a href> directly with none of the DOMPurify filtering the markdown path gets. Nice catch.

One thing that supports merging, since it's the first objection I'd expect someone to raise: the CSP does not break syntax highlighting. script-src has no wasm-unsafe-eval, which would normally take Shiki out — but this repo deliberately runs Shiki's JavaScript regex engine rather than the WASM one (createJavaScriptRegexEngine in src/lib/highlight.ts, whose first line says "no WASM"), and no .wasm is emitted in the build. Checked against a production build rather than assumed.

Two maintenance footguns worth a comment in the code, because both fail silently and neither is obvious six months from now:

1. The sha256 pin and the inline theme script are now coupled. script-src allowlists that script by hash, so editing it — including whitespace — stops it executing under CSP with no error anywhere. The symptom is a theme flash on first paint and the stored preference being ignored, which reads as a theming bug rather than a CSP one. The note added to index.html covers it; it may be worth the same note beside the hash in vercel.json, since that is the side someone updating the script will not be looking at.

2. connect-src and FEDERATED_NODES have to move together. The policy allowlists the four node hosts explicitly, and src/lib/nodes.ts lists the same four. Adding a fifth node to the registry without updating the CSP means its fetches are blocked in production only — dev has no CSP, so it passes locally and fails after deploy. vercel.json already has to be updated for the proxy rewrite when a node is added, so the two edits at least live in the same file; a comment pointing at nodes.ts would make the dependency explicit.

Neither blocks merging.

The sha256-pinned script-src hash and index.html's inline theme script
have to change together, and so do the CSP connect-src allowlist and
FEDERATED_NODES — both fail silently in production only, since there's
no CSP in dev. vercel.json is strict JSON and can't hold inline
comments, so the notes live in CONTRIBUTING.md and beside the node
registry instead.
@euxaristia

Copy link
Copy Markdown
Author

@Vasanthdev2004 addressed your earlier requested changes in my latest commit 9159e08

@Vasanthdev2004

Copy link
Copy Markdown
Contributor

Thanks for picking up both couplings — the CONTRIBUTING.md section and the nodes.ts comment cover them better than a vercel.json comment could have, given it's strict JSON.

Did a full pass over the rest. One blocking item, then smaller things.


Blocking: npm ci fails, so CI never runs

package.json raises 22 dependency floors, but package-lock.json isn't regenerated. .github/workflows/ci.yml:20 runs npm ci, which refuses to install on a manifest/lock mismatch.

Reproduced with this branch's package.json against the repo's package-lock.json:

npm error code EUSAGE
npm error `npm ci` can only install packages when your package.json and
npm error package-lock.json are in sync.
npm error Invalid: lock file's dompurify@3.4.11 does not satisfy dompurify@3.4.14
npm error Invalid: lock file's eslint@10.4.1 does not satisfy eslint@10.8.1
...22 packages

Lint, test and build never execute. npm install on the branch and committing the lockfile should clear it.

Related: only bun.lock is updated here. The repo carries both, so as it stands the dependency patches reach bun users but not npm users — or CI. Worth updating both in the same commit, since they'll otherwise keep drifting.


Smaller

No tests for isHttpUrl. src/lib/api.test.ts already has 43 cases and isn't touched. This function is the only thing standing between a gossiped URL and an anchor href, so it's worth pinning — a later refactor to url.startsWith('http') would pass CI while admitting httpx:. I checked the current implementation against javascript:, a leading-space variant, mixed-case JaVaScRiPt:, data:, vbscript: and protocol-relative //evil.com; all six are correctly rejected, and those are exactly the cases a test should lock in.

The rejected-URL fallback renders blank. PeerList.tsx:91 and :106 render peerHost(peer.http_url) in the <span> branch, but peerHost returns new URL(url).host, which is "" for every scheme isHttpUrl blocks — confirmed for javascript:, data:, vbscript: and //evil.com. So a peer advertising a hostile URL is still listed, with an empty host cell that reads as missing data rather than a URL the app refused. Rendering the raw string as text, or an explicit marker, would make it visible to whoever is auditing peers.

ClonePanel.tsx:40 guards more than the link. gitlawbUrl && isHttpUrl(cloneUrl) wraps a paragraph whose first sentence explains the git-remote-gitlawb helper requirement — true regardless of the https URL. HomePage.tsx:449 gets the scope right by guarding only the · or over https fragment; the same shape here would keep the sentence and drop just the anchor.

rel is now inconsistent across the same class of link. PeerList gets rel="noopener noreferrer", while ClonePanel.tsx:45 and HomePage.tsx:455 — both node-supplied URLs, both edited here — keep rel="noopener". The new Referrer-Policy limits the leak to the origin, so it's minor, but it's odd to have three anchors hardened two ways in one PR.

isHttpUrl parses twice. new URL(url).protocol === 'https:' || new URL(url).protocol === 'http:' builds the object once per comparison. Destructuring once reads the same and parses once.


Two judgment calls rather than defects, take or leave:

  • Strict-Transport-Security ships includeSubDomains and preload together. preload is inert until someone submits the domain, and submission needs the apex — so it does nothing on explorer.gitlawb.com today. If the app ever serves from gitlawb.com, that header commits every subdomain including the four node hosts to HTTPS-only for two years, and leaving the preload list takes months.
  • connect-src allowlists four node hosts the browser never contacts directly. nodes.ts routes every peer request through a same-origin proxy prefix and vercel.json forwards it server-side, so connections only ever go to 'self'. Harmless, but it implies direct cross-origin calls work, and a later change that drops a proxy rewrite on that assumption would fail on CORS rather than CSP.

For what it's worth, I checked the two things most likely to be wrong in a CSP change and both are right: the sha256 matches the inline theme script exactly (recomputed from index.html), and the policy doesn't break syntax highlighting, because this repo runs Shiki's JavaScript regex engine rather than the WASM one. Every other dynamic href in src/ is either guarded by this PR or fed from a trusted source — NodeCard from the in-repo registry, FileViewer from app-constructed same-origin blob paths.

The security fix itself looks right to me. It's the lockfile that needs sorting before this can land.

@Vasanthdev2004

Copy link
Copy Markdown
Contributor

for that ci issue need approval @kevincodex1

The scheme check constructed the URL object twice per call. It also
had no test coverage, so a later refactor (e.g. to url.startsWith)
could silently readmit a rejected scheme like javascript: since
nothing pins the current behavior.

Refs Gitlawb#13
peerHost() returns "" for any URL whose scheme isHttpUrl blocks, so a
peer advertising a javascript: or data: http_url over gossip rendered
an empty cell instead of the offending value. That reads as missing
data rather than a URL the app refused, which hides exactly the case
someone auditing peers needs to see. Render the raw string as text
(safe: React escapes it) instead.

Refs Gitlawb#13
gitlawbUrl && isHttpUrl(cloneUrl) gated the whole explanatory
paragraph, so a clone_url with a rejected scheme dropped a sentence
that's true regardless of the https link. Narrow the check to just
the anchor, matching the pattern HomePage.tsx already uses. Also
bring ClonePanel and HomePage's node-supplied anchors to
rel="noopener noreferrer", matching PeerList's — the same class of
link was hardened two different ways in this PR.

Refs Gitlawb#13
This PR's dependency bump updated bun.lock but not
package-lock.json, so npm ci failed on a manifest/lockfile mismatch
and CI never ran. The repo only ever used bun.lock in practice — bun
was already the working package manager, npm was vestigial and had
already drifted once. Rather than regenerate a second lockfile that
can drift the same way next time, drop it and standardize CI, README,
CONTRIBUTING, and the PR template on Bun.

Verified locally: bun install --frozen-lockfile, bun run lint,
bun run test (161 passed), and bun run build all pass. Note bun test
invokes Bun's own test runner, not Vitest, so docs use `bun run test`
to hit the actual package.json script.

Refs Gitlawb#13
@euxaristia

euxaristia commented Aug 20, 2026

Copy link
Copy Markdown
Author

I would like to switch CI to bun. npm is a Pandora's box, I don't even install npm on my systems. The bun feature of no automatic postinstall in particular is valuable here

@Vasanthdev2004

Vasanthdev2004 commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

The security work is in good shape. The isHttpUrl tests cover exactly the cases that matter — javascript: with the whitespace and casing variants, data:, vbscript:, protocol-relative — and ClonePanel now guards only the anchor while keeping the sentence around it, with noreferrer picked up along the way. That part is ready.

Maintainer call: let's split this. Land the security fix now, and take the package-manager migration as its own PR.

Of the 7,005 deleted lines, 6,799 are package-lock.json. Alongside it the CI workflow moves from actions/setup-node + npm ci to oven-sh/setup-bun + bun install --frozen-lockfile, and every script call becomes bun run. That's a change to how the project is developed and built, inside a PR titled "Add security headers and harden external link rendering."

To be clear, the underlying problem is real and I'm the one who raised it — carrying both package-lock.json and bun.lock meant they drifted, and CI broke because only one was updated. Standardising on one is the right conclusion. It's the packaging of it I want to change, for three reasons:

  • It's contributor-facing. Dropping package-lock.json means anyone building this repo needs bun installed. That deserves its own discussion rather than arriving as a side effect of a security fix.
  • It affects deploys, not just CI. Vercel selects its install command from the lockfile it finds. Removing package-lock.json changes how production installs dependencies, and that wants a preview deploy to confirm before it ships.
  • It buries the part that needs review. The security changes are ~90 lines across five files, currently sitting under a 6,799-line deletion.

So for this PR: npm install on the branch, commit the regenerated package-lock.json, and revert the CI and README/CONTRIBUTING changes. That gets the hardening merged on its own.

Then open the bun migration separately — I'm receptive to it, the two-lockfile situation genuinely needs resolving, and it'll be a quick review once it's the only thing in the diff and a preview deploy is green.

Sorry for the extra round trip; the lockfile problem was worth fixing, I just want the fix to land where it can be judged on its own.

@euxaristia

euxaristia commented Aug 20, 2026

Copy link
Copy Markdown
Author

Yeah I see your point. I'll leave this to Kevin's discretion cuz it will create more work for me to split this PR

@euxaristia

Copy link
Copy Markdown
Author

@kevincodex1

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants