Security, perf, a11y & CI hardening from end-to-end audit #3

Closed
kleb wants to merge 27 commits from audit-fixes into electron-port
Owner

Remediation from the end-to-end audit. 26 commits, one per fix, each validated
with typecheck + lint (+ test where applicable). Branch builds on
electron-port.

P0 — release / ship-blockers

  • publish.owner + win.publisherName set to kleb (was the literal
    PLACEHOLDER_OWNER, which pointed the auto-updater at an unclaimed GitHub org).
  • New ci.yml + a gating check job in release.yml (typecheck/lint/test);
    both fail if PLACEHOLDER_OWNER reappears. bun-version pinned.
  • Production CSP now enforced via a build-only injected <meta> tag (header-only
    CSP never applied to file://). Dev/HMR untouched.
  • Filename-format template path-traversal closed in both builders.
  • macOS hardened-runtime entitlements plist (was killing bundled ffmpeg/ffprobe).
  • Vitest + 20 unit tests (sanitize/path-safety, Spotify-URL parser, rate-limit).

P1 — security / correctness / perf

  • SSRF guard (assertPublicHttpUrl + beforeRedirect re-validation, maxRedirects:5)
    on cover / Qobuz / Amazon / Tidal media + segment fetches. Localhost custom-API
    path unaffected.
  • OpenFolder directory-only via realpath; ReadTextFile/ReadImageAsBase64
    realpath-resolve before the extension gate.
  • Destructive library ops (delete/move/deleteEmptyFolders) confined to the
    last in-process scan's results and symlink-rejected.
  • safeStorage plaintext-fallback now warns once; settings.json written 0600;
    Amazon gateway filenames use the full sanitizer.
  • base-cache: drop per-write COUNT(*), memoize prepared statements, quarantine
    • recreate a corrupt DB, sweep expired rows at startup; external-DB handle reuse.
  • Dependabot for both workspaces + actions.

P2 / P3 — renderer / UI / a11y / UX / observability

  • manualChunks fix (framework chunk 306→214 kB), stable TrackList keys,
    QueueItem React.memo + stable onRemove.
  • Global :focus-visible; progress-toast / sidebar (aria-current) / spectrogram
    (role=img) a11y; SettingsPage header aligned to the page-header pattern.
  • Download-queue control errors now surface as toasts; inline Spotify-URL validation.
  • Disk log sink (rotating) + local-only crashReporter (no uploads).

Deferred (need GUI/runtime verification; too risky to land blind)

Push-based IPC migration, download state-machine rewrite, list virtualization,
React Compiler, the 5-file type-contract collapse, FFT worker pooling, partial-file
.part restructure, double-ISRC-rescan removal, cache request-coalescing /
negative-caching / schema-versioning.

Verify before release

Packaged script-src 'self' meta CSP needs a real GUI smoke test (file://
origin) — could not be verified without a desktop runtime.

Remediation from the end-to-end audit. 26 commits, one per fix, each validated with `typecheck` + `lint` (+ `test` where applicable). Branch builds on `electron-port`. ## P0 — release / ship-blockers - `publish.owner` + `win.publisherName` set to `kleb` (was the literal `PLACEHOLDER_OWNER`, which pointed the auto-updater at an unclaimed GitHub org). - New `ci.yml` + a gating `check` job in `release.yml` (typecheck/lint/test); both fail if `PLACEHOLDER_OWNER` reappears. `bun-version` pinned. - Production CSP now enforced via a build-only injected `<meta>` tag (header-only CSP never applied to `file://`). Dev/HMR untouched. - Filename-format template path-traversal closed in both builders. - macOS hardened-runtime entitlements plist (was killing bundled ffmpeg/ffprobe). - Vitest + 20 unit tests (sanitize/path-safety, Spotify-URL parser, rate-limit). ## P1 — security / correctness / perf - SSRF guard (`assertPublicHttpUrl` + `beforeRedirect` re-validation, `maxRedirects:5`) on cover / Qobuz / Amazon / Tidal media + segment fetches. Localhost custom-API path unaffected. - `OpenFolder` directory-only via `realpath`; `ReadTextFile`/`ReadImageAsBase64` realpath-resolve before the extension gate. - Destructive library ops (`delete`/`move`/`deleteEmptyFolders`) confined to the last in-process scan's results and symlink-rejected. - safeStorage plaintext-fallback now warns once; `settings.json` written `0600`; Amazon gateway filenames use the full sanitizer. - `base-cache`: drop per-write `COUNT(*)`, memoize prepared statements, quarantine + recreate a corrupt DB, sweep expired rows at startup; external-DB handle reuse. - Dependabot for both workspaces + actions. ## P2 / P3 — renderer / UI / a11y / UX / observability - `manualChunks` fix (framework chunk 306→214 kB), stable `TrackList` keys, `QueueItem` `React.memo` + stable `onRemove`. - Global `:focus-visible`; progress-toast / sidebar (`aria-current`) / spectrogram (`role=img`) a11y; SettingsPage header aligned to the page-header pattern. - Download-queue control errors now surface as toasts; inline Spotify-URL validation. - Disk log sink (rotating) + local-only `crashReporter` (no uploads). ## Deferred (need GUI/runtime verification; too risky to land blind) Push-based IPC migration, download state-machine rewrite, list virtualization, React Compiler, the 5-file type-contract collapse, FFT worker pooling, partial-file `.part` restructure, double-ISRC-rescan removal, cache request-coalescing / negative-caching / schema-versioning. ## Verify before release Packaged `script-src 'self'` meta CSP needs a real GUI smoke test (`file://` origin) — could not be verified without a desktop runtime.
kleb added 26 commits 2026-06-23 22:14:23 +02:00
The publish owner was the literal PLACEHOLDER_OWNER (baked into shipped
app-update.yml), which points electron-updater at an unclaimed, attacker-
registerable GitHub org -> repo-takeover RCE. win.publisherName was unset,
disarming the Windows Authenticode publisher check on updates.
The CSP delivered only through onHeadersReceived does not apply to file://
document loads, so packaged builds shipped with no enforced CSP. Inject a
<meta http-equiv> CSP at build time (dev/HMR untouched) mirroring the
window.ts header policy.

Note: needs a packaged smoke test to confirm script-src 'self' resolves
sibling assets under file:// (cannot be verified without a GUI runtime).
buildExpectedFilename and generateFilename interpolated the user/settings-
controlled format template verbatim while sanitizing only the substituted
values, so a custom format like '../../{title}' escaped the output directory
via path.join(). Strip separators from the composed name; built-in presets
are unaffected.
hardenedRuntime was enabled with no entitlements plist, which kills the
bundled ffmpeg/ffprobe child processes (and JIT) at launch on notarized
builds. Add entitlements (allow-jit, allow-unsigned-executable-memory,
disable-library-validation), wire mac.entitlements/entitlementsInherit, and
narrow the build/ gitignore so the static plist is tracked while generated
icons stay ignored.
Adds Vitest (none existed) and 20 tests covering the highest-risk pure
logic: sanitizeFilename/sanitizeFolderPath/buildExpectedFilename (including
the filename-template traversal guard), parseSpotifyURI (host allowlist),
and the rate-limit backoff/Retry-After helpers.
Adds ci.yml (typecheck + lint + test hard gates on push/PR; audit/format/
knip informational) and a gating 'check' job in release.yml that the build
job now needs:. Both fail if electron-builder.yml still contains
PLACEHOLDER_OWNER. Pins bun-version (was 'latest') for reproducibility.
Previously a v* tag published signed installers with zero checks.
Provider/song.link/cover/manifest responses are attacker-influenced and their
URLs were fetched with no scheme/host validation, and got followed redirects
with no re-validation. Add assertPublicHttpUrl() (https/http only; reject
loopback/private/link-local incl. 169.254.169.254) at the cover, Qobuz/Amazon
stream, and Tidal media/segment fetch sites, plus a global beforeRedirect
re-check and maxRedirects:5. The user's localhost custom Tidal API path is
unaffected (it does not flow through these media fetches).
OpenFolder passed any string to shell.openPath, which shell-executes
.exe/.bat/.lnk/documents; now resolve symlinks and require an actual
directory. ReadTextFile/ReadImageAsBase64 gated only on the raw extname,
bypassable via symlink/NTFS-ADS/trailing-dot; now realpath-resolve first and
read the resolved target.
deleteFilteredTracks/moveFilteredTracks/deleteEmptyFolders acted on arbitrary
renderer-supplied path lists with no root scoping and no symlink guard, so a
compromised renderer could delete/move/recursively-remove any file the user
can. Restrict each op to paths the most recent in-process scan actually
returned, and refuse symlinked targets (resolved absolute-path allowlist).
encodeTokenData silently stored Tidal tokens in plaintext when safeStorage
had no backend (e.g. Linux without a keyring); now warn once (without token
contents). settings.json holds the Spotify client secret and was written
world/group-readable; write it 0600 (+ chmod on POSIX).
No update automation existed; weekly grouped minor/patch PRs for the root
and frontend workspaces plus GitHub Actions. Pairs with the bun audit step
added to CI.
updateTotal() ran an O(rows) COUNT(*) on every cache insert, blocking the
synchronous node:sqlite engine on the main thread during downloads — yet the
value is never read (getStats recomputes it live). Replace with a timestamp
bump. Add a prepared-statement cache so the constant per-instance SQL is not
re-parsed/re-planned on every hit.
new DatabaseSync + schema exec were unguarded, so a corrupt cache file
(power loss mid-WAL-checkpoint, disk error) threw on first access and broke
every dependent feature with no self-heal. Quarantine the file (+ WAL
sidecars) and recreate once on open failure.
cleanExpired() ran only on explicit user action, so expiry bounded freshness
but never disk size and dead rows grew without bound. Sweep the expiring
caches (track/album/artist/songlink) once on app startup.
Every ISRC/cover lookup opened and closed the user DB (re-reading headers and
re-parsing the schema), called per-track in ISRC-fallback and the 10-worker
library-verification loop. Cache one read-only handle keyed by path.
The gateway-supplied track/artist names used a separator-only stripper that
let through control chars, Windows reserved device names, and trailing dots.
Use sanitizeFilename instead and drop the redundant stripGatewayFilenameChars.
id.includes('react') matched 'lucide-react' first, pulling the icon set into
the stable framework chunk. Match @radix-ui/lucide-react before the react
test so an icon-set bump no longer busts the framework chunk.
Focus visibility was opt-in per component, so controls without an explicit
ring (frameless titlebar buttons, command-palette rows, bare buttons) showed
no keyboard-focus indicator. Add a global outline fallback (WCAG 2.4.7).
Pause/Resume/Stop/Retry/Clear/Remove swallowed failures to console.error, so
a failed action looked like it did nothing. Show a toast.error on failure
(console.error kept for debugging).
With key={index}, sorting/filtering the track list re-associated row state
and cover <img> to whatever track moved into each slot, forcing full subtree
re-renders and cover reloads. Key by isrc/spotify_id/coverKey instead.
Settings rendered its title in the body font at font-bold with no kicker,
unlike every sibling page (font-display, font-semibold, spec kicker). Match
the canonical header so it looks like part of the redesign.
DownloadProgressToast button had no accessible name (icon + MB number only);
add aria-label, role=progressbar with aria-value*, and aria-hidden on the
decorative icons. Sidebar nav now sets aria-current=page on the active item
and labels the nav landmark.
Logs lived only in a RAM ring buffer, lost on the crash you most need to
debug, and native crashes produced no artifact at all. Add a best-effort
rotating disk sink (userData/logs/main.log, injected so logger.ts stays
electron-free/testable) and start crashReporter in local-only mode
(uploadToServer:false) for minidumps. No data leaves the machine.
A malformed or wrong-service URL was sent to the backend and surfaced as a
raw API error after a round-trip. Reject non-spotify.com http(s) links inline
with a clear message; spotify: URIs and bare ids still pass through.
The queue list re-rendered every row on each poll tick (~5/s during a
download) even when only one item changed. Wrap QueueItem in React.memo and
useCallback the parent's onRemove so only changed rows reconcile. Also label
the remove button (aria-label) and hide its decorative icon.
fix(a11y): add text alternative to the spectrogram canvas
Some checks are pending
CI / check (pull_request) Waiting to run
e003d3165f
The analyzer's primary output was an unlabeled <canvas> (axis labels painted
into the bitmap, invisible to assistive tech). Add role=img with an aria-label
summarizing sample rate, bit depth, and frequency ceiling.
Collaborator

kReview review

Verdict: no findings

No findings to address in the reviewed diff.

Overall risk is low; no concrete correctness or security regressions were verifiable from the provided diff and context.

Excluded as generated or vendored (not reviewed): build/entitlements.mac.plist, bun.lock.

Reviewed by kReview at 2f91e1a92a. This comment is conservative and based only on the PR diff, metadata, and supplied repository context.

Est. cost ~$0.51 total (97.7k in / 1.3k out) · this run ~$0.25 (49.0k in / 557 out) / gpt-5.5.

<!-- codex-forgejo-review --> <!-- codex-forgejo-review-head:2f91e1a92af5085d37f23a0b72a339ae58f59738 --> ## kReview review **Verdict:** no findings No findings to address in the reviewed diff. Overall risk is low; no concrete correctness or security regressions were verifiable from the provided diff and context. _Excluded as generated or vendored (not reviewed):_ `build/entitlements.mac.plist`, `bun.lock`. _Reviewed by kReview at `2f91e1a92a`. This comment is conservative and based only on the PR diff, metadata, and supplied repository context._ _Est. cost ~$0.51 total (97.7k in / 1.3k out) · this run ~$0.25 (49.0k in / 557 out) / gpt-5.5._ <!-- codex-forgejo-review-state:eyJoZWFkU2hhIjoiMmY5MWUxYTkyYWY1MDg1ZDM3ZjIzYTBiNzJhMzM5YWU1OGY1OTczOCIsInN1bW1hcnkiOiJPdmVyYWxsIHJpc2sgaXMgbG93OyBubyBjb25jcmV0ZSBjb3JyZWN0bmVzcyBvciBzZWN1cml0eSByZWdyZXNzaW9ucyB3ZXJlIHZlcmlmaWFibGUgZnJvbSB0aGUgcHJvdmlkZWQgZGlmZiBhbmQgY29udGV4dC4iLCJmaW5kaW5ncyI6W10sImN1bXVsYXRpdmVDb3N0Ijp7InVzZCI6MC41MDYxNzIsImlucHV0VG9rZW5zIjo5NzY4MCwib3V0cHV0VG9rZW5zIjoxMzIyLCJtb2RlbCI6ImdwdC01LjUifX0= -->
fix(db): reopen external DB when the file at the same path changes
Some checks failed
CI / check (pull_request) Has been cancelled
2f91e1a92a
Address PR review: the read-only handle cache keyed only by path string would
keep serving a stale connection if the user rewrites/replaces the SQLite file
at the same path (testDatabaseConnection is exactly that entry point). Key the
cache by path + mtime/size and reopen on change; stat stays far cheaper than
the open + schema-parse it replaces.
kleb closed this pull request 2026-06-24 08:41:56 +02:00
Some checks failed
CI / check (pull_request) Has been cancelled

Pull request closed

Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
kleb/SpotifFLAC!3
No description provided.