Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,24 @@ npm run build
All three must pass. `npm run build` includes the TypeScript project build
(`tsc -b`), so type errors fail there.

### Editing the security headers

`vercel.json`'s `headers` block is strict JSON — it can't carry inline
comments — so these two couplings are documented here instead:

- **The CSP `script-src` hash and the inline theme script.** `index.html`'s
anti-FOUC theme script is allowlisted by sha256 hash rather than
`'unsafe-inline'`. Changing that script, even its whitespace, changes the
hash, and a stale hash in `vercel.json` makes the script silently stop
running under CSP (symptom: a theme flash on load, stored preference
ignored). Recompute the hash and update `script-src` whenever the script
changes.
- **The CSP `connect-src` allowlist and `FEDERATED_NODES`.** `src/lib/nodes.ts`
lists every node host the app talks to; `vercel.json`'s `connect-src` (and
its per-node proxy `rewrites`) must list the same hosts. There's no CSP in
dev, so a host added to one but not the other passes locally and is
silently blocked in production.

## What makes a good PR

- **Keep it focused.** One logical change per PR. Refactors, formatting sweeps, and
Expand Down
308 changes: 177 additions & 131 deletions bun.lock

Large diffs are not rendered by default.

5 changes: 4 additions & 1 deletion index.html
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,10 @@
<link href="https://fonts.googleapis.com/css2?family=Geist+Mono:wght@400;500;600;700;800;900&family=Geist:wght@400;500;600;700;800;900&display=swap" rel="stylesheet" />
<!-- Anti-FOUC: set theme before first paint. Stored choice wins, then the
OS preference, then dark — which is what this audience runs and what
the site shipped before. -->
the site shipped before.
CSP allowlists this script by sha256 hash (vercel.json script-src) —
editing so much as its whitespace requires recomputing that hash or
the script silently stops running under CSP. -->
<script>
(function () {
var stored = localStorage.getItem('gl-theme');
Expand Down
48 changes: 24 additions & 24 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,47 +30,47 @@
"canvas-confetti": "^1.9.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cobe": "^0.6.4",
"dompurify": "^3.4.11",
"cobe": "^0.6.5",
"dompurify": "^3.4.14",
"gemoji": "^8.1.0",
"github-slugger": "^2.0.0",
"gsap": "^3.15.0",
"lucide-react": "^1.27.0",
"marked": "^18.0.5",
"lucide-react": "^1.33.0",
"marked": "^18.0.10",
"marked-alert": "^2.1.2",
"marked-emoji": "^2.0.3",
"marked-footnote": "^1.4.0",
"marked-gfm-heading-id": "^4.1.4",
"marked-highlight": "^2.2.4",
"motion": "^13.1.0",
"next-themes": "^0.4.6",
"postcss": "^8.5.23",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-router-dom": "^7.17.0",
"shiki": "^4.3.0",
"postcss": "^8.5.26",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react-router-dom": "^7.18.2",
"shiki": "^4.4.3",
"tailwind-merge": "^3.6.0",
"tailwind-variants": "^3.3.0",
"tailwind-variants": "^3.3.1",
"tw-animate-css": "^1.4.0",
"vaul": "^1.1.2"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@tailwindcss/vite": "^4.3.0",
"@types/node": "^24.12.3",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"eslint": "^10.3.0",
"@tailwindcss/vite": "^4.3.3",
"@types/node": "^24.13.3",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.4",
"@vitejs/plugin-react": "^6.0.5",
"eslint": "^10.8.1",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.6.0",
"eslint-plugin-react-refresh": "^0.5.4",
"globals": "^17.11.0",
"jsdom": "^29.1.1",
"playwright": "^1.60.0",
"tailwindcss": "^4.3.0",
"typescript": "~6.0.2",
"typescript-eslint": "^8.59.2",
"vite": "^8.0.12",
"vitest": "^4.1.9"
"playwright": "^1.62.1",
"tailwindcss": "^4.3.3",
"typescript": "~6.0.3",
"typescript-eslint": "^8.67.0",
"vite": "^8.2.1",
"vitest": "^4.1.11"
}
}
42 changes: 27 additions & 15 deletions src/components/peers/PeerList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { useState } from 'react';
import { Button } from '../register/controls';
import { cn } from '../../lib/utils';
import type { ApiPeer } from '../../lib/api';
import { pingPeer, peerHost, shortDid, timeAgo } from '../../lib/api';
import { pingPeer, peerHost, isHttpUrl, shortDid, timeAgo } from '../../lib/api';
import { MicroLabel } from '../ui/MicroLabel';
import { Skeleton } from '../ui/Skeleton';
import { Pill } from '../ui/Pill';
Expand Down Expand Up @@ -52,6 +52,10 @@ function PingCell({ did }: { did: string }) {
}

