fix(upload): narrow the admission check to the memory budget only

The admission check I just added rejected ANY image the decoder couldn't build —
corrupt, truncated, or unsupported, not only over-budget. That broke two
adversarial tests, and they were right to break.

07-adversarial/file-upload-attacks pins, deliberately, that acceptance follows the
MAGIC BYTES: a payload whose first three bytes are a JPEG header is accepted
regardless of what follows, because the security property under test is that the
client-declared Content-Type has no influence. Both failing cases upload 1024
bytes of JPEG magic followed by zeros. Rejecting those at admission is a
different, broader contract than the one asked for, and rewriting an adversarial
test to match new behaviour is precisely the thing that needs justifying rather
than doing quietly.

So admission now checks only what it was meant to: `exceeds_decode_budget`
returns true solely for `ImageError::Limits`. A corrupt file goes to the
compression worker exactly as before — which handles it gracefully and, since the
retry classifier in the previous commit, no longer burns backoff on it. The
resource guard is the part that had to move earlier; nothing else did.

Tests: the size agreement between admission and the worker is still asserted in
both directions, plus a new one writing a magic-bytes-only stub and asserting
admission accepts it WHILE the worker still rejects it — pinning the boundary
between the two checks so a future widening fails here rather than in the
adversarial suite.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
fabi
2026-07-29 07:55:56 +02:00
parent 674ea87bbd
commit ceb68939a7
2 changed files with 57 additions and 17 deletions

View File

@@ -239,12 +239,10 @@ pub async fn upload(
// vanish with, at best, a vague "could not be processed". Rejecting here gives them a
// reason at the door that they can act on, and it uses the SAME budget the worker
// enforces, so admission and processing cannot disagree.
if mime.starts_with("image/")
&& let Err(e) = crate::services::imaging::probe_decodable(&temp_abs)
{
if mime.starts_with("image/") && crate::services::imaging::exceeds_decode_budget(&temp_abs) {
let mp = crate::services::imaging::megapixels(&temp_abs);
tracing::info!(
error = ?e, %mime, megapixels = ?mp,
%mime, megapixels = ?mp,
"rejecting an image that exceeds the decode budget at admission"
);
let _ = tokio::fs::remove_file(&temp_abs).await;