Skip to content

fix(security): close three HIGH-severity API issues#224

Merged
lstein merged 4 commits intomasterfrom
lstein/security/api-hardening
Apr 25, 2026
Merged

fix(security): close three HIGH-severity API issues#224
lstein merged 4 commits intomasterfrom
lstein/security/api-hardening

Conversation

@lstein
Copy link
Copy Markdown
Owner

@lstein lstein commented Apr 23, 2026

Summary

Closes four HIGH-severity findings from the API security scan. All fixes are localized, backwards-compatible for the legitimate UI, and covered by new tests.

H1 — Thumbnail color arbitrary-path write (search.py)

The color query param was interpolated straight into the on-disk thumbnail cache filename (thumb_dir / f"{...}_{color_hex}_r{radius}.png"). Because Python's Path / treats / characters in the right-hand string as real path segments, ?color=../../evil escaped the cache directory and PIL wrote a PNG to an arbitrary location. Now color must match #?[0-9A-Fa-f]{6} or \d{1,3},\d{1,3},\d{1,3} (what the parser actually supports); size and radius are bounded.

H2 — Unauthenticated POST /version/update and /version/restart (upgrade.py)

Both endpoints run pip install --upgrade photomapai / kill the process with no auth. They now:

  • Enforce PHOTOMAP_INLINE_UPGRADE server-side (previously only the UI button was hidden).
  • Require X-Requested-With: photomap, which forces a CORS preflight we do not answer — so a cross-origin simple POST from any tab the user visits cannot silently trigger either action.

about.js already sends the header from the legitimate caller.

H3 — SSRF via InvokeAI URL + queue_id path injection (invoke.py)

  • POST /invokeai/config now rejects URLs whose scheme is not http/https (plus malformed / empty-host values) so the stored URL can't be flipped to file://, javascript:, or similar and then used as an SSRF pivot from /status, /boards, /recall, /use_ref_image.
  • queue_id on RecallRequest / UseRefImageRequest is now pattern-gated to ^[A-Za-z0-9_.-]{1,64}$, so the body can't splice ../auth/login into the outbound URL path and hit arbitrary endpoints on the configured backend.

H4 — add_albumserve_image arbitrary-file-read chain (search.py)

POST /add_album accepts arbitrary absolute image_paths, and serve_image / get_image_by_name previously returned any file under those paths as long as validate_image_access's is_relative_to guard passed — that guard checks location only, not type. A caller could therefore:

POST /add_album  {"image_paths":["/etc"], "index":"/tmp/x.npz", ...}
GET  /images/<key>/passwd      # returns /etc/passwd

Both endpoints now require the resolved file's suffix to be in SUPPORTED_EXTENSIONS (the same allowlist the indexer uses).

Originally scored Medium (as filetree enumeration) until I recognised the chain gives an arbitrary-file-read primitive, which is High on its own. New test: tests/backend/test_image_type_guard.py.

Test plan

  • pytest tests — 177 backend tests pass, including new test_thumbnail_validation.py, test_upgrade_router.py, test_image_type_guard.py, and SSRF / queue_id coverage appended to test_invoke_router.py
  • npm test — 239 frontend tests pass
  • make lint — ruff / eslint / prettier clean
  • Manual: Update / restart from the About dialog still work
  • Manual: With PHOTOMAP_INLINE_UPGRADE=0 in the env, Update/Restart buttons return 403 from the backend
  • Manual: Cross-origin fetch("http://localhost:8050/version/update", {method:"POST"}) from an unrelated page returns 403 / never fires pip
  • Manual: Thumbnail grid still renders with the landmark color borders (?color=#e41a1c etc.)
  • Manual: Attempting to save file:///etc/passwd in the InvokeAI URL field surfaces a 400 in the settings panel
  • Manual: Existing albums still render their images in the slideshow and grid view (extension allowlist is not a regression for real albums)

🤖 Generated with Claude Code

lstein and others added 4 commits April 23, 2026 10:57
* Thumbnail ``color`` / ``size`` / ``radius`` are now whitelisted before
  the ``color`` string reaches the cache-filename construction. Values
  like ``?color=../../evil`` previously escaped the thumbnail cache
  directory via ``Path /`` concatenation and let PIL write a .png to an
  arbitrary location.
* ``POST /version/update`` and ``POST /version/restart`` now honour
  ``PHOTOMAP_INLINE_UPGRADE`` server-side (previously only the UI
  button was hidden) and require an ``X-Requested-With: photomap``
  header, which forces a CORS preflight that we do not answer — so a
  cross-origin simple POST from any page the user visits can no longer
  silently trigger a pip install or kill the server. The frontend
  already sends the header from ``about.js``.
* InvokeAI settings reject URLs whose scheme is not ``http``/``https``
  (and empty-host values like ``http://``) so the config field cannot
  be flipped to ``file://`` or ``javascript:`` and used as an SSRF
  pivot from ``/status``, ``/boards``, ``/recall`` or
  ``/use_ref_image``. The ``queue_id`` field is constrained to
  ``[A-Za-z0-9_.-]{1,64}`` so a request body can't splice ``../`` into
  the outbound path and reach arbitrary endpoints on the configured
  backend.

New tests: tests/backend/test_thumbnail_validation.py,
tests/backend/test_upgrade_router.py, plus SSRF / queue-id coverage
appended to tests/backend/test_invoke_router.py. 175 backend + 239
frontend tests pass; ruff / eslint / prettier clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
``POST /add_album`` accepts arbitrary absolute ``image_paths``, and
``serve_image`` / ``get_image_by_name`` previously returned any file
under those paths as long as ``validate_image_access``'s
``is_relative_to`` guard passed — the guard checks *location* only,
not *type*. A caller could therefore create an album with
``image_paths=["/etc"]`` and read ``/etc/passwd`` via
``GET /images/<key>/passwd``.

Both endpoints now require the resolved file's suffix to be in
``SUPPORTED_EXTENSIONS`` (the same allowlist the indexer uses).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…s inline

Previously the settings panel silently dropped the 400 returned for invalid
URLs (e.g. file:// schemes) and only flipped auth-row visibility for
unreachable backends, leaving the user with no feedback. The hint under the
URL field now turns red with a warning icon and shows the backend's detail
for: invalid scheme/host, unreachable host, and reachable-but-not-InvokeAI
servers. Restores the default hint once the URL is valid and reachable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@lstein lstein enabled auto-merge (squash) April 25, 2026 19:52
@lstein lstein merged commit 80388b1 into master Apr 25, 2026
6 checks passed
@lstein lstein deleted the lstein/security/api-hardening branch April 26, 2026 15:53
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