Files
5chan/docs/agent-runs/codebase-audit-2026-04-23/03-security.md
T

65 lines
19 KiB
Markdown
Raw Normal View History

# Security Audit
## Summary
5chan's React frontend has a generally defensive posture for an imageboard that renders untrusted peer content: zero `dangerouslySetInnerHTML` usages (prior scan confirmed), custom markdown tokenizer that leans on React's JSX escaping, `target="_blank"` links uniformly paired with `rel="noopener noreferrer"`, and URL parsing routed through the browser `URL` constructor. The highest-impact issues are a severely broken crypto RNG polyfill that silently downgrades `crypto.getRandomValues` to `Math.random`, third-party embed scripts loaded into `about:srcdoc` iframes that inherit the 5chan origin without Subresource Integrity, and the complete absence of a `Content-Security-Policy` header — so any future XSS has no defense-in-depth to contain it. Several medium issues stem from peer-controlled URLs flowing into `<img>`, `<video>`, `<audio>`, `<a href>`, and iframe embedders without origin validation, which is acceptable for many decentralized imageboard designs but warrants an explicit allow-list or protocol filter.
## Threat Surface Overview
| Trust boundary | Where it enters the client | Key files |
|---|---|---|
| Peer post content (title, content, link, reason) | Subplebbit/community feeds via `bitsocial-react-hooks` | `src/components/markdown/markdown.tsx`, `src/components/comment-content/comment-content.tsx`, `src/components/comment-media/comment-media.tsx`, `src/views/archive/archive.tsx` |
| Peer-supplied media URLs | Comment `link` field | `src/lib/utils/media-utils.ts`, `src/components/comment-media/comment-media.tsx`, `src/components/catalog-row/catalog-row.tsx`, `src/components/embed/embed.tsx` |
| External iframe challenges (captcha) | `challenge` field of type `url/iframe` | `src/components/challenge-modal/challenge-modal.tsx` |
| External webpage thumbnails (og:image/first `<img>`) | Community-served link preview fetched via `fetch()`/`CapacitorHttp` | `src/lib/utils/media-utils.ts` (`fetchWebpageThumbnail`) |
| GitHub release manifest | `api.github.com` release JSON for auto-update | `src/lib/app-update.ts`, `src/lib/app-update-config.ts` |
| GitHub directories list | `raw.githubusercontent.com` JSON | `src/hooks/use-directories.ts` |
| Account data / private keys | bitsocial-react-hooks account store; editor reads/writes JSON including `signer` | `src/views/account-data-editor/account-data-editor.tsx`, `src/components/settings-modal/account-settings/account-settings.tsx`, `src/lib/utils/account-editor-utils.ts` |
| Local preferences | `localStorage` keys (filters, subscriptions, UI state) | `src/stores/use-*-store.ts`, `src/hooks/use-directories.ts` |
| URL query/hash routing | Location search/hash parsed via `URLSearchParams`/`is5chanLink` | `src/lib/utils/url-utils.ts`, `src/components/catalog-search/catalog-search.tsx`, `index.html` |
| User regex hide filters | User-typed patterns compiled via `new RegExp` | `src/lib/utils/pattern-utils.ts` |
| Native bridges | `window.electronApi.*` (copy clipboard, upload automation, installer) | `src/globals.d.ts`, `src/hooks/use-file-upload.ts`, `src/lib/app-update.ts`, `src/lib/utils/clipboard-utils.ts` |
## Findings
### Critical
- **src/polyfills.js:20-29** — `window.crypto.getRandomValues` is replaced with a `Math.random()`-backed fallback when `window.crypto` is undefined. `Math.random` is a non-cryptographic PRNG (typically xorshift/PCG seeded at startup, fully predictable). Anything that later calls `crypto.getRandomValues` under this polyfill — libsodium, noble-curves/ed25519, IPFS/libp2p key generation, nonces, session IDs inside bundled deps — silently produces guessable output. In modern browsers the branch shouldn't trip (crypto always exists), but the guard also short-circuits *any* environment where `crypto` exists but `getRandomValues` is missing (older SSR shims, some Electron preload corner cases), so the defensive posture is broken rather than safe-by-default. The severity is magnified because a decentralized imageboard derives account keypairs, signs publications, and derives author addresses from this primitive layer. *Fix:* remove the Math.random fallback entirely. If a host truly lacks `crypto.getRandomValues`, `throw` so callers bail; do not fabricate randomness. At minimum, guard the replacement behind a build-time flag so it never ships to production.
### High
- **src/components/embed/embed.tsx:113-315** — Third-party embed scripts (`platform.twitter.com/widgets.js`, `embed.reddit.com/widgets.js`, `www.tiktok.com/embed.js`, `//www.instagram.com/embed.js`) are injected via `srcDoc` iframes with no `sandbox` attribute and no Subresource Integrity hash. An `about:srcdoc` iframe inherits the embedding document's origin, so any script it loads executes *as 5chan.app* and can reach `window.parent.document`. A supply-chain compromise of any of those four CDNs (or a routed MITM on a user without HSTS) directly yields full XSS of the 5chan app, including read/write of IndexedDB account keys. This is amplified by the `//www.instagram.com/embed.js` scheme-relative URL, which downgrades to `http://` if the top-level page is ever served non-TLS (Electron `file://`, local dev). *Fix:* (a) add `sandbox="allow-scripts allow-popups allow-same-origin"` carefully chosen per embed type so cross-origin script compromise cannot reach parent; ideally omit `allow-same-origin` and rely on the iframe being a true opaque origin; (b) pin `src="https://..."` absolute for Instagram; (c) consider inlining the embed via the official oEmbed server-side rendering path or proxying through a CSP-constrained iframe host (e.g. a sandbox subdomain).
- **index.html (entire file) + vercel.json:36-45** — No `Content-Security-Policy` header is emitted and no `<meta http-equiv="Content-Security-Policy">` exists. Given that the primary threat model is untrusted peer content and third-party scripts load into the origin via srcdoc iframes, a CSP is the most impactful missing control. A single XSS bypass (today, future regression, or supply-chain) would have full DOM access. *Fix:* add a strict CSP via Vercel header: `default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; frame-src https:; img-src https: data: blob:; media-src https: blob:; connect-src 'self' https: wss:; style-src 'self' 'unsafe-inline'; object-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'`. Tighten over time (drop `'unsafe-inline'` for styles once CSS-modules replaces any inline styles). Mirror with `Strict-Transport-Security: max-age=31536000; includeSubDomains` and, since the app is hash-routed, consider `Cross-Origin-Opener-Policy: same-origin` and `Cross-Origin-Resource-Policy: same-origin` to block spectre-style side-channels.
- **src/components/comment-media/comment-media.tsx:74, 89, 93, 108, 124, 138, 218, 235, 238, 349, 388; src/components/catalog-row/catalog-row.tsx:84-101** — Peer-supplied `url` / `thumbnail` / `gifFrameUrl` are rendered directly as `<img src>`, `<video src>`, and `<audio src>` with no scheme or host allow-list. `getLinkMediaInfo` only validates that `new URL(link)` succeeds (`src/lib/utils/url-utils.ts:13-20`), so `javascript:foo` parses successfully and flows through. React auto-strips `javascript:` for `href` and `src` on `<a>`/`<iframe>`/`<script>`/`<form>` (since React 16.9), but `<img src="javascript:…">` is a no-op in modern browsers and `<video src>`/`<audio src>` likewise ignore javascript URLs — so the immediate XSS risk is low — however `data:` URIs and oversized `blob:`/`file:` URIs are not filtered, which lets a malicious community: (1) use `data:image/svg+xml,<svg onload=…>` style SVGs (browsers suppress script execution in `<img src>`-loaded SVGs but still fetch, and the stored-URL pattern becomes dangerous if ever rendered via `<object>` or `<iframe>`), (2) trigger unbounded memory via `data:` URLs, or (3) exfiltrate the visitor's IP to arbitrary servers simply by being rendered. *Fix:* in `getLinkMediaInfo`, reject URLs whose protocol is not `https:` or `http:` (and optionally `ipfs:`/`ipns:` for decentralized use). Extend the same check to `thumbnail` and `patternThumbnailUrl`. Consider routing all peer media through a rewrite that enforces protocol and normalizes origin.
### Medium
- **src/lib/utils/media-utils.ts:149-209 (`fetchWebpageThumbnail`)** — Peer-supplied URLs are fetched directly by the client with no hostname filtering. This is a browser-side SSRF amplifier: a malicious community can set `link` to a private-range URL (`http://192.168.1.1/…`, `http://169.254.169.254/…`, intranet hosts, etc.) that the visitor's browser will fetch, parse, and extract an `og:image` or `<img src>` from — then render that extracted URL as `<img src={thumbnail}>`. On native (`CapacitorHttp`) this bypasses CORS entirely, so the attacker can probe arbitrary intranet resources and exfiltrate the response's first `og:image`/first image through cached thumbnail storage. *Fix:* reject private-range/link-local hostnames before `fetch`, require `https:`, and strip the extracted thumbnail URL down to `https:` only. Set an `AbortController` timeout shorter than the current 5 s on native, and never cache a thumbnail whose hostname differs from the source link's registered domain.
- **src/components/challenge-modal/challenge-modal.tsx:27** — `ImageChallenge` renders `data:image/png;base64,${challenge}` where `challenge` is a peer-supplied string. React escapes the attribute so breakout is not possible; however there is no size or base64-sanity check, so a community can ship a multi-megabyte "challenge" that exhausts the decoder, or a non-PNG payload that fails silently and presents a broken image (usability, not XSS). *Fix:* validate `challenge` is `/^[A-Za-z0-9+/=]{0,2000000}$/` before rendering; cap byte length; optionally decode with `atob` and sniff the PNG magic number.
- **src/components/challenge-modal/challenge-modal.tsx:81-184** — `IframeChallenge` accepts a peer-provided URL, allows HTTPS plus any `localhost` / `127.0.0.1` / `[::1]` HTTP URL, and then hands it to an iframe with `sandbox="allow-scripts allow-forms allow-popups allow-same-origin allow-top-navigation-by-user-activation"`. `allow-same-origin` + `allow-scripts` + `allow-top-navigation-by-user-activation` is a well-known dangerous combination: the iframe inherits 5chan origin (well, it's a cross-origin iframe so `allow-same-origin` only gives it *its own* origin back, not 5chan's — this is OK), but `allow-top-navigation-by-user-activation` lets the challenge redirect the top frame after a click, enabling phishing attacks disguised as challenges. The `localhost` exemption also lets any community probe services listening on the user's machine. *Fix:* drop `allow-top-navigation-by-user-activation`; either drop the localhost exemption or scope it to an explicit allow-list of known bitsocial services (and show a louder UI warning). Enforce a Permissions-Policy on the iframe (e.g. `allow=""`).
- **src/lib/app-update-config.ts:26-30** — `isAllowedDownloadUrl` accepts any URL whose hostname is exactly `github.com` (and any additional hosts via `VITE_APP_UPDATE_ALLOWED_DOWNLOAD_HOSTS`, which is permitted to be `http:`). GitHub release asset downloads begin at `github.com/{owner}/{repo}/releases/download/…` and redirect to `objects.githubusercontent.com`; the native updater may or may not follow that redirect, and an attacker who compromises the Releases API response can return an arbitrary `browser_download_url` pointing at `github.com/<attacker>/<repo>/releases/download/…/evil.exe`. Because the check is hostname-only, any `github.com` owner suffices. *Fix:* tighten to `hostname === 'github.com' && url.pathname.startsWith('/bitsocialnet/5chan/releases/download/')`. Also drop the `http:` fallback in the `configuredDownloadHosts` branch.
- **src/lib/utils/pattern-utils.ts:34** — User-authored regex filters (`/pattern/flags`) are compiled with `new RegExp(regexPattern, flags)`. Although these are user-typed for their own client, a combination of a pathological pattern like `/(a+)+$/` and peer content can lock the UI (ReDoS). *Fix:* wrap the `regex.test` call in a worker with an execution budget, or reject patterns that fail a complexity heuristic (nested quantifiers, >1000 chars). Low urgency since only the user harms themselves.
- **src/components/settings-modal/account-settings/account-settings.tsx:101** — Export filename comes from `account?.name ?? 'account'` with no sanitization before being assigned to `link.download`. `account.name` is user-controlled, so a user (or imported backup) with a name like `../../etc/passwd.json` or one containing control characters would produce an odd download, and some browsers historically honored path separators. Low severity (self-inflicted, browsers normalize), but worth stripping. *Fix:* `link.download = (account?.name || 'account').replace(/[^\w.\-]/g, '_') + '.json'`.
- **src/components/post-desktop/post-menu-desktop/post-menu-desktop.tsx:147-154 and src/components/post-mobile/post-menu-mobile/post-menu-mobile.tsx:148-155** — Reverse-image-search URLs interpolate peer `url` directly into a template: `` `https://lens.google.com/uploadbyurl?url=${url}` ``, `saucenao.com/search.php?url=${url}`, `yandex.com/images/search?img_url=${url}`. No `encodeURIComponent`; because `url` came from `new URL()`, dangerous characters like `"`, `<`, `#` are percent-encoded, but `&` is not, letting a crafted URL add extra query parameters to the third-party site (e.g. `&` followed by rogue query keys). Not a traditional XSS but a minor integrity/tracking concern. *Fix:* `encodeURIComponent(url)` in all three places.
- **src/components/comment-media/comment-media.tsx:163** — `<a href={url} target='_blank' rel='noreferrer'>` uses `rel='noreferrer'` only. Modern browsers treat `noreferrer` as implying `noopener`, but older targets (some WebKit variants, older Android WebViews used via Capacitor) do not. *Fix:* standardize on `rel='noopener noreferrer'` like the other 20+ occurrences in the codebase.
- **src/views/account-data-editor/account-data-editor.tsx:85-98** — The account JSON editor accepts free-form JSON and writes it back via `setAccount`. A hostile paste (e.g. a phishing "here's your key, paste this") is handed straight to the hook layer. No warning about what fields are dangerous (e.g. `signer.privateKey`, `subscriptions`), and the error surface is a plain `alert(e.message)` which could leak library internals. *Fix:* enumerate expected fields; warn or refuse to import JSON that mutates `signer` to a different address than the current account.
### Low
- **src/stores/*-store.ts** — Many stores persist to `localStorage` (subscriptions, filters, catalog style, disclaimer acceptance, popular threads options, directories cache). None of this is secret, so plaintext is fine; however the underlying account store used by `bitsocial-react-hooks` likely persists the `signer` (private key) to IndexedDB unencrypted. That's upstream of this repo, but it means any XSS trivially exfiltrates identity. Document the threat model and consider an optional passphrase-encrypted vault for key material.
- **src/components/settings-modal/account-settings/account-settings.tsx:84, 166** — `console.log(error)` of whole Error objects during account export/import error paths. If the error chain from `exportAccount()` ever includes the raw account JSON (it shouldn't, but is library-dependent), that lands in devtools. *Fix:* log `error.message` only.
- **src/components/comment-media/comment-media.tsx:144** — `new URL(url)` is constructed in the middle of render (`linkWithoutThumbnail = url && new URL(url)`). If `url` is malformed the component throws and unmounts the subtree. A malicious peer can crash rendering of a thread by posting a comment with a link value like `"not a url"`. *Fix:* use `isValidURL` guard before constructing.
- **index.html:64-76** — Initial-load redirect synthesizes `'/#' + window.location.pathname + window.location.search` and calls `window.location.replace`. Same-origin-only, so this is not an open redirect, but `pathname` could contain arbitrary characters (via DNS or server rewrite) that end up in the hash. Low risk given router validation, but worth normalizing.
- **src/components/embed/embed.tsx:163,166** — `parent=${window.location.hostname}` passed to the Twitch player. The hostname is trusted (it's the 5chan origin), but Twitch's embed documentation requires each parent domain to be pre-registered on the Twitch side; a hostname mismatch causes silent failure. Not a security bug but a reliability footgun when the app runs on alt hostnames (`5chan.eth.limo`, Electron `file:`).
- **src/sw.ts** — Service worker uses `NetworkFirst` for navigations and `StaleWhileRevalidate` for assets. No validation that cached responses originated from the 5chan origin (the Workbox defaults enforce that via same-origin `registerRoute`), so lookup semantics are fine. However an XSS on 5chan can seed the SW cache with a poisoned response and pin it for up to 30 days (`maxAgeSeconds: 60 * 60 * 24 * 30`). *Fix:* once CSP is in place, also add `self.registration.unregister()` + cache wipe on startup if the SW detects a version mismatch.
- **src/lib/media-hosting/*** — Uploads POST peer files to `catbox.moe` and imgur. No hash pinning or response sanitization; the returned URL (text body) is trusted verbatim and flows back into `<img src>`. A compromised host can substitute URLs pointing at attacker content, but the user chose to upload there, so this is expected risk. Document it.
- **Static analysis confirmations (no issue found)** — `dangerouslySetInnerHTML` usages in `src/`: 0. `document.write`: 0. `eval` / `new Function`: 0. `innerHTML =`: only in `__tests__`. All `target="_blank"` anchors have `rel` attributes (the handful with only `noreferrer` are noted above). `matchesPattern` uses RegExp only on the user's own inputs. The custom markdown tokenizer renders every text/URL/quote token through JSX children (React-escaped); no raw HTML is produced.
## Top 5 Actions
1. **Remove the `Math.random` fallback in `src/polyfills.js`** (and rebuild). This is a latent crypto-weakness with catastrophic blast radius if ever exercised.
2. **Add a `Content-Security-Policy` header** in `vercel.json` (and a `<meta>` fallback for Electron/IPFS hosting) that restricts `script-src` to `'self'` plus the specific embed CDNs, and constrains `frame-src`/`img-src`/`media-src`/`connect-src` to the actually-used origins. Pair with HSTS.
3. **Harden the third-party embed pipeline** in `src/components/embed/embed.tsx`: add `sandbox` attributes to every srcDoc iframe, pin Instagram to absolute `https://`, and (ideally) move the external-script embeds to a dedicated sandbox origin so widgets.js compromises cannot reach the parent.
4. **Add a protocol allow-list in `getLinkMediaInfo`/`isValidURL`** (`src/lib/utils/url-utils.ts`, `src/lib/utils/media-utils.ts`) so only `http:`/`https:` (and intentionally supported `ipfs:`/`ipns:`) URLs flow into `<img>`/`<video>`/`<audio>`/`<a href>` and into `fetchWebpageThumbnail`. Reject private-range hostnames in the thumbnail fetcher to close the browser-side SSRF.
5. **Tighten `isAllowedDownloadUrl`** (`src/lib/app-update-config.ts`) to require the github.com path prefix of the official release repo, and drop the `http:` permission in the configurable-hosts branch. Bundle this with a `sandbox` hardening of `IframeChallenge` (drop `allow-top-navigation-by-user-activation`, reconsider the localhost exemption).