Files
5chan/docs/agent-runs/codebase-audit-2026-04-23/03-security.md
T
Tommaso CasaburiandGitHub 5dc5408a15 fix(codebase audit): preserve cleanup without regressions
Fix codebase audit regressions while preserving UI/UX behavior and adding review-driven hardening.
2026-04-24 15:48:07 +07:00

19 KiB

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-29window.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:27ImageChallenge 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-184IframeChallenge 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-30isAllowedDownloadUrl 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, 166console.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:144new 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,166parent=${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).