Skip to content

Phase 1: security, data integrity and Resolve EDL fixes; broader test battery - #6

Merged
Consterna merged 32 commits into
mainfrom
fix/phase1-audit
Oct 25, 2026
Merged

Consterna merged 32 commits into
mainfrom
fix/phase1-audit

Conversation

@Consterna

@Consterna Consterna commented Oct 24, 2026 •

Copy link
Copy Markdown
Owner

Phase 1 of the improvement plan: fix, debug and test. Everything below is under [Unreleased] in both changelogs; the version stays 1.6.1 until a release is decided.

Security

  • zod 4.6.7: fixes GHSA-mrhx-c7xc-m3w5 (high).
  • Credentials per server: a ?server= link could make the page send saved host and member tokens to another server. Tokens and the member id are now kept per server; credentials saved before this version are only reused with another server after the user accepts it in a banner naming it.
  • Unknown room codes count per IP across REST and WebSocket; custom room codes need 8+ characters.
  • Sockets that leave or lose their room are closed after the join timeout; WebSocket connections per IP are capped (MAX_SOCKETS_PER_IP, default 64).
  • Room codes are drawn with crypto.randomInt: the old byte % 31 made 8 of the 31 characters more likely (a chi-square test now guards it).
  • Member ids come from crypto.getRandomValues (64 bits) instead of Math.random, so another device can't predict and claim them.
  • The runtime Docker image no longer ships npm / npx (they carried all of the image's HIGH Node findings in Trivy).
  • SECURITY.md gives an email fallback (private vulnerability reporting is disabled) and lists the known limits by design (credentials in browser storage, ?server=).

Fixes

  • DaVinci Resolve EDL now uses the layout Resolve itself writes (|C:ResolveColor<Color> |M:<name> |D:1), imported with Timelines → Import → Timeline Markers from EDL. Checked against an EDL exported by Resolve and three independent implementations. Markers that land on the same frame are merged into one (all names, the first one's color), because Resolve keeps one marker per frame. Still to be tried in Resolve itself.
  • Sessions and database:
    • a failed BEGIN no longer disables later transactions;
    • stopping an old session no longer orphans the running one;
    • markers follow the stop time;
    • restarts resume sessions after a deploy and close them at the last heartbeat after a crash;
    • migration v5 repairs bad stop times;
    • a newer schema is refused;
    • an Auto Session starts at the key press that opens it, never before the previous session stopped;
    • the latest session breaks start-time ties by creation order.
  • API / server: unknown session exports answer 404, repeated query parameters 400, empty .env values mean the default; an oversized WebSocket frame is logged as a warning, not an error (a client could flood the error log).
  • Client:
    • fixed the OBS reconnect loop;
    • fixed the server backoff;
    • deleted or unknown rooms are no longer retried in a loop, and entering the code again retries;
    • the SNTP filter re-baselines when the network slows down;
    • input limits match the server;
    • shortcuts ignore key repeat, modifiers and open dialogs;
    • dialogs handle focus;
    • added missing translations;
    • export file names follow the server's rule;
    • credentials survive blocked browser storage for the page load (locked-down OBS profiles reconnected as a new identity every time), while storage stays the source of truth when it works;
    • Dock markers keep their category color (every Dock marker was blue since v1.0.0);
    • the Dock clock follows the session timeline whenever a session runs.
  • Accessibility:
    • contrast and labels fixed where axe found WCAG 2.1 AA violations;
    • the Director Panel reflows down to 320 px (it was about 650 px wide on a phone, WCAG 1.4.10);
    • focus moves to the Start / Stop button that replaces the focused one, also when the change comes from another tab (WCAG 2.4.3).
  • ws is declared where it is imported; the workspace packages are marked private and MIT.

Tests (all green locally and in CI)

  • 303 unit / integration tests (30 files), 9 component tests in Chromium (Vitest Browser Mode), 18 Playwright end-to-end tests (3 files) including axe scans in English and Spanish: 330 in total.
  • Coverage counts every source file: 67.4 % statements, enforced by thresholds.
  • Mutation score of the timecode engine and exporters: 94.5 % (Stryker, on demand via the manual workflow; not a project dependency; 618 killed, 35 survived, 1 without coverage, 0 timeouts; the same totals in two runs).
    • The 93.7 % given earlier in this PR was wrong: under the default timeout, about 40 slow mutants (mostly the framerate table, which reruns every test) ended as Timeout, which Stryker counts as detected, depending on machine load (87.6 %, 91.4 % and 93.7 % on the same code). stryker.config.mjs now sets a 60 s timeout, and the honest score was then 85.8 % (the same status for every mutant in two runs).
    • packages/shared/test/mutants-v162.test.ts kills the mutants that were real gaps: the timecode parser accepted junk around a timecode when its ^ / $ anchors or trim() were removed, the drop-frame validation of session framerates had no test in shared, chapters past one hour, the 10 s spacing after the intro chapter, EDL text cleanup and category colors, XML defaults and padding, CSV fields missing in old rows and drop-frame timecodes.
    • The 36 mutants still alive are listed in that file with why they can't be told apart from the real code: float tolerances, guards that give the same result, whitespace between XML elements, and the framesPerTenMinutes fields of the framerate table, which nothing reads.
  • Every fix has a regression test that fails without it. Independent reviews of the diff; their findings are fixed.
  • E2E views wait for the server's JOINED before pressing hotkeys (this removed an intermittent failure in the export test).

Verified outside the unit tests

  • Clean clones on Node 24.21.0 and 26.12.1, and on Linux (WSL).
  • Docker image (amd64 and arm64): health, client, graceful stop resumes the session, a kill closes it at the last heartbeat; Compose with Caddy: HTTPS, WSS, and a spoofed X-Forwarded-For doesn't bypass the rate limits.
  • Exports read by OpenTimelineIO 0.18.1 (CMX 3600 and FCP 7 XML adapters) and a port of SubtitleEdit's Resolve marker reader: every marker on its expected frame. CSV opened in LibreOffice with formula import on: no formulas. YouTube chapters follow Google's rules.
  • Protocol fuzzing (WebSocket and REST): no 5xx, no error-level logs. 10-minute load test with 50 Docks and 10 HUDs: every marker delivered once and in order.
  • Databases written by v1.0.0, v1.2.0, v1.5.1, v1.6.0 and v1.6.1 migrate to v5 with no data lost.
  • Gitleaks, Knip, npm audit (0 vulnerabilities) and CodeQL (security-and-quality): the two real CodeQL findings are fixed; the rest are documented limits or a false positive (the public default display name).
  • Every link in the Markdown files. The compare/ links of the CHANGELOG answer 404 until the v1.0.0–v1.6.0 tags exist on GitHub (only v1.6.1 does).

Documentation

  • The accessibility statement only states what is tested, including reflow and focus. It had claimed screen readers that were never tried, and it named categories that don't exist.
  • Test counts, the test file list and the database stress test description are accurate. The stress test now really interleaves its writers.
  • Spanish community files sit next to the English ones.

Not verified here

  • Import of the new EDL in DaVinci Resolve and of the XML in Premiere Pro.
  • OBS Studio 33 with the Dock and HUD (a protocol-level mock of obs-websocket v5 was used).
  • Screen readers, Firefox and Safari.
  • A load test longer than 10 minutes (slow memory growth).

…nor updates

- zod 4.6.7 fixes a high-severity memory exhaustion advisory (< 4.6.6)
- @fastify/cors 11 only changes the default methods, which the server sets explicitly
- Dependabot ignores @types/node majors until the runtime moves to that major
…and lookup limits

- transaction(): a failed BEGIN no longer leaves every later transaction running without one;
  the rollback keeps the original error; afterCommit() for broadcasts that must not outlive a rollback
- Stopping a session that already stopped neither moves its end nor orphans the running session
- Markers never go into a stopped session (a dock that hasn't seen SESSION_STOPPED yet)
- Undo only reaches the running session, as documented
- Partial status updates are validated against the merged OBS framerate
- Migration v5 repairs sessions v4 closed before they started; a newer schema is refused and the
  database file is closed when it can't be opened
- Restart recovery: running sessions resume after a recent clean shutdown and close at the last
  heartbeat after a crash; members are no longer reported as recording until their dock rejoins
- Unknown room codes count per IP across REST and WebSocket; custom codes need 8+ characters
- Sockets that leave their room or whose room is deleted are closed after the unjoined timeout;
  simultaneous sockets per IP are capped
- Exports of an unknown session answer 404; repeated query parameters answer 400
- Empty PORT / HOST / LOG_LEVEL / ROOM_CREATE_LIMIT_PER_MINUTE use their defaults
- RFC 5987 file names percent-encode ' ( ) *; startup errors are logged before exiting
…mecode in FCP XML

- The DaVinci Resolve export now writes the layout Resolve itself produces with
  Timeline Markers to EDL (one-frame event + |C:ResolveColor<Color> |M:<name> |D:1)
  instead of Avid-style * LOC: locators; '|' in names is replaced, White maps to Cream
  (Resolve has no white marker)
- FCP 7 XML writes the base timecode with ';' for drop-frame sessions
- Golden-layout and property tests for the marker EDL; import steps updated in both languages
…input limits and a11y

- Tokens and the member id are kept per server: a ?server= link can no longer make the page send
  the credentials of one server to another; older shared credentials are only reused with that
  server after the user accepts it in a banner naming it
- OBS: a retry left by an earlier failed attempt no longer closes a good connection (reconnect
  loop on double Connect / React StrictMode); attempts finishing after disconnect are closed
- Server socket: the backoff resets on JOINED instead of on open; deleted or unknown rooms are not
  rejoined on every reconnect; late events of a replaced socket are ignored; the SNTP filter
  re-baselines after 3 consecutive RTT spikes instead of rejecting samples forever
- ?room= follows the room in use; TIMEOUT no longer replaces the banner
- Inputs limited to the server's lengths (name, room code, marker text, session name); the base
  timecode is validated before the start dialog closes; long stored names are trimmed
- A custom marker's text is no longer saved as both label and comment
- Dock shortcuts ignore key repeat, modifier keys and open dialogs
- Dialogs trap and restore focus; missing labels added; category translated in the timeline
- Clipboard failures are caught; export file names follow the server's rule; download links
  URL-encode their ids; API errors are shown with a translated prefix
- The Director Panel no longer shows the previous room's session history after switching rooms
…resholds; fix contrast

- Vitest projects: unit (node) and browser (Chromium via @vitest/browser-playwright with
  vitest-browser-react) for dialogs, focus handling, clipboard, file names and the start form
- e2e/a11y.spec.ts runs axe (WCAG 2.1 A/AA rules) on the dock, HUD preview, Director Panel and
  both dialogs in English and Spanish; the grep-only dialog checks are replaced by these
- Coverage counts every source file (66 % statements, measured) and now has thresholds
- vitest.config.ts -> vitest.config.mts (no more ESM-as-CommonJS warning)
- Contrast fixes found by axe: secondary text zinc-500/600 -> zinc-400, emerald/sky buttons one
  shade darker; labels for the filter, search and POV selects; inactive download links are
  aria-disabled and not focusable
- CI runs the browser tests and coverage in the job that installs Chromium
…testing

- ACCESSIBILITY.md / ACCESSIBILITY.es.md say only what is verified (axe-core WCAG 2.1 A/AA rules
  on the main screens in both languages, dialog focus tests) and what is not tested yet (screen
  readers, Firefox, Safari); the previous text named marker categories that don't exist and
  screen readers that were never tried
- README: WCAG is a target, not a conformance claim; test counts recounted (289 at v1.6.2)
- The database stress test really interleaves its 20 writers and takes the backup in the middle
  of them, and asserts it; the README no longer claims 2,000 writes/sec
- Spanish community files sit next to the English ones; language selector in CODE_OF_CONDUCT.md
- Stryker config (on demand, not a dependency) and a manual workflow; tests for the mutants it
  found: mutation score of the timecode engine and exporters 85.9 % -> 93.7 %
- CHANGELOG [Unreleased] in English and Spanish
… keys, review findings

Independent review of the phase 1 changes:
- After ROOM_NOT_FOUND the same code could not be joined again without a reload (regression):
  reconnects no longer retry it by themselves, but entering the code again asks once more
- Tokens of the page's own server are now kept under their own key, so the trust question for
  another server only offers credentials written before v1.6.2
- A HUD opened while the trust question is pending still connects (it sends no credentials)
- The Director Panel no longer shows the previous room's sessions until the new room answers
- A failing after-commit callback no longer hides a committed transaction or skips the others
- A marker pressed just before the host's Stop stays in that session instead of opening an
  Auto Session; one pressed after it goes to the running session
- MAX_SOCKETS_PER_IP (default 64), with a warning in the log when a connection is refused;
  README and .env.example explain the per-IP limits behind a proxy
- axe scans run with reduced motion, so they never measure text halfway through a fade-in
…efore marking

Second independent review:
- Entering the same room code reconnects only when this client is not in that room (refused or
  deleted), tracked in React state: a joined dock showing an unrelated error keeps its socket, and
  the retry still works after the error banner was dismissed
- MAX_SOCKETS_PER_IP default lives with the other limits (config no longer loads the WebSocket
  handler); comments on the shared pre-v1.6.2 token copy and on the HUD made accurate
- e2e openView() waits for the server's JOINED (opt out for invalid codes and the offline-server
  test): tests pressed hotkeys before the join, which made the export test fail now and then
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 24, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-24T20:13:05.186277Z f667cfe PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f667cfe40b

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +318 to +319
if (msg.payload.code === 'ROOM_NOT_FOUND') {
this.autoRejoin = false;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Stop auto-rejoining after a rate-limit refusal

When a client receives RATE_LIMITED for the per-IP lookup limit or socket cap, the server closes the socket, but this branch disables auto-rejoin only for ROOM_NOT_FOUND. Because joinRoom() leaves autoRejoin enabled, each reconnect retries the same join and is refused again until the limit clears. Disable auto-rejoin for this refusal so the client waits for an explicit retry.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Not applied: RATE_LIMITED is temporary, and disabling auto-rejoin would make the Dock worse.

  • Per-IP socket cap (handler.ts, on connect): the refusal happens before any join. autoRejoin doesn't stop reconnecting, it only decides whether to send JOIN_ROOM afterwards, so turning it off would leave the Dock reconnecting forever without ever entering the room, even once a slot frees up.
  • Unknown-code block: it uses a fixed window, and refused attempts don't count toward it (recordFailure only runs on ROOM_NOT_FOUND, after the isBlocked check). Retrying doesn't extend the block, and the Dock gets back in by itself once the window ends. An unknown room already disables auto-rejoin through ROOM_NOT_FOUND.
  • Per-socket message limit: it doesn't close the socket and has nothing to do with joining.

The reconnect backoff spaces retries up to 15 s and only resets on JOINED. A test now pins this behaviour (rejoins by itself after a rate-limit refusal, once the server lets it in again, in e909d6d), and the reasoning is in a comment next to this branch.

The server, its healthcheck and the backup command only need node; the npm bundled with the base
image brought five high-severity advisories in its own dependencies (brace-expansion, undici,
http-cache-semantics) that Trivy reported. They are gone from the scan; Debian's own advisories
(none with a fix yet) stay until the base image moves to trixie.
DaVinci Resolve keeps at most one timeline marker per frame (its scripting API returns markers as a
dict keyed by frame), so two streamers marking the same frame lost one of the markers on import.
Markers on the same frame are now one event whose name lists all of them. Found by reading the
exports back with OpenTimelineIO's CMX 3600 adapter, which rejects overlapping events.
An oversized frame (or any frame breaking the protocol) made @fastify/websocket's default handler
log an error-level entry per socket, so a client could flood the error log. Its documented
errorHandler option now logs ws protocol errors (WS_ERR_*) and abrupt disconnects as warnings and
keeps real errors at the error level; the socket is dropped either way. Found by fuzzing the
running server.
With storage blocked (some locked-down browser or OBS profiles) the member token issued at join
was not kept anywhere, so after any reconnect the server refused the member id and the Dock came
back as a new member. Values now also live in memory for the page load. Found by a chaos test
that kills and restarts the server under 20 real socket clients.
The server imports ws at runtime (WebSocket.OPEN in the room manager and the socket handler) and the
client's mock OBS server uses it in tests, but neither declared it: it only resolved because
@fastify/websocket and obs-websocket-js depend on it. Same version as already installed (8.22.0).
Found by Knip.
The root package was private but @streamcollab/shared, server and client were not, so an npm publish
across workspaces would have published them; they also had no license field (the project is MIT).
It started when the server created it, slightly after the calibrated key press, so markers pressed
in the first few milliseconds were all clamped to relative time 0. Found in the in-browser
walkthrough (four quick hotkeys all on 01:00:00:00).
…allback and known limits

From a CodeQL (security-and-quality) run:
- Room code characters were drawn with byte % 31, so 8 of the 31 characters came up more often;
  crypto.randomInt draws them uniformly (chi-square test added)
- New member ids came from Math.random; they now use crypto.getRandomValues (64 bits), which also
  works on plain-http LAN pages, so another device can't guess and claim an id first
- SECURITY.md: an email fallback while GitHub private vulnerability reporting is not enabled, and
  the by-design limits CodeQL pointed at (credentials in browser storage, ?server=)
… session timeline

Found in a real browser walkthrough with a mock OBS:
- The Dock sent color 'Blue' with every marker, which overrode the server's per-category colors
  (Kill red, Wipe fuchsia...): all Dock markers were blue in every export since v1.0.0. The color
  is now only sent when chosen; the e2e export test checks the exact colors per category.
- While OBS recorded, the big SMPTE clock showed the recording file's position with the session's
  base timecode (minutes apart from the markers listed under it). It shows the session timeline,
  on the server-calibrated clock, whenever a session runs.
The Resolve import steps said a per-POV EDL starts at 00:00:00:00; it uses the session's base
timecode (01:00:00:00 by default), with the recording file starting there.
@Consterna
Consterna merged commit 602f6e1 into main Oct 25, 2026
3 checks passed
@Consterna
Consterna deleted the fix/phase1-audit branch October 25, 2026 05:59
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.

1 participant