Skip to content

fix(remote): symlink escape, decompression amplification, stalled requests - #58

Merged
bucknova merged 3 commits into
mainfrom
fix/remote-web-hardening
Sep 1, 2026
Merged

fix(remote): symlink escape, decompression amplification, stalled requests#58
bucknova merged 3 commits into
mainfrom
fix/remote-web-hardening

Conversation

@bucknova

@bucknova bucknova commented Sep 1, 2026

Copy link
Copy Markdown
Owner

Three findings from the adversarial security review of the embedded server.
All three verified against the real server, not just reasoned about.

1. Symlinks escaped the path-traversal fence

The opaque-id registry properly defeats client-supplied paths — the review
probed it with ../, double-encoding, absolute paths, ..;/ and null bytes,
and it held. But the registry is built by walking the gallery directories,
and Path.is_file() follows symlinks. So leak.png → ~/.ssh/id_rsa
dropped into a watched folder became a legitimate id whose bytes the server
served.

gallery_extra_dirs is documented as pointing at a shared folder, which is
precisely where someone else can create a symlink.

image_path() now resolves the target and requires containment;
thumbnail_path() inherits it. The entry still lists — filtering at scan
time would cost a resolve() per file per scan — but its bytes and thumbnail
are refused, which is the boundary that matters.

2. A 150 KB upload could allocate hundreds of megabytes

/api/compose/render needs only the token, not the TX gate. And Pillow's
MAX_IMAGE_PIXELS only raises above the limit — between 1× and 2×
it warns and decodes anyway. So our 32 MP cap was really 64 MP, and a
solid-colour PNG declaring 7000×7000 compresses to ~150 KB, well under the
12 MB upload cap.

Nothing bounded concurrent renders either; the reviewer drove the server from
44 MB to 3.3 GB RSS with six requests.

open() is lazy, so the declared dimensions are now checked before
load().

3. Stalled connections held threads forever, unauthenticated

BaseHTTPRequestHandler leaves timeout at None. A client that sent half
a header line and stopped held a request thread indefinitely — and it's
parked before auth runs, so no token is needed. A few hundred such
connections exhaust the thread pool and the fd limit and block the accept
loop for the operator's own phone.

Requests now time out at 15 s. That bounds reading the request, not writing
an SSE response.

Verified

1. gallery lists 2 item(s): ['real.png', 'leak.png']
   symlink target served? no — blocked
2. 49 MP payload (152 KB): HTTP 400 — rejected
3. stalled connection closed after 15.0s (server hung up)

A note on the tests

Two regression tests. The compose one nearly shipped meaningless: it
passed without the fix, because a bogus template id made render() return
None before the decode was ever reached. It now uses a real template id and
asserts that a normal photo through the same call succeeds — so a None can
only be the cap. Same failure mode we flagged on #53, caught here by adding a
positive control.

109 passed in tests/remote; ruff clean.

🤖 Generated with Claude Code

bucknova and others added 3 commits August 31, 2026 18:37
…uests

Three findings from the adversarial security review of the embedded server.

1. The opaque-id registry fences off client-supplied paths, but it is built
   by walking the gallery directories and Path.is_file() follows symlinks.
   A symlink dropped into images_save_dir or a gallery_extra_dir became a
   legitimate gallery id whose bytes the server would then serve — and
   gallery_extra_dirs is documented as pointing at a shared folder, which
   is exactly where an attacker can create one. image_path() now resolves
   the target and requires containment inside a configured gallery dir;
   thumbnail_path() inherits it.

   The entry still appears in the listing (filtering at scan time would
   cost a resolve() per file per scan); its bytes and thumbnail are
   refused, which is the security boundary.

2. /api/compose/render needs only the token, not the TX gate, and Pillow's
   MAX_IMAGE_PIXELS only raises above 2x the limit — between 1x and 2x it
   warns and decodes anyway. So a ~150 KB solid-colour PNG declaring
   7000x7000 cleared the 32 MP cap and materialised hundreds of MB of
   pixels, with nothing bounding concurrent renders. open() is lazy, so the
   declared size is checked before load().

3. BaseHTTPRequestHandler leaves timeout at None, so a client that sent
   half a header and stopped held a request thread forever — parked before
   auth runs, so no token needed. A few hundred such connections exhaust
   the thread pool and the fd limit and block the accept loop. Requests now
   time out at 15 s; that bounds reading the request, not writing an SSE
   response.

Verified against the real server: the symlink target is refused, a 49 MP /
152 KB payload gets a 400, and a stalled connection is closed after 15 s.

Two regression tests. Note the compose one nearly shipped meaningless — it
passed without the fix because a bogus template id returned None before the
decode was ever reached. It now uses a real template id and asserts a normal
photo through the same call succeeds, so a None can only be the cap.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…lates

It asserted that svc.list_templates() was non-empty, which held on a
machine with the starter pack installed and failed on all 17 CI jobs
where nothing had installed it. The test now installs the bundled
starter pack into a temp dir and points the service at it, so it stands
alone.

Verified it still fails without the pixel-cap fix.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@bucknova
bucknova merged commit b9fe957 into main Sep 1, 2026
18 checks passed
@bucknova
bucknova deleted the fix/remote-web-hardening branch September 1, 2026 03:37
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