Fix codebase audit regressions while preserving UI/UX behavior and adding review-driven hardening.
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-29 —
window.crypto.getRandomValuesis replaced with aMath.random()-backed fallback whenwindow.cryptois undefined.Math.randomis a non-cryptographic PRNG (typically xorshift/PCG seeded at startup, fully predictable). Anything that later callscrypto.getRandomValuesunder 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 wherecryptoexists butgetRandomValuesis 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 lackscrypto.getRandomValues,throwso 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 viasrcDociframes with nosandboxattribute and no Subresource Integrity hash. Anabout:srcdociframe inherits the embedding document's origin, so any script it loads executes as 5chan.app and can reachwindow.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.jsscheme-relative URL, which downgrades tohttp://if the top-level page is ever served non-TLS (Electronfile://, local dev). Fix: (a) addsandbox="allow-scripts allow-popups allow-same-origin"carefully chosen per embed type so cross-origin script compromise cannot reach parent; ideally omitallow-same-originand rely on the iframe being a true opaque origin; (b) pinsrc="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-Policyheader 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 withStrict-Transport-Security: max-age=31536000; includeSubDomainsand, since the app is hash-routed, considerCross-Origin-Opener-Policy: same-originandCross-Origin-Resource-Policy: same-originto 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/gifFrameUrlare rendered directly as<img src>,<video src>, and<audio src>with no scheme or host allow-list.getLinkMediaInfoonly validates thatnew URL(link)succeeds (src/lib/utils/url-utils.ts:13-20), sojavascript:fooparses successfully and flows through. React auto-stripsjavascript:forhrefandsrcon<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 — howeverdata:URIs and oversizedblob:/file:URIs are not filtered, which lets a malicious community: (1) usedata: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 viadata:URLs, or (3) exfiltrate the visitor's IP to arbitrary servers simply by being rendered. Fix: ingetLinkMediaInfo, reject URLs whose protocol is nothttps:orhttp:(and optionallyipfs:/ipns:for decentralized use). Extend the same check tothumbnailandpatternThumbnailUrl. 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 setlinkto 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 anog:imageor<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 firstog:image/first image through cached thumbnail storage. Fix: reject private-range/link-local hostnames beforefetch, requirehttps:, and strip the extracted thumbnail URL down tohttps:only. Set anAbortControllertimeout 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 —
ImageChallengerendersdata:image/png;base64,${challenge}wherechallengeis 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: validatechallengeis/^[A-Za-z0-9+/=]{0,2000000}$/before rendering; cap byte length; optionally decode withatoband sniff the PNG magic number. - src/components/challenge-modal/challenge-modal.tsx:81-184 —
IframeChallengeaccepts a peer-provided URL, allows HTTPS plus anylocalhost/127.0.0.1/[::1]HTTP URL, and then hands it to an iframe withsandbox="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-activationis a well-known dangerous combination: the iframe inherits 5chan origin (well, it's a cross-origin iframe soallow-same-originonly gives it its own origin back, not 5chan's — this is OK), butallow-top-navigation-by-user-activationlets the challenge redirect the top frame after a click, enabling phishing attacks disguised as challenges. Thelocalhostexemption also lets any community probe services listening on the user's machine. Fix: dropallow-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 —
isAllowedDownloadUrlaccepts any URL whose hostname is exactlygithub.com(and any additional hosts viaVITE_APP_UPDATE_ALLOWED_DOWNLOAD_HOSTS, which is permitted to behttp:). GitHub release asset downloads begin atgithub.com/{owner}/{repo}/releases/download/…and redirect toobjects.githubusercontent.com; the native updater may or may not follow that redirect, and an attacker who compromises the Releases API response can return an arbitrarybrowser_download_urlpointing atgithub.com/<attacker>/<repo>/releases/download/…/evil.exe. Because the check is hostname-only, anygithub.comowner suffices. Fix: tighten tohostname === 'github.com' && url.pathname.startsWith('/bitsocialnet/5chan/releases/download/'). Also drop thehttp:fallback in theconfiguredDownloadHostsbranch. - src/lib/utils/pattern-utils.ts:34 — User-authored regex filters (
/pattern/flags) are compiled withnew 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 theregex.testcall 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 tolink.download.account.nameis user-controlled, so a user (or imported backup) with a name like../../etc/passwd.jsonor 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
urldirectly into a template:`https://lens.google.com/uploadbyurl?url=${url}`,saucenao.com/search.php?url=${url},yandex.com/images/search?img_url=${url}. NoencodeURIComponent; becauseurlcame fromnew 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'>usesrel='noreferrer'only. Modern browsers treatnoreferreras implyingnoopener, but older targets (some WebKit variants, older Android WebViews used via Capacitor) do not. Fix: standardize onrel='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 plainalert(e.message)which could leak library internals. Fix: enumerate expected fields; warn or refuse to import JSON that mutatessignerto 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 bybitsocial-react-hookslikely persists thesigner(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 fromexportAccount()ever includes the raw account JSON (it shouldn't, but is library-dependent), that lands in devtools. Fix: logerror.messageonly. - src/components/comment-media/comment-media.tsx:144 —
new URL(url)is constructed in the middle of render (linkWithoutThumbnail = url && new URL(url)). Ifurlis 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: useisValidURLguard before constructing. - index.html:64-76 — Initial-load redirect synthesizes
'/#' + window.location.pathname + window.location.searchand callswindow.location.replace. Same-origin-only, so this is not an open redirect, butpathnamecould 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, Electronfile:). - src/sw.ts — Service worker uses
NetworkFirstfor navigations andStaleWhileRevalidatefor assets. No validation that cached responses originated from the 5chan origin (the Workbox defaults enforce that via same-originregisterRoute), 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 addself.registration.unregister()+ cache wipe on startup if the SW detects a version mismatch. - src/lib/media-hosting/* — Uploads POST peer files to
catbox.moeand 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) —
dangerouslySetInnerHTMLusages insrc/: 0.document.write: 0.eval/new Function: 0.innerHTML =: only in__tests__. Alltarget="_blank"anchors haverelattributes (the handful with onlynoreferrerare noted above).matchesPatternuses 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
- Remove the
Math.randomfallback insrc/polyfills.js(and rebuild). This is a latent crypto-weakness with catastrophic blast radius if ever exercised. - Add a
Content-Security-Policyheader invercel.json(and a<meta>fallback for Electron/IPFS hosting) that restrictsscript-srcto'self'plus the specific embed CDNs, and constrainsframe-src/img-src/media-src/connect-srcto the actually-used origins. Pair with HSTS. - Harden the third-party embed pipeline in
src/components/embed/embed.tsx: addsandboxattributes to every srcDoc iframe, pin Instagram to absolutehttps://, and (ideally) move the external-script embeds to a dedicated sandbox origin so widgets.js compromises cannot reach the parent. - Add a protocol allow-list in
getLinkMediaInfo/isValidURL(src/lib/utils/url-utils.ts,src/lib/utils/media-utils.ts) so onlyhttp:/https:(and intentionally supportedipfs:/ipns:) URLs flow into<img>/<video>/<audio>/<a href>and intofetchWebpageThumbnail. Reject private-range hostnames in the thumbnail fetcher to close the browser-side SSRF. - Tighten
isAllowedDownloadUrl(src/lib/app-update-config.ts) to require the github.com path prefix of the official release repo, and drop thehttp:permission in the configurable-hosts branch. Bundle this with asandboxhardening ofIframeChallenge(dropallow-top-navigation-by-user-activation, reconsider the localhost exemption).