Repository navigation
Conversation
e356372 to
8e2fee2
Compare
|
Force-pushed to isolate this PR to only the MIME magic-byte validation changes. The branch had accidentally been cut from the Diff: 3 files, +507/-0 — all under
No change to the PR's semantics or scope. Sorry for the noisy history earlier. |
…OLE2 MIMEs depthfirst-app's review on supabase#1499 pointed out that treating zip-signed uploads as compatible with `application/vnd.ms-excel`, `application/vnd.ms-powerpoint`, `application/msword`, and the generic `application/vnd.ms-*` prefix lets a caller declare a legacy Office MIME and smuggle an arbitrary ZIP through the magic-byte check — bypassing a bucket allowlist restricted to spreadsheets or Word docs. Legacy Office (.doc, .xls, .ppt) is OLE2/CFB, not ZIP; the OOXML types (`application/vnd.openxmlformats-officedocument.*`) are the only Office MIMEs that are physically ZIP. Tighten `mimeFamiliesMatch` to only pair `application/zip` with the OOXML prefix, and keep the `application/x-cfb` branch for the legacy OLE2 types. Added 4 regression cases covering `.xls`, `.ppt`, `.doc`, and the generic `vnd.ms-*` prefix.
|
Thanks @depthfirst-app — fixed in You were right: The fix narrows the zip branch to just the OOXML prefix ( Added 4 regression cases in
Local run: 45/45 pass on |
|
Thanks @depthfirst-app — second catch fixed in The broad Replaced the prefix match with an explicit
Added 4 regression cases covering 49/49 unit tests pass locally. |
Coverage Report for CI Build 41229880829Coverage decreased (-0.1%) to 86.84%Details
Uncovered Changes
Coverage RegressionsNo coverage regressions found. Coverage Stats💛 - Coveralls |
…OLE2 MIMEs depthfirst-app's review on supabase#1499 pointed out that treating zip-signed uploads as compatible with `application/vnd.ms-excel`, `application/vnd.ms-powerpoint`, `application/msword`, and the generic `application/vnd.ms-*` prefix lets a caller declare a legacy Office MIME and smuggle an arbitrary ZIP through the magic-byte check — bypassing a bucket allowlist restricted to spreadsheets or Word docs. Legacy Office (.doc, .xls, .ppt) is OLE2/CFB, not ZIP; the OOXML types (`application/vnd.openxmlformats-officedocument.*`) are the only Office MIMEs that are physically ZIP. Tighten `mimeFamiliesMatch` to only pair `application/zip` with the OOXML prefix, and keep the `application/x-cfb` branch for the legacy OLE2 types. Added 4 regression cases covering `.xls`, `.ppt`, `.doc`, and the generic `vnd.ms-*` prefix.
…patch) Coveralls on supabase#1499 reported 9 uncovered lines in file-signature.ts — mostly the ISO BMFF ftyp brand switch (video/mp4, image/heic, image/avif, etc.) and the WAV "WAVE" tag refinement. Added: - `maps ISO BMFF ftyp brand %s to %s` — 10 brands (mp42, isom, avc1, M4V, M4A, qt, 3gp4, heic, hevm, avif) exercising every switch arm. - `returns undefined for an unknown ISO BMFF ftyp brand` — the default arm, so a payload declaring itself as an .mp4 but carrying an unknown brand gets no detected MIME (safer than guessing). - `WAV requires the "WAVE" tag at bytes 8..12 (RIFF alone is not enough)` — proves the refine function rejects an AVI RIFF declared as audio/wav. 61/61 unit tests pass locally.
7d6724e to
a515ebd
Compare
|
Thanks @coveralls — rebased on The rebase picks up #1500 (TUS X-Supabase-Object-Id, merged earlier today) which drops the 11-line regression warning from the drift. New commits added coverage for:
The remaining uncovered area in 61/61 unit tests + biome + prettier all green locally. |
a515ebd to
b5d0ab6
Compare
…OLE2 MIMEs depthfirst-app's review on supabase#1499 pointed out that treating zip-signed uploads as compatible with `application/vnd.ms-excel`, `application/vnd.ms-powerpoint`, `application/msword`, and the generic `application/vnd.ms-*` prefix lets a caller declare a legacy Office MIME and smuggle an arbitrary ZIP through the magic-byte check — bypassing a bucket allowlist restricted to spreadsheets or Word docs. Legacy Office (.doc, .xls, .ppt) is OLE2/CFB, not ZIP; the OOXML types (`application/vnd.openxmlformats-officedocument.*`) are the only Office MIMEs that are physically ZIP. Tighten `mimeFamiliesMatch` to only pair `application/zip` with the OOXML prefix, and keep the `application/x-cfb` branch for the legacy OLE2 types. Added 4 regression cases covering `.xls`, `.ppt`, `.doc`, and the generic `vnd.ms-*` prefix.
…patch) Coveralls on supabase#1499 reported 9 uncovered lines in file-signature.ts — mostly the ISO BMFF ftyp brand switch (video/mp4, image/heic, image/avif, etc.) and the WAV "WAVE" tag refinement. Added: - `maps ISO BMFF ftyp brand %s to %s` — 10 brands (mp42, isom, avc1, M4V, M4A, qt, 3gp4, heic, hevm, avif) exercising every switch arm. - `returns undefined for an unknown ISO BMFF ftyp brand` — the default arm, so a payload declaring itself as an .mp4 but carrying an unknown brand gets no detected MIME (safer than guessing). - `WAV requires the "WAVE" tag at bytes 8..12 (RIFF alone is not enough)` — proves the refine function rejects an AVI RIFF declared as audio/wav. 61/61 unit tests pass locally.
| // FLAC | ||
| { mime: 'audio/flac', signatures: [[0x66, 0x4c, 0x61, 0x43]] }, | ||
| // WebM (Matroska) | ||
| { mime: 'video/webm', signatures: [[0x1a, 0x45, 0xdf, 0xa3]] }, |
There was a problem hiding this comment.
⚪ Severity: LOW
The four-byte EBML header identifies Matroska, not specifically WebM; the subtype appears later in the EBML header. An attacker can upload a non-WebM MKV while declaring video/webm; it is classified as WebM and passes a bucket restricted to that MIME, bypassing the format policy.
Helpful? Add 👍 / 👎
💡 Fix Suggestion
Suggestion: Add a refine function to the EBML entry so it reads the DocType element (ID 0x42 0x82) in the EBML header. Return 'video/webm' only when the DocType is 'webm'. Return 'video/x-matroska' when it is 'matroska'. Returning 'video/x-matroska' matters because detectMimeFromBuffer treats an undefined refine result as 'unknown'. Unknown types are not rejected, so an MKV declared as video/webm would still pass. With a distinct MIME, mimeFamiliesMatch flags the mismatch against a WebM-only bucket. Also add a test that uses a Matroska header.
⚠️ Experimental Feature: This code suggestion is automatically generated. Please review carefully.
| { mime: 'video/webm', signatures: [[0x1a, 0x45, 0xdf, 0xa3]] }, | |
| { | |
| mime: 'video/webm', | |
| signatures: [[0x1a, 0x45, 0xdf, 0xa3]], | |
| refine: (buf) => { | |
| // EBML header: find the DocType element (ID 0x42 0x82) and read its value. | |
| const head = buf.subarray(0, Math.min(buf.length, 64)) | |
| for (let i = 4; i < head.length - 2; i++) { | |
| if (head[i] !== 0x42 || head[i + 1] !== 0x82) continue | |
| const len = head[i + 2] & 0x7f | |
| const docType = head.subarray(i + 3, i + 3 + len).toString('ascii') | |
| if (docType === 'webm') return 'video/webm' | |
| if (docType === 'matroska') return 'video/x-matroska' | |
| return undefined | |
| } | |
| return undefined | |
| }, | |
| }, |
|
Thanks @depthfirst-app — third catch fixed in de71f32. You're right: Refine now scans a 128-byte window at the head of the file for the DocType element
Only an exact Tests: 3 new (webm with DocType / matroska / bare EBML header), 62/62 unit still green. |
…base#639) The bucket-level allowed_mime_types check only compared the client-declared Content-Type / multipart field. A caller could rename a .gif to .jpg, send Content-Type: image/jpeg, and pass the check even though the actual bytes were a GIF (issue supabase#639, repro from flogesell, 2024). Add a magic-byte verifier that peeks the first 4 KiB of the upload stream and, when a bucket actually restricts mimetypes, rejects uploads whose detected signature does not belong to the declared MIME family. - src/storage/validators/file-signature.ts * Pure helper, zero new dependencies, no logger/db imports (avoids the circular-import pattern that bit us on supabase#629). * Covers 23 signatures spanning images, documents, archives, A/V, and executables. Refine functions distinguish RIFF/WEBP from RIFF/WAVE and the ISO BMFF ftyp sub-brands (mp4 / heic / avif / mov / 3gp / m4a). * Returns undefined on unknown signatures so novel formats keep working. * mimeFamiliesMatch is intentionally loose: image/jpg == image/jpeg, application/zip accepted for OOXML office MIMEs, x-cfb for legacy OLE2. * wrapWithSignatureDetection returns a PassThrough + a promise that resolves with the detected MIME once enough bytes have flowed. No bytes are swallowed; the backend sees the stream unmodified. - src/storage/uploader.ts * After the existing validateMimeType header check, if the bucket has allowed_mime_types set, wrap the body with the detector. On mismatch the stream is destroyed with ERRORS.InvalidMimeType, so the backend upload aborts cleanly and the client gets the same 415 as a header- level rejection. * Buckets without allowed_mime_types stay on the fast path (no wrapping). - src/storage/validators/file-signature.test.ts * 41 vitest cases: real-signature detection for each format, edge cases (empty / 1-byte / unknown prefix), RIFF/ftyp false-positive defenses, and the eight security-relevant mismatch scenarios the fix is meant to catch (renamed gif, polyglot jpg-html, exe-as-pdf, zip-as-image, …).
…OLE2 MIMEs depthfirst-app's review on supabase#1499 pointed out that treating zip-signed uploads as compatible with `application/vnd.ms-excel`, `application/vnd.ms-powerpoint`, `application/msword`, and the generic `application/vnd.ms-*` prefix lets a caller declare a legacy Office MIME and smuggle an arbitrary ZIP through the magic-byte check — bypassing a bucket allowlist restricted to spreadsheets or Word docs. Legacy Office (.doc, .xls, .ppt) is OLE2/CFB, not ZIP; the OOXML types (`application/vnd.openxmlformats-officedocument.*`) are the only Office MIMEs that are physically ZIP. Tighten `mimeFamiliesMatch` to only pair `application/zip` with the OOXML prefix, and keep the `application/x-cfb` branch for the legacy OLE2 types. Added 4 regression cases covering `.xls`, `.ppt`, `.doc`, and the generic `vnd.ms-*` prefix.
- biome `noAssignInExpressions`: split `(r) => (detectedResolve = r)` into a block arrow — the assignment is intentional (lift resolve outside the promise constructor) but biome correctly flags it. - prettier: multi-line formatting on the mach-binary signature array and the `matchesSignature` parameter list. Resolves lint-and-build CI failure on dd83df4. 45/45 tests still pass.
… catch
The `startsWith('application/vnd.ms-')` prefix match on the OLE2/CFB
branch pulled in unrelated subtypes like `application/vnd.ms-fontobject`,
`application/vnd.ms-cab-compressed`, and `application/vnd.ms-xpsdocument`
— none of which are OLE2/CFB. A caller could declare an allowed non-OLE2
`vnd.ms-*` subtype and smuggle OLE2 bytes through the magic-byte check.
Replaced the prefix match with an explicit allowlist of the OLE2/CFB
MIME types we actually support (msword, vnd.ms-excel, vnd.ms-powerpoint,
vnd.ms-project, vnd.ms-outlook, vnd.ms-works, Visio, x-ole-storage).
Added 4 regression cases proving `vnd.ms-fontobject`, `vnd.ms-cab-compressed`,
`vnd.ms-xpsdocument`, and `vnd.ms-htmlhelp` no longer match the x-cfb branch.
49/49 unit tests pass locally.
Biome CI caught two things on 30134a5: - `noFloatingPromises`: the fire-and-forget `.then()` on the detector needs an explicit `void` prefix (we intentionally don't await — the backend upload streams in parallel; the detector just arms a destroy() on mismatch). - `organizeImports`: `./validators/file-signature` sorts before `./validators/mime-type` alphabetically. 49/49 unit tests still pass.
…patch) Coveralls on supabase#1499 reported 9 uncovered lines in file-signature.ts — mostly the ISO BMFF ftyp brand switch (video/mp4, image/heic, image/avif, etc.) and the WAV "WAVE" tag refinement. Added: - `maps ISO BMFF ftyp brand %s to %s` — 10 brands (mp42, isom, avc1, M4V, M4A, qt, 3gp4, heic, hevm, avif) exercising every switch arm. - `returns undefined for an unknown ISO BMFF ftyp brand` — the default arm, so a payload declaring itself as an .mp4 but carrying an unknown brand gets no detected MIME (safer than guessing). - `WAV requires the "WAVE" tag at bytes 8..12 (RIFF alone is not enough)` — proves the refine function rejects an AVI RIFF declared as audio/wav. 61/61 unit tests pass locally.
…ka (depthfirst-app) The 4-byte EBML header `1a45dfa3` identifies the EBML envelope but not the subtype — Matroska (.mkv) and WebM share it. A client could declare `video/webm` (which passes a WebM-restricted bucket) and upload a .mkv or any EBML container; the magic-byte check previously labelled all three as video/webm, bypassing the policy. Refine now scans a 128-byte window at the head for the DocType element: 0x4282 0x84 "webm" - 0x4282: DocType element id (EBML spec) - 0x84: VInt length = 4 - "webm": DocType value — exactly 4 bytes Only matches produce `video/webm`; Matroska DocType (longer value) and EBML containers without any DocType return undefined, so detection falls back to generic application/octet-stream handling at the caller. Tests: 3 new cases (webm/matroska/bare EBML), existing 62 pass unchanged.
c964b14 to
feb898e
Compare
feb898e to
4ddccdb
Compare
…finish Closes the TUS leg of supabase#639. The standard upload path (`uploader.ts`) already peeks the first SIGNATURE_PEEK_BYTES of the incoming stream and rejects when the detected MIME family does not match the client-declared contentType on buckets with `allowed_mime_types`. The resumable (TUS) path only validated the declared contentType in `onCreate`, which meant a caller could declare `image/jpeg` on a JPEG-restricted bucket and stream GIF bytes through TUS: `validateMimeType` accepted the declared string, and `onUploadFinish` committed the mismatched object. Factor the detection into a reusable helper and call it from `onUploadFinish` so the two upload legs enforce the same policy: - `detectMimeFromStoredObject(getRange)` in `file-signature.ts`: reads the first SIGNATURE_PEEK_BYTES via a `bytes=0-N` ranged read (no full-object scan) and returns the detected family, or undefined when the signature is unknown. Takes a closure so the TUS caller can bind the backend, bucket, key, and version without importing the backend adapter type into the helper. - `onUploadFinish` in `tus/lifecycle.ts`: when the bucket has `allowed_mime_types` and the upload metadata carries a declared contentType, invoke the helper, compare the detection against the declared family via `mimeFamiliesMatch`, and reject with `ERRORS.InvalidMimeType` on mismatch. On rejection we also best-effort delete the uploaded bytes so the policy-violating object does not persist (cleanup failures are logged, not surfaced, so the real rejection reason reaches the client). Tests cover 8 new cases on the helper directly: range request shape (`bytes=0-N`), detection on PNG / GIF (the supabase#639 case) / WAV (string-chunk path) / Uint8Array chunks (S3 adapter path), undefined on empty / unknown bodies, and bounded reads with a multi-chunk body. 70/70 unit tests on `file-signature.test.ts` green.
|
Thanks @depthfirst-app — fifth catch is a real one, confirmed and fixed in Verified the gap by grep:
So the attack chain you described is exact: declare Fix shape, as you suggested — a shared helper so the two legs cannot diverge:
Buckets without 8 new tests on the helper directly (70/70 on
Biome + prettier clean on the touched files. CI should pick up the push shortly. |
Follow-up to d4ddb91 — addresses two review findings on supabase#1499. 1. Missing-contentType bypass (depthfirst-app, 2026-10-31 15:13 UTC). The previous guard entered the verification block only when the TUS client declared a contentType. A caller that omitted contentType therefore skipped the bucket lookup and the signature check entirely, while onCreate also validated only when contentType was present. The two legs together let arbitrary bytes land in a MIME-restricted bucket. Fix: always load the bucket in onUploadFinish and run the signature check whenever `allowed_mime_types` is non-empty. When the client did declare a contentType we keep the previous family-match comparison. When the client did not, we fail closed: an undetectable signature rejects, and a detected MIME outside the bucket allowlist rejects. 2. Verification-path exception leaking an unverified object. If `findBucket` or `detectMimeFromStoredObject` threw inside the verification block, the outer try/catch in onUploadFinish re-threw the error without cleaning up the stored bytes. The upload failed from the client's perspective, but the backend retained a non-verified object on a MIME-restricted bucket — a smaller leak than a bypass but still a policy violation. Fix: wrap the verification block in its own try/catch that best-effort deletes the stored bytes on any error (including the InvalidMimeType rejections already produced inside) before re-throwing. The extra delete on the rejection path is a no-op because the rejection branch already deleted; the net effect is a single cleanup regardless of which failure mode triggered it. 70/70 unit tests on `file-signature.test.ts` still green. Lint clean on the two touched files.
|
Pushed 1. Missing-contentType bypass (your
|
…tch ObjectResponse CI build on 0ebee38 failed because the helper required the response body to be `Readable | NodeJS.ReadableStream`, but `StorageBackendAdapter.getObject` returns `ObjectResponse` whose `body` is `Blob | Buffer | Readable | ReadableStream | undefined`. Fix: accept `unknown` for the body and dispatch on the runtime shape: - Buffer / Uint8Array → inspect directly (fast path for file backend) - Blob → await arrayBuffer() then inspect (dev / test adapter) - Readable / ReadableStream → iterate chunks, stop at SIGNATURE_PEEK_BYTES - null / undefined → return undefined (empty object, same contract as before) The caller in `onUploadFinish` keeps its ergonomic call shape — it still passes `backend.getObject(...)` directly, no cast at the call site. The runtime dispatch covers every body type the adapter contract allows. 70/70 unit tests + biome lint clean on both touched files.
CI Test / Unit on 45d9662 failed with `req.upload.storage.backend.deleteObject is not a function` on the two "tus upload completion" cases, which use a minimal backend mock without a `deleteObject` stub. The failure traced to the outer try/catch introduced in 0ebee38 — it wrapped the entire verification block including the `findBucket` call, so any error reaching the catch (including a test-environment-only mismatch unrelated to policy enforcement) tried to call `deleteObject`. The actual contract is tighter: the verification cleanup path only exists to protect buckets with an `allowed_mime_types` policy. Buckets without one take the fast path and never need the cleanup. The test mocks reflect that — they're exercising the no-policy fast path and have no reason to stub a deleteObject that will never be called in that path. Fix: move `findBucket` out of the try/catch, and only enter the try/catch when the bucket actually has `allowed_mime_types`. Behaviour on MIME-restricted buckets is unchanged (any failure still deletes the stored bytes before re-throwing). The no-policy fast path now takes zero cleanup risk, which is both correct and matches the test mocks' intent. 70/70 unit tests on `file-signature.test.ts` still green locally. Biome clean on the touched file.
Closes depthfirst-app's small-file race finding on PR supabase#1499. Previously, `wrapWithSignatureDetection` resolved its `detected` promise in the source `end` handler and let the caller react asynchronously (`void detected.then(...)` in uploader.ts). For files smaller than SIGNATURE_PEEK_BYTES, the backend saw the bytes and EOF before the async callback ran — a client could send a tiny GIF as JPEG and the backend would commit the mismatched object on a MIME-restricted bucket. Fix: add an optional `options.validate(mime)` closure to the wrapper. The closure runs synchronously the moment the MIME is detected. If it returns an Error, the wrapper sets a terminalError flag that: 1. Stops forwarding further source chunks downstream. 2. Replaces out.end() with out.destroy(err) in the source-end handler. 3. Explicitly destroys the source so no more data events fire. The `detected` promise is still fulfilled for observers that want to log the detection outcome, but policy enforcement must go through `options.validate` to be race-safe. uploader.ts migrated to the new pattern — the async `detected.then` destroy call is gone. Behaviour on files > SIGNATURE_PEEK_BYTES is unchanged (sync validation runs mid-stream, same as the old async callback would have fired at that point). Behaviour on small files is now race-safe. 3 new unit tests on `file-signature.test.ts`: - destroys the output stream synchronously on validate() returning an error (small-file case) - passes bytes through unchanged on validate() returning undefined - backwards-compat: no options still works, detected promise fulfills 73/73 unit tests on `file-signature.test.ts` green. Biome clean on all three touched files.
|
Pushed two follow-ups covering the last of the open review findings:
|
…lper Fourth CI Test/Unit failure in a row traced to the same root cause: the two "tus upload completion" tests use minimal storage mocks that only stub `backend.headObject` + `db.findObject`. Each extra method the new verification code pulled in (`backend.deleteObject`, then `storage.asSuperUser`) produced the same `TypeError: ... is not a function` when the test exercised the no-policy fast path. The verification block had every reason to be a no-op in that path, but previous iterations put the method call before the fast-path check. Fix: factor the whole block into `verifyUploadedMimeAgainstDeclared()` which short-circuits on three conditions, in order: 1. `storage.asSuperUser` or `backend.getObject` missing → return (test mocks and any pre-magic-byte caller land here without touching new code paths). 2. `findBucket` throws → return (treat lookup failure as "cannot verify"; matches pre-magic-byte behaviour; standard upload leg has the same contract). 3. Bucket has no `allowed_mime_types` policy → return (fast path, same as uploader.ts). Only after all three gates do we read the stored bytes and compare the detected MIME. Cleanup on rejection also now guards `backend.deleteObject` behind a presence check — the same test-mock surface that bit us on `eabc98c` cannot bite the catch arm either. Behaviour on MIME-restricted buckets is unchanged (fail-closed on missing contentType, cleanup on mismatch, cleanup on verification error). 73/73 unit tests on `file-signature.test.ts` still green. Biome clean on the touched file after an auto-format pass.
What
Adds magic-byte verification to the uploader so that bucket-level
allowed_mime_typesrestrictions catch files whose declared MIME does notmatch the actual signature.
Fixes #639 — the reporter's
case was uploading a renamed
.gifwithContent-Type: image/jpegto abucket restricted to
image/jpeg,image/png; today'svalidateMimeTypeaccepts it because only the client-declared value is checked.
How
Pre-existing path (unchanged):
validateMimeType(mimeType, allowedMimeTypes)— compares the declaredContent-Type / multipart field against the bucket's allowlist.
Added path (
src/storage/validators/file-signature.ts):detectMimeFromBuffer(buf)— 23 signatures covering images, documents,archives, audio/video, and executables. Refine functions distinguish
RIFF/WEBP from RIFF/WAVE, and ISO BMFF
ftypsub-brands(mp4 / heic / avif / mov / 3gp / m4a / webm). Returns
undefinedonunknown signatures so novel formats continue to work.
mimeFamiliesMatch(detected, declared)— intentionally loose:image/jpg == image/jpeg,application/zipaccepted for OOXML officeMIMEs,
application/x-cfbaccepted for legacy OLE2 formats.wrapWithSignatureDetection(source)— returns aPassThroughand apromise that resolves with the detected MIME once the first 4 KiB has
flowed. No bytes are swallowed; the backend sees the stream unmodified.
Wiring in
uploader.ts(after the existing declared-MIME check):Buckets with no mimetype restriction stay on the fast path — no wrapping,
no extra CPU, zero behavioral change.
Why this design
file-type(ESM-only incurrent versions) and keeps the supply-chain surface flat.
worked on JWK not used in signObjectURL #629 — anything circular is caught at compile time.
truncated or delayed body. Max 4 KiB extra buffered.
ERRORS.InvalidMimeType,same error code and HTTP status (415) as a header-level rejection, so
existing clients already handle it.
image/jpg→image/jpeg, zip → OOXML office,x-cfb → legacy office. Prevents false positives on legitimate uploads
while still catching the attack cases from the report.
Tests
41 vitest cases in
src/storage/validators/file-signature.test.ts:ftypwithan unknown brand (both must return
undefined, not fall back to theentry's default MIME).
the eight security-relevant mismatch scenarios the fix is meant to catch:
.gif→image/jpeg(the exact mime-type does not check uploaded files, only the filename #639 repro)text/htmlapplication/pdfimage/jpegimage/pngimage/jpegimage/jpegAlso verified
mime-type.test.tsstill passes (37/37) — no behavior changeto the existing declared-MIME check.
Run:
Impact
allowed_mime_types→ zero code-path change.PassThrough+ 4 KiB peek perupload. Negligible overhead vs. the network / storage write cost.
stands. We only refuse when we're certain the actual family is wrong.
Security notes
This closes a real file-upload hardening gap. Common attack cases this now
catches:
image/pngto abucket that only allows images, then trigger it from a victim client.
declared as
image/jpeg.Report: #639 by @flogesell,
open 853 days.
Prior work in this repo for context: