This repository has been archived on 2026-06-29. You can view files and clone it, but you cannot make any changes to it's state, such as pushing and creating new issues, pull requests or comments.
Second adversarial Opus audit found 2 CRIT + 7 HIGH + 9 MED + 9 LOW
against HEAD 2b948d7. This sprint lands all CRIT, the 5 highest-value
HIGH items, and the most impactful MEDs. Remaining items deferred (see
notes at end).
CRIT-1 (race + torn write on Watch Later / search-history JSON state):
The persistence layer was load->mutate->save with plain open(w). Two
real failure modes were live:
- Lost-update race: phone fires two RunPlugin wl_add back-to-back,
each in a fresh Python interpreter. Interpreter A reads [], inserts
AAA, writes [AAA]; B was already past _load_watch_later (also read
[]) and writes [BBB] — AAA lost.
- Torn-write loss: open(w) truncates immediately. Kodi crash / Pi
yank between truncate and json.dump finish leaves a zero-byte or
partial file. _load* catches JSONDecodeError and returns [],
silently wiping the user's pinned-videos list on next read.
Fix: _atomic_write_json (mkstemp + fsync + os.replace) + _with_lock
(fcntl.flock on a sibling .lock file). All four persistence functions
(_record_search, _clear_search_history, _add_to_watch_later,
_remove_from_watch_later) refactored to wrap load->mutate->save under
the lock. os.replace is atomic on POSIX same-filesystem; flock is
per-process advisory across the concurrent plugin interpreters.
CRIT-2 (rip allowlist bypass via ..): RIP_DEST_ALLOWLIST check was
string-level starts_with. '/storage/.kodi/temp/../../etc/cron.d' passed
the allowlist but escaped to anywhere. Dormant op (no Python caller
today) but the protocol is a wide-open arbitrary-write primitive
under the sidecar UID (root on LibreELEC). Fix: literal-prefix check
first (cheap reject), then create_dir_all + tokio::fs::canonicalize,
then a second check against the same allowlist on the canonical
result. Defeats .. and symlink escape.
HIGH-1 (SponsorBlock 1 MiB cap was post-hoc): The cap from the first
audit landed AFTER resp.bytes().await — which buffers the entire body
unbounded. A hostile mirror returning multi-GB would OOM the Pi before
the cap fired. Fix: stream via resp.chunk() in a loop, bail as soon as
accumulated bytes > cap. Defends OOM during ingest, not after.
HIGH-2 (Container.Refresh fires in wrong context): _wl_remove_action
unconditionally fired Refresh. Future refactors that expose the Remove
context menu outside the Watch Later listing (or stale context-menu
state across navigation) would refresh the wrong container — search
results would reload pointlessly when the user just hit Remove in WL.
Fix: only Refresh when Container.FolderPath contains
'action=watch_later'.
HIGH-3 (thumbnail shape — promoted from first-audit MED-9): The new
Watch Later code persists rustypipe Player.details which has a
thumbnail string in many versions. _pick_thumbnail called max() on a
string — iterates chars and crashes with AttributeError on the .get
lookup. Live crash-on-render of Watch Later. Fix: _pick_thumbnail now
accepts Any and dispatches on isinstance: empty/None -> '', str ->
str, dict -> .url, list -> filter for dicts then max-by-area.
HIGH-4 (search query unvalidated — length, control chars, surrogates):
Search took an arbitrary string from the addon and shipped it to
rustypipe. Three failure modes:
- 10 MB query allocates 10 MB on stdin pipe + another 10 MB in
serde_json::from_str + materializes in rustypipe's HTTP call. ~30
MB blip per request on the 1 GB Pi.
- Newlines in query produce multi-line entries in
tracing::info!(query, ...) — log injection (mild on this stack,
but obfuscates real entries).
- Lone UTF-16 surrogates aren't a crash but produce a confusing
error path.
Fix: validate_query() at the sidecar dispatch — 2 KB length cap,
reject control chars (TAB allowed since YouTube treats it as
whitespace). Addon-side _record_search now also collapses whitespace
via ' '.join(query.split()).
MED-1 (clear_history flicker): _clear_history_action finalized with
succeeded=True THEN Container.Update — caused a half-tick of empty
directory before the nav replaced it. Fix: succeeded=False first,
then the replace navigates cleanly.
MED-3 (LibreELEC-only fallback): _addon_data_path's xbmcvfs fallback
hardcoded /storage/.kodi/userdata/... — non-existent on Linux desktop
Kodi etc. Fix: fall back to ~/.kodi/userdata/addon_data/... which is
portable. Tests + dev rigs now persist correctly.
MED-4 (Response::ok clobber): Added debug_assert! so any future op
returning its own 'ok' key is caught in debug builds. Not a fix
per se — but a tripwire.
MED-5 (codec regex single-quote): Made _MIME_CODEC_RE accept either
quote style. Belt-and-braces against upstream MIME format drift.
MED-8 (SponsorBlockMonitor orphaned across plugin boundary): When we
delegate to plugin.video.youtube, our monitor runs in our plugin's
context against xbmc.Player()'s global state. If the user starts a
different video mid-monitor, the segments are for the old one — would
spuriously skip the new content. Fix: capture player.getPlayingFile()
at start, bail if it changes mid-loop.
MED-9 / LOW-2 carried over from first audit and tracked here too.
Smoke-verified via JSON-RPC (browse-only, no playback, someone still
watching the TV):
- 3000-byte query rejected: 'query too long: 3000 bytes (cap 2048)'
- newline-in-query rejected: 'query contains control characters'
- legit search returns expected results
- wl_add + watch_later directory renders without thumbnail crash
Remaining deferred (cosmetic / no current impact):
- MED-2 (WL staleness): accept for v0.1, add lazy refresh later
- MED-6 (byte-length validate_youtube_id error message): cosmetic
- MED-7 (_lan_ip multi-NIC): not currently a problem on the family
LAN, document and revisit if Tailscale lands on the Pi
- LOW-1..9: defensive polish, no real risk
- First-audit's deferred MED/LOWs: still defer-able
Addon v0.0.16.