CategoryFetcher.fetch_post pulls a post-view HTML page and runs
_TAG_ELEMENT_RE.finditer over the full body. The regex itself is
linear (no catastrophic backtracking shape), but a hostile server
returning hundreds of MB of HTML still pegs CPU walking the buffer.
Caps the body the regex sees at 2MB — well above any legit
Gelbooru/Moebooru post page (~30-150KB).
Truncation rather than streaming because httpx already buffers the
body before _request returns; the cost we're cutting is the regex
walk, not the memory hit. A full streaming refactor of fetch_post
is a follow-up that the audit explicitly flagged as out of scope
("not catastrophic — defense in depth").
Audit-Ref: SECURITY_AUDIT.md finding #14
Severity: Informational
The previous flow streamed the full body to disk and called
_is_valid_media after completion. A hostile server that omits
Content-Type (so the early text/html guard doesn't fire) could
burn up to MAX_DOWNLOAD_BYTES (500MB) of bandwidth and cache-dir
write/delete churn before the post-download check rejected.
Refactors _do_download to accumulate chunks into a small header
buffer until at least 16 bytes have arrived, then runs
_looks_like_media against the buffer before committing to writing
the full payload. The 16-byte minimum handles servers that send
tiny chunks (chunked encoding with 1-byte chunks, slow trickle,
TCP MSS fragmentation) without false-failing on the first chunk.
Extracts _looks_like_media(bytes) as a sibling to _is_valid_media
(path) sharing the same magic-byte recognition. _looks_like_media
fails closed on empty input — when called from the streaming
validator, an empty header means the server returned nothing
useful. _is_valid_media keeps its OSError-fallback open behavior
for the on-disk path so transient EBUSY doesn't trigger a delete
+ re-download loop.
Audit-Ref: SECURITY_AUDIT.md finding #10
Severity: Low
The previous floors-only scheme would let a future `pip install` pull
in any new major release of httpx, Pillow, PySide6, or python-mpv —
including ones that loosen safety guarantees we depend on (e.g.
Pillow's MAX_IMAGE_PIXELS, httpx's redirect-following defaults).
Caps each at the next major version. Lock-file generation is still
deferred — see TODO.md for the follow-up (would require adding
pip-tools as a new dev dep, out of scope for this branch).
Audit-Ref: SECURITY_AUDIT.md finding #9
Severity: Low
The cap is now installed by core/__init__.py (previous commit), so
the line in cache.py is redundant. Removing it leaves a single
authoritative location for the security-critical PIL setting.
Audit-Ref: SECURITY_AUDIT.md finding #8
Severity: Low
PIL's decompression-bomb cap previously lived as a side effect of
importing core/cache.py. Any future code path that touched core/images
(or any other core submodule) without first importing cache would
silently revert to PIL's default 89M-pixel *warning* (not an error),
re-opening the bomb surface.
Moves the cap into core/__init__.py so any import of any
booru_viewer.core.* submodule installs it first. The duplicate set
in cache.py is left in place by this commit and removed in the next
one — both writes are idempotent so this commit is bisect-safe.
Audit-Ref: SECURITY_AUDIT.md finding #8
Severity: Low
render_filename_template's sanitization stripped reserved chars,
control codes, whitespace, and `..` prefixes — but did not catch
Windows reserved device names (CON, PRN, AUX, NUL, COM1-9, LPT1-9).
On Windows, opening `con.jpg` for writing redirects to the CON
device, so a tag value of `con` from a hostile booru would silently
break Save to Library.
Adds a frozenset of reserved stems and prefixes the rendered name
with `_` if its lowercased stem matches. The check runs
unconditionally (not Windows-gated) so a library saved on Linux
can be copied to a Windows machine without breaking on these
filenames.
Audit-Ref: SECURITY_AUDIT.md finding #7
Severity: Low
Replaces the inline f-string concatenation of post.source into the
RichText document with a call through build_source_html(), which
escapes both the href value and the visible display text.
Also escapes the filetype field for defense-in-depth — the value
comes from a parsed URL suffix (effectively booru-controlled) and
the previous code interpolated it raw.
Removes the dead duplicate setText() call that wrote a plain-text
version before being overwritten by the RichText version on the
next line.
Audit-Ref: SECURITY_AUDIT.md finding #6
Severity: Medium
Extracts the rich-text Source-line builder out of info_panel.py
into a Qt-free module so it can be unit-tested under CI (which
installs only httpx + Pillow + pytest, no PySide6).
The helper html.escape()s both the href and the visible display
text, and only emits an <a> tag for http(s) URLs — non-URL
sources (including javascript: and data: schemes) get rendered
as escaped plain text without a clickable anchor.
Not yet wired into InfoPanel.set_post; that lands in the next
commit.
Audit-Ref: SECURITY_AUDIT.md finding #6
Severity: Medium
Replaces the unbounded defaultdict(asyncio.Lock) with an OrderedDict
guarded by _get_url_lock() and _evict_url_locks(). The cap is 4096
entries; LRU semantics keep the hot working set alive and oldest-
unlocked-first eviction trims back toward the cap on each new
insertion.
Eviction skips locks that are currently held — popping a lock that
a coroutine is mid-`async with` on would break its __aexit__. The
inner loop's evicted-flag handles the edge case where every
remaining entry is either the freshly inserted hash or held; in
that state the cap is briefly exceeded and the next insertion
retries, instead of looping forever.
Audit-Ref: SECURITY_AUDIT.md finding #5
Severity: Medium
The sites table stores api_key + api_user in plaintext. Previous
behavior left the DB file at the inherited umask (0o644 on most
Linux systems) so any other local user could sqlite3 it open and
exfiltrate every booru API key.
Adds Database._restrict_perms(), called from the lazy conn init
right after _migrate(). Tightens the main file plus the -wal and
-shm sidecars to 0o600. The sidecars only exist after the first
write, so the FileNotFoundError path is expected and silenced.
Filesystem chmod failures are also swallowed for FUSE-mount
compatibility.
behavior change from v0.2.5: ~/.local/share/booru-viewer/booru.db
is now 0o600 even if a previous version created it 0o644.
Audit-Ref: SECURITY_AUDIT.md finding #4
Severity: Medium
The data directory holds the SQLite database whose `sites` table
stores api_key and api_user in plaintext. Previous behavior used
the inherited umask (typically 0o755), which leaves the dir
world-traversable on shared workstations and on networked home
dirs whose home is 0o755. Tighten to 0o700 unconditionally on
every data_dir() call so the fix is applied even when an older
version (or external tooling) left the directory loose.
Failures from filesystems that don't support chmod (some FUSE
mounts) are swallowed — better to keep working than refuse to
start. Windows: no-op, NTFS ACLs handle this separately.
behavior change from v0.2.5: ~/.local/share/booru-viewer is now
0o700 even if it was previously 0o755.
Audit-Ref: SECURITY_AUDIT.md finding #4
Severity: Medium
Same fix as danbooru.py and e621.py — Gelbooru's params dict
carries api_key + user_id when configured. Route through
redact_params() before the debug log emits them.
Audit-Ref: SECURITY_AUDIT.md finding #3
Severity: Medium
Same fix as danbooru.py — the search() log.debug params line
previously emitted login + api_key. Route through redact_params().
Audit-Ref: SECURITY_AUDIT.md finding #3
Severity: Medium
The log.debug(f" params: {params}") line in search() previously
dumped login + api_key to the booru logger at DEBUG level. Route
the params dict through redact_params() so the keys are replaced
with *** before formatting.
Audit-Ref: SECURITY_AUDIT.md finding #3
Severity: Medium
The httpx request event hook converts request.url to a str so
log_connection can parse it — at that point the credential query
params (login, api_key, etc.) are in scope and could be captured
by any traceback, debug hook, or monitoring agent observing the
hook call. Pipe through redact_url() first so the rendered string
never carries the secrets, even transiently.
Audit-Ref: SECURITY_AUDIT.md finding #3
Severity: Medium
detect_site_type constructs a fresh BooruClient._shared_client
directly (bypassing the BooruClient.client property) for the
/posts.json, /index.php, and /post.json probes. The hooks set
here are the ones installed on that initial construction — if
detection runs before any BooruClient instance's .client is
accessed, the shared singleton must still have SSRF validation
and connection logging.
This additionally closes finding #16 for the detect client — site
detection requests now appear in the connection log instead of
being invisible.
behavior change from v0.2.5: Test Connection from the site dialog
now rejects private-IP targets. Adding a local/RFC1918 booru via
the "auto-detect type" dialog will fail with "blocked request
target ..." instead of probing it. Explicit api_type selection
still goes through the BooruClient.client path, which is also
now protected.
Audit-Ref: SECURITY_AUDIT.md finding #1
Also-Closes: SECURITY_AUDIT.md finding #16 (detect half)
Severity: High
E621 maintains its own httpx.AsyncClient because their TOS requires
a per-user User-Agent string that BooruClient's shared client can't
carry. The client is rebuilt on User-Agent change, so the hook must
be installed in the same construction path.
Also installs BooruClient._log_request as a second hook (this
additionally closes finding #16 for the e621 client — e621 requests
previously bypassed the connection log entirely, and this wires
them in consistently with the base client).
Audit-Ref: SECURITY_AUDIT.md finding #1
Also-Closes: SECURITY_AUDIT.md finding #16 (e621 half)
Severity: High
Adds validate_public_request to the cache module's shared httpx
client event_hooks. Covers image/video/thumbnail downloads, which
are the most likely exfil path — file_url comes straight from the
booru JSON response and previously followed any 3xx that landed,
so a hostile booru could point downloads at a private IP. Every
redirect hop is now rejected if the target is non-public.
The import is lazy inside _get_shared_client because
core.api.base imports log_connection from this module; a top-level
`from .api._safety import ...` would circular-import through
api/__init__.py during cache.py load. By the time
_get_shared_client is called the api package is fully loaded.
Audit-Ref: SECURITY_AUDIT.md finding #1
Severity: High
Adds validate_public_request to the BooruClient event_hooks list so
every request (and every redirect hop) is checked against the block
list from _safety.py. Danbooru, Gelbooru, and Moebooru subclasses
all go through BooruClient.client and inherit the protection.
Preserves the existing _log_request hook by listing both hooks in
order: validate first (so blocked hops never reach the log), then
log.
Audit-Ref: SECURITY_AUDIT.md finding #1
Severity: High
Introduces core/api/_safety.py containing check_public_host and the
validate_public_request async request-hook. The hook rejects any URL
whose host is (or resolves to) loopback, RFC1918, link-local
(including 169.254.169.254 cloud metadata), CGNAT, unique-local v6,
or multicast. Called on every request hop so it covers both the
initial URL and every redirect target that httpx would otherwise
follow blindly.
Also exports redact_url / redact_params for finding #3 — the
secret-key set lives in the same module since both #1 and #3 work
is wired through httpx client event_hooks. Helper is stdlib-only
(ipaddress, socket, urllib.parse) plus httpx; no new deps.
Not yet wired into any httpx client; per-file wiring commits follow.
Audit-Ref: SECURITY_AUDIT.md finding #1
Severity: High
Replaces the inline mpv.MPV(...) literal kwargs with a call through
build_mpv_kwargs(), which adds ytdl=no, load_scripts=no, a lavf
protocol whitelist (file,http,https,tls,tcp), and POSIX input_conf
lockdown. Closes the yt-dlp delegation surface (CVE-prone extractors
invoked on attacker-supplied URLs) and the concat:/subfile: local-
file-read gadget via ffmpeg's lavf demuxer.
behavior change from v0.2.5: any file_url whose host is only
handled by yt-dlp (youtube.com, reddit.com, etc.) will no longer
play. Boorus do not legitimately return such URLs, so in practice
this only affects hostile responses. Cached local files and direct
https .mp4/.webm/.mkv continue to work.
Manually smoke tested: played a cached local .mp4 from the library
(file: protocol) and a fresh network .webm from a danbooru search
(https: protocol) — both work.
Audit-Ref: SECURITY_AUDIT.md finding #2
Severity: High
Extracts the mpv.MPV() kwargs into a Qt-free pure function so the
security-relevant options can be unit-tested on CI (which lacks
PySide6 and libmpv). The builder embeds the audit #2 hardening —
ytdl="no", load_scripts="no", and a lavf protocol whitelist of
file,http,https,tls,tcp — alongside the existing playback tuning.
Not yet wired into _MpvGLWidget; that lands in the next commit.
Audit-Ref: SECURITY_AUDIT.md finding #2
Severity: High
booru-viewer-git is now on the AUR — lead the Linux install section
with it for Arch-family distros, keep the source-build path for other
distros and dev use.
pyproject.toml and installer.iss both used 'v0.2.5' — not PEP 440
compliant, so hatchling silently normalized it to '0.2.5' in wheel
builds. Align the source strings with what actually gets shipped.
GitHub's YAML issue forms require `description:`, not `about:` (which
is for the legacy markdown templates). GitHub silently ignores forms
with invalid top-level fields, so only the config.yml contact links
were showing in the new-issue picker.
- preview_pane: unsave button now checks self._is_saved instead of
self._save_btn.text() == "Unsave", which stopped matching after the
button text became a Unicode icon (✕ / ⤓)
- popout: new _exec_menu_at_button helper uses menu.popup() +
QEventLoop blocked on aboutToHide instead of menu.exec(globalPos).
On Hyprland the popout gets moved via hyprctl after Qt maps it and
Qt's window-position tracking stays stale, so exec(btn.mapToGlobal)
resolved to a global point on the wrong monitor, flashing the menu
there before the compositor corrected it. popup() routes through a
different positioning path that anchors correctly.
- Render "Once" loop icon as bold "1×" text via QPainter drawText
instead of the hand-drawn line art
- Responsive controls bar: hide volume slider below 320px, duration
label below 240px, current time label below 200px
- _toggle_play seeks to 0 if paused at EOF so pressing play replays
the video in Once mode instead of doing nothing
- Fix stray "Auto" text leaking through the autoplay icon — the
autoplay property setter was still calling setText
- ThumbnailWidget detects clicks outside the pixmap and calls
grid.on_padding_click() via parent walk (signals + event filters
both failed on Wayland/QScrollArea)
- Grid tracks a pending rubber band origin; only activates past 30px
manhattan distance so small clicks deselect cleanly
- Move/release events forwarded from ThumbnailWidget to grid for both
the pending-drag check and the active rubber band drag
- Fixed mapFrom/mapTo direction (mapFrom's first arg must be a parent)