fix(remote): symlink escape, decompression amplification, stalled requests - #58
Merged
Conversation
…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>
# Conflicts: # CHANGELOG.md
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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. Soleak.png → ~/.ssh/id_rsadropped into a watched folder became a legitimate id whose bytes the server
served.
gallery_extra_dirsis documented as pointing at a shared folder, which isprecisely 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 scantime would cost a
resolve()per file per scan — but its bytes and thumbnailare refused, which is the boundary that matters.
2. A 150 KB upload could allocate hundreds of megabytes
/api/compose/renderneeds only the token, not the TX gate. And Pillow'sMAX_IMAGE_PIXELSonly raises above 2× 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 beforeload().3. Stalled connections held threads forever, unauthenticated
BaseHTTPRequestHandlerleavestimeoutatNone. A client that sent halfa 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
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()returnNonebefore the decode was ever reached. It now uses a real template id andasserts that a normal photo through the same call succeeds — so a
Nonecanonly 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