function PeerRow({ peer, index }: { peer: ApiPeer; index: number }) {
// Gossiped by other nodes, not this app — a `javascript:` http_url must not
// reach an anchor href.
const safeUrl = isHttpUrl(peer.http_url) ? peer.http_url : undefined;

return (
<li
className="grid grid-cols-[16px_minmax(0,1fr)_70px] md:grid-cols-[24px_minmax(0,4fr)_minmax(0,3fr)_120px_90px]
Expand All @@ -74,25 +78,33 @@ function PeerRow({ peer, index }: { peer: ApiPeer; index: number }) {
{shortDid(peer.did)}
</span>
<CopyButton value={peer.did} label="did" />
{safeUrl ? (
<a
href={safeUrl}
target="_blank"
rel="noopener noreferrer"
className="md:hidden text-[11px] text-muted truncate"
>
{peerHost(peer.http_url)}
</a>
) : (
<span className="md:hidden text-[11px] text-muted truncate">{peerHost(peer.http_url)}</span>
)}
</div>

{/* Host */}
{safeUrl ? (
<a
href={peer.http_url}
href={safeUrl}
target="_blank"
rel="noopener noreferrer"
className="md:hidden text-[11px] text-muted truncate"
className="hidden md:block text-[12px] text-muted truncate"
>
{peerHost(peer.http_url)}
{peerHost(peer.http_url)}
</a>
</div>

{/* Host */}
<a
href={peer.http_url}
target="_blank"
rel="noopener noreferrer"
className="hidden md:block text-[12px] text-muted truncate"
>
{peerHost(peer.http_url)} ↗
</a>
) : (
<span className="hidden md:block text-[12px] text-muted truncate">{peerHost(peer.http_url)}</span>
)}

{/* Last seen */}
<span className="hidden md:block text-[12px] text-muted tabular-nums whitespace-nowrap">
Expand Down
3 changes: 2 additions & 1 deletion src/components/repo-detail/ClonePanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Button } from '../register/controls';
import { CopyButton } from '../ui/CopyButton';
import { Section } from '../register/primitives';
import { MagicCard } from '../ui/magic-card';
import { isHttpUrl } from '../../lib/api';

interface ClonePanelProps {
/** The node's https clone URL — plain git handles this one. */
Expand Down Expand Up @@ -36,7 +37,7 @@ export function ClonePanel({ cloneUrl, gitlawbUrl, onNavigate }: ClonePanelProps
</code>
</MagicCard>

{gitlawbUrl && (
{gitlawbUrl && isHttpUrl(cloneUrl) && (
<p className="m-0 mt-2 text-[12px] leading-relaxed text-muted">
The <code className="font-mono">gitlawb://</code> scheme requires the{' '}
<code className="font-mono">git-remote-gitlawb</code> helper. Plain{' '}
Expand Down
14 changes: 14 additions & 0 deletions src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -441,6 +441,20 @@ export function peerHost(url: string): string {
}
}

/**
* Whether a URL is safe to render as an anchor `href`. Peer/repo URLs
* (`http_url`, `clone_url`) come from the gossip network and other nodes, not
* this app, so a `javascript:` scheme must be rejected before it ever reaches
* the DOM — React does not filter link schemes the way DOMPurify does.
*/
export function isHttpUrl(url: string): boolean {
try {
return new URL(url).protocol === 'https:' || new URL(url).protocol === 'http:';
} catch {
return false;
}
}

/** `refs/heads/main` → `main`; other refs lose only the `refs/` prefix. */
export function shortRefName(ref: string): string {
if (ref.startsWith('refs/heads/')) return ref.slice('refs/heads/'.length);
Expand Down
2 changes: 2 additions & 0 deletions src/lib/nodes.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
// Federated node registry for the network page. The nodes serve no CORS
// headers, so peer nodes are reached through per-node proxy prefixes — see
// vite.config.ts (dev) and vercel.json (prod), which must list the same hosts.
// vercel.json's CSP connect-src must also list every host below, or a new
// node's fetches pass locally (no CSP in dev) and are silently blocked in prod.
import type { NodeInfo, NodeStats, ApiPeer, P2PInfo, ApiRefUpdate } from './api';

export interface FederatedNode {
Expand Down
3 changes: 2 additions & 1 deletion src/pages/HomePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
parseEventRepo,
isGossip,
didKeySegment,
isHttpUrl,
} from '../lib/api';
import type { ApiRefUpdate } from '../lib/api';
import { CopyButton } from '../components/ui/CopyButton';
Expand Down Expand Up @@ -445,7 +446,7 @@ export default function HomePage() {
<Link to="/docs/quickstart" className="text-accent hover:underline">
quickstart
</Link>
{cloneRepo && (
{cloneRepo && isHttpUrl(cloneRepo.clone_url) && (
<>
{' '}· or over{' '}
<a
Expand Down
16 changes: 16 additions & 0 deletions vercel.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,20 @@
{
"headers": [
{
"source": "/(.*)",
"headers": [
{
"key": "Content-Security-Policy",
"value": "default-src 'self'; script-src 'self' 'sha256-WPdSHaM+crt0cb8vWbsE3d1RcMKgF7JlUQV8s9ZltE0='; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' https: data:; connect-src 'self' https://node.gitlawb.com https://node2.gitlawb.com https://node3.gitlawb.com https://manila.gitlawb.com; object-src 'none'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'"
},
{ "key": "X-Frame-Options", "value": "DENY" },
{ "key": "X-Content-Type-Options", "value": "nosniff" },
{ "key": "Referrer-Policy", "value": "strict-origin-when-cross-origin" },
{ "key": "Permissions-Policy", "value": "camera=(), microphone=(), geolocation=()" },
{ "key": "Strict-Transport-Security", "value": "max-age=63072000; includeSubDomains; preload" }
]
}
],
"rewrites": [
{ "source": "/repos/:owner/:name", "destination": "/api/repo-page?owner=:owner&name=:name" },
{ "source": "/og/repos/:owner/:name/:version", "destination": "/api/og?owner=:owner&name=:name&v=:version" },
Expand Down