Skip to content

Audit sweep: security, correctness, perf, cleanup#20

Open
pulsedemon wants to merge 26 commits into
masterfrom
audit-fixes
Open

Audit sweep: security, correctness, perf, cleanup#20
pulsedemon wants to merge 26 commits into
masterfrom
audit-fixes

Conversation

@pulsedemon

Copy link
Copy Markdown
Owner

Summary

Comprehensive repo audit followed by 25 commits across 4 categories. Stakes are low (personal art project) but this PR ships real security fixes, fixes a few bugs that have been quietly wrong, and tightens build/test plumbing.

Security

  • Stored DOM-XSS fix in VirusLab.updateSavedMixesListmix.name from localStorage was interpolated into innerHTML. Switched to createElement + textContent. Regression test included.
  • Lab iframe nav validationviruses/lab/index.html now validates ?primary= / ?secondary= against an allowlist before assigning iframe.src. Path-traversal payloads like ../../foo no longer reach the iframe. Drift test enforces sync with Playlist.viruses.
  • .env untracked — committed .env only held a public Sentry DSN, but the pattern is wrong. Added to .gitignore and removed from tracking. Amplify rebuilds it from CI env at deploy time.
  • Baseline headers added to customHttp.yml: Referrer-Policy, X-Content-Type-Options, X-Frame-Options. (CSP intentionally deferred — the iframe + inline-script mix needs careful policy work.)

Correctness

  • Fullscreen this bindingobj[method]() lost this for requestFullscreen/exitFullscreen. Switched to .call(obj) and added a mock.contexts[0] assertion test.
  • draggable NaN guard — first drag with computed top: auto no longer teleports the element to (0, 0).
  • randomIntBetween now inclusive of max — was off-by-one. Rotation interval now actually reaches 12s, matching docstring. Test asserts both bounds appear across 1000 samples.
  • Playlist.current() regenerates the playlist on out-of-bounds rather than silently resetting currentIndex.
  • document.onkeydown -> addEventListener — property assignment was clobbering global handlers.
  • teleportMenu tracks position by index, not fragile string-match on style.inset shorthand.
  • shuffleTitle now uses proper Fisher-Yates and returns a { interval, stop } handle; stop() restores the original title on beforeunload.
  • Orphan virus directories deletedviruses/error-like-text and viruses/static-uzumaki weren't in Playlist.viruses but still shipped to prod.

Performance

  • Thumbnail overlay lazy-loads iframes via IntersectionObserver (with fallback for environments without IO). Previously booted ~15 three.js/rapier scenes on overlay open.
  • Thumbnail overlay reuses the live Playlist instead of constructing its own. Saved mixes now appear without a reload.
  • safetyTimeout bumped 5s -> 12s to stop spurious Sentry warnings on crane-game cold WASM loads.

Cleanup

  • jsconfig.json (empty) deleted; tsconfig.json include tightened.
  • Handlebars Vite plugin extracted to build-utils/handlebars-loader.ts (was duplicated between vite.config.ts and vitest.config.ts, with already-drifting escape logic).
  • Magic numbers centralized in components/constants.ts.
  • Production console.logs gated behind import.meta.env.DEV.
  • Test as any casts replaced with a typed internals() helper.
  • engines.node: ">=20" added to package.json.
  • window.TVStaticLoading global and unused imports removed.
  • VirusLab.cleanup() is now actually wired into the lab-close path.
  • README documents the URL routing redirect and lab allowlist.

Test plan

  • yarn lint — clean
  • yarn format:check — clean
  • npx tsc --noEmit — clean
  • yarn test — 221 tests passing across 17 files (incl. new XSS, lab allowlist drift, fullscreen this-binding, draggable NaN, randomIntBetween bounds, lazy-load IO, live-playlist regression tests). Coverage thresholds still met.
  • yarn build — builds clean
  • Manual smoke in yarn preview:
    • XSS regression: paste hostile mix into localStorage.savedVirusMixes, open lab, confirm no script execution
    • Lab nav regression: visit /viruses/lab/?primary=..%2F&secondary=sphere, confirm "invalid mix" fallback
    • Fullscreen on Chrome and Safari
    • Crane-game keyboard control (arrow keys drive crane, don't skip)
    • Thumbnail overlay: iframes off-screen have no src until visible
    • Mix persistence: save a mix in lab, mix appears in overlay without reload

Out of scope (filed as follow-ups)

  • Real CSP — needs careful policy work for inline scripts + GA + Google Fonts + iframes
  • Pre-rendered PNG thumbnails instead of live iframes
  • prefers-reduced-motion honored inside virus animations
  • PWA manifest / offline support
  • Seeded RNG for deterministic playback
  • Yarn 1 vs Yarn modern reconciliation

pulsedemon added 25 commits May 18, 2026 21:28
The committed .env only contained a public Sentry DSN (DSNs are
designed to ship to the client), so this isn't a credential leak —
but the pattern invites future contributors to put real secrets
there. Amplify already rebuilds .env from CI env vars at build time,
so removing the tracked file has no deployment impact.
updateSavedMixesList previously interpolated mix.name straight into
innerHTML. mix.name flows from localStorage and is therefore attacker-
controlled by anything with same-origin write access (DevTools, browser
extensions, another exploit on the origin). A planted name like
<img src=x onerror=...> would execute on every render of the lab.

Switch to createElement + textContent for the label and event-listener
binding for the buttons. data-id attributes removed since the closure-
captured mix already supplies the id.

Adds a regression test that stores a hostile name and asserts no
script execution and that the markup is rendered as literal text.
viruses/lab/index.html wrote URL params directly into iframe.src with
no validation, so values like ?primary=../../foo would resolve to
arbitrary same-origin paths inside the inner iframes.

Adds an inline ALLOWED_VIRUSES set (kept in sync with
components/Playlist.ts) and shows an "invalid mix" fallback when
params don't validate.

The lab page is a static HTML with an inline module script and can't
import Playlist directly. A drift test parses the inline allowlist
and asserts equality with Playlist.viruses so CI catches divergence.
Adds Referrer-Policy, X-Content-Type-Options, X-Frame-Options.

Deliberately not adding CSP in this commit — the site's heavy
iframe + inline-script + GA + Google Fonts mix needs a careful
policy and a misconfigured CSP would break everything. Filed as a
follow-up.
requestFullscreen / exitFullscreen are spec'd to require their `this`
to be the element or document respectively. The previous
`(obj[method] as () => ...)()` invocation called them as free
functions, which works in some browsers but fails per spec and has
been observed to silently no-op in stricter engines.

Switch to `.call(obj)` and add a test that inspects mock.contexts[0]
to prove the binding is preserved.
parseInt("auto") returns NaN, so on the first drag of an element
that never had top/left explicitly set, offsetX/offsetY became NaN
and the element snapped to "NaNpx" — which browsers treat as 0,
visually teleporting it to (0,0) before normal drag math resumed.

Coalesce to 0 with `|| 0` so the first drag honors the element's
on-screen starting point.
randomIntBetween(min, max) was implemented as exclusive of max
(Math.floor(rand * (max - min)) + min), which contradicts both the
function's name and the docstring at every call site. The randomization
interval comment was "2-12s" but the function actually returned 2..11s.

Switch to inclusive on both ends and add tests that hit every bound
across 1000 samples to catch any future drift.

Visual impact at call sites: rotation interval now actually reaches
12s, and ErrorUI font sizes now actually reach 100px.
document.onkeydown / document.onkeyup property assignment clobbers
any other handler on the same property. addEventListener composes,
which leaves room for future handlers (or third-party scripts that
ad-hoc attach) without silently breaking each other.
Playlist.current() previously warned and reset currentIndex to 0
without confirming the playlist itself was valid. If both the index
*and* the playlist were stale, this would return undefined-flavored
garbage on the next read.

Regenerate the playlist on out-of-bounds so current() always returns
a valid item.

(Audit also flagged a perceived duplicate-insertion bug in
setCurrentVirus; on re-read it's actually fine — the splice path is
only reached when indexOf returns -1, so a duplicate can't appear.
No change there.)
viruses/error-like-text and viruses/static-uzumaki are not registered
in Playlist.viruses and have no external references. They were still
shipping in the build because Vite globs viruses/*/index.html.

static-uzumaki lacks an index.html entry, so it was never even
buildable on its own — purely dead SCSS+TS.
teleportMenu compared menu.style.inset to a list of formatted
shorthand strings. Browsers normalize shorthand-inset serialization
differently (Chrome vs Safari), so the indexOf could miss and the
function might pick the same corner it was already in — defeating
the whole "teleport elsewhere" idea.

Track the current corner by index. Pick uniformly from the other
three on each call. Same behavior on first call, predictable
behavior thereafter.
Replaces .sort(() => 0.5 - Math.random()) with the project's
Fisher-Yates shuffle (utils/misc.ts). Sort-based shuffles are
biased and aren't even sortable comparators; for a 15-char title
the visual effect is unchanged but the implementation is now correct.

shuffleTitle now returns { interval, stop } where stop both clears
the timer AND restores the original document.title. main.ts calls
stop() in beforeunload so the title doesn't get persisted to
browser history in a scrambled state.
The overlay previously constructed its own Playlist instance, which
re-read localStorage and re-shuffled. This both wasted work and meant
mixes saved in the lab could be invisible to the overlay until a
full page reload (depending on which instance was queried).

Pass the live Playlist via options. floating-buttons now also calls
playlist.loadSavedMixes() before opening so freshly-saved mixes are
guaranteed to appear.

Adds a regression test that mutates testPlaylist.savedMixes after
playlist construction and asserts the new mix renders in the
overlay's thumbnail grid.
The gallery rendered 15+ virus iframes simultaneously, each booting
its own three.js/rapier scene. On mobile or cold caches this thrashes
hard.

Switch iframes to data-src and assign real src only when the
thumbnail enters the overlay's viewport (with a 100px root margin so
near-offscreen items still pre-warm). Falls back to immediate src
assignment when IntersectionObserver is unavailable (e.g., some test
environments).

Test simulates an IO callback and asserts iframes have no src until
intersection, then get their data-src promoted to src.
The crane-game virus loads ~2MB of rapier3d WASM and can easily
exceed 5s on cold cache or slow networks, producing a stream of
spurious "safety timeout" Sentry warnings. 12s still catches
genuinely-stuck loads while accommodating the slowest virus in the
playlist on realistic mobile conditions.
startRandomization() already clears loadRandomInterval as its first
step, so the preceding clearInterval call is dead.
- Delete window.TVStaticLoading global (no consumer; verified via grep).
  Stops leaking the class onto the window object.
- Drop unused TVStaticLoading import from main.ts.
- Wire VirusLab.cleanup() into VirusLoader.toggleLab close path so the
  well-named teardown runs (event listeners + iframes get tidied
  before the container is removed).

Also updates VirusLoader tests to use the bumped 12s safety timeout
(carried over from the prior commit's runtime change).
Empty file, tracked, redundant alongside tsconfig.json.
include: ["."] pulled dist/, coverage/, .superpowers/, node_modules/
into the typecheck universe until excludes kicked in. Switch to an
explicit allowlist of source dirs (.ts only) and add dist + coverage
to exclude.

Also fixes a typecheck error that surfaced once dist/ wasn't being
scanned: the fullscreen test's `delete doc.requestFullscreen` was
flagged because TS sees the lib property as required. Restore via
assignment instead.
vite.config.ts and vitest.config.ts each had their own inline copy
of a handlebars .hbs loader. The two had already drifted — the Vite
copy escaped <, >, / and the line/paragraph separators; the Vitest
copy only escaped the separators. Extracting both to
build-utils/handlebars-loader.ts means they stay in sync and the
safer escape set is used everywhere.
Three test cases used (lab as any).saveMix() / .deleteMix() with
disable-comments for half a dozen lint rules. Replace with an
internals() helper that double-casts through unknown to a typed
interface — same runtime behavior, cleaner test code, no
eslint-disable comments. Intersection with VirusLab itself collapses
to never because TS can't widen private to public, hence the
double-cast.
Amplify pins node 20 and CI runs node 22, but local dev had no
version gate. Add engines.node: ">=20" so devs on older node get a
clear warning at install time.
Centralizes LOAD_SAFETY_TIMEOUT_MS, MIN_LOAD_ANIMATION_MS,
NAVIGATION_LOCK_MS, RANDOMIZATION_{MIN,MAX}_S, FLASH_INTERVAL_MS,
FLASH_STOP_DELAY_MS. Wires VirusLoader and Flash through them.

Other magic numbers live inside individual virus animations and are
out of scope for this PR — those tend to be aesthetic tuning rather
than control-flow timing.
Loading/Skipping/Reloading logs and the TVStaticLoading watermark
"fortune" log shipped to production, polluting users' devtools every
2-12s. Wrap each in `if (import.meta.env.DEV)` so they remain
useful during development but stay quiet in the bundled site.

Keeps the ASCII-art banner in main.ts — that's intentional branding.
README now mentions the /viruses/<name> -> /?virus=<name> dev-server
redirect and the lab allowlist that mirrors Playlist.viruses. The
"add a new virus" steps now include updating the lab allowlist.
Comment thread components/VirusThumbnailOverlay.ts Fixed
Comment thread components/VirusThumbnailOverlay.ts Fixed
CodeQL flagged the lazy-load assignment `iframe.src = src` (where
`src` came from `iframe.getAttribute('data-src')`) as "DOM text
reinterpreted as HTML." Even though the data-src values originate
from a hardcoded `Playlist.viruses` array, the static analyzer
can't prove that, and the indirection through a DOM attribute
means a future change (or DOM tampering) could pivot to a
`javascript:` URL or a same-origin path-traversal.

Add a strict regex check (`/^\/viruses\/[a-z][a-z0-9-]*\/$/`) and
drop anything that doesn't match before assigning. Removes the
attribute either way so we never re-attempt a tampered value.

Add a regression test that poisons `playlist.viruses` and asserts
only the well-formed entry gets a src.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR performs a broad audit sweep across the repo, focused on hardening client-side security (XSS / iframe navigation), fixing a few correctness issues (fullscreen binding, random bounds, drag NaN), improving runtime performance (lazy iframe thumbnails), and cleaning up build/test plumbing (shared Handlebars loader, centralized constants, tighter TS config, removed orphaned viruses).

Changes:

  • Security hardening: DOM-XSS fix in VirusLab saved mixes; lab iframe allowlist validation; baseline security headers; stop tracking .env.
  • Correctness fixes: inclusive randomIntBetween, draggable NaN guard, fullscreen this binding, playlist regeneration on OOB, safer event wiring.
  • Performance/cleanup: lazy-load thumbnail iframes via IntersectionObserver, shared Handlebars loader, centralized constants, removed unused/orphan code.

Reviewed changes

Copilot reviewed 35 out of 38 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
vitest.config.ts Use shared Handlebars loader plugin.
vite.config.ts Use shared Handlebars loader plugin.
build-utils/handlebars-loader.ts New shared .hbs loader implementation.
viruses/lab/index.html Add allowlist + param validation for iframe src.
viruses/lab/__tests__/lab-allowlist.test.ts Drift test enforcing allowlist sync with Playlist.
components/VirusLab.ts Replace innerHTML mix list rendering with DOM APIs.
components/__tests__/VirusLab.test.ts Add XSS regression + typed internals helper.
components/VirusThumbnailOverlay.ts Require live Playlist; lazy-load thumbnail iframes.
components/__tests__/VirusThumbnailOverlay.test.ts Tests for live playlist + lazy-load + safe path.
ui/floating-buttons.ts Pass live playlist into thumbnail overlay; refresh mixes.
ui/menu.ts Robust menu teleporting; Fisher–Yates title shuffle handle.
ui/__tests__/menu.test.ts Update tests for shuffleTitle handle + stop restore.
ui/fullscreen.ts Fix fullscreen method invocation this binding.
ui/__tests__/fullscreen.test.ts Add assertion that this is correct for fullscreen call.
main.ts Switch to addEventListener; title shuffle stop on unload; remove unused global.
utils/random.ts Make randomIntBetween inclusive of max.
utils/__tests__/random.test.ts Add tests for inclusive bounds + min===max.
utils/misc.ts Draggable NaN guard; expose shuffle used by UI.
utils/__tests__/misc.test.ts Add draggable regression test for auto positioning.
components/VirusLoader.ts Centralize constants; gate logs; wire lab cleanup; adjust timeouts.
components/__tests__/VirusLoader.test.ts Update safety-timeout expectations for new timeout.
components/flash/flash.ts Use centralized flash timing constants.
components/constants.ts New shared constants for loader/flash/randomization.
components/TVStaticLoading.ts Gate console logging behind DEV.
components/Playlist.ts Regenerate playlist on OOB currentIndex; minor cleanup.
components/__tests__/Playlist.test.ts Update test to assert regeneration behavior.
customHttp.yml Add baseline security headers.
tsconfig.json Tighten includes/excludes for TS checking.
package.json Declare Node engine >=20.
README.md Document routing redirect and lab allowlist rules.
.gitignore Ignore .env.
.env Remove tracked .env file.
jsconfig.json Remove empty JS config.
viruses/error-like-text/index.html Delete orphan virus (not in playlist).
viruses/error-like-text/error-like-text.ts Delete orphan virus implementation.
viruses/error-like-text/error-like-text.scss Delete orphan virus styles.
viruses/static-uzumaki/static-uzumaki.ts Delete orphan virus implementation.
viruses/static-uzumaki/static-uzumaki.scss Delete orphan virus styles.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread utils/random.ts
Comment on lines +5 to 6
/** Returns a random integer in [min, max] (inclusive of both bounds). */
export function randomIntBetween(min: number, max: number): number {
Comment thread components/VirusLab.ts
const deleteButton = document.createElement('button');
deleteButton.className = 'delete-mix';
deleteButton.textContent = 'Delete';
deleteButton.addEventListener('click', () => this.deleteMix(mix.id!));
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.

3 participants