fix(storage): keep the real format for files sniffed as their container by HarshMN2345 · Pull Request #13825 · appwrite/appwrite · GitHub
Skip to content

fix(storage): keep the real format for files sniffed as their container - #13825

Merged
HarshMN2345 merged 18 commits into
mainfrom
fix/issue-9418
Sep 23, 2026
Merged

HarshMN2345 merged 18 commits into
mainfrom
fix/issue-9418

Conversation

@HarshMN2345

@HarshMN2345 HarshMN2345 commented Sep 22, 2026 •

Copy link
Copy Markdown
Member

What does this PR do?

Content sniffing recognises the container a file is packed in, not the format packed inside it. An APK is a zip, so one whose archive starts with res/ is reported as application/zip, while the same app built with AndroidManifest.xml near the front is reported as application/vnd.android.package-archive. The sniffed value is persisted as the file's mimeType and echoed back as Content-Type on download, next to a Content-Disposition filename carrying the real extension. Responses also carry X-Content-Type-Options: nosniff, so a client that trusts the declared type over the filename saves the download under the wrong one.

APK is not alone. Measured with mime_content_type() — the call Appwrite makes — against libmagic 5.47 in the runtime image:

upload sniffs as stored after this change
.jar built by Gradle or zip application/zip application/java-archive
.war built by zip application/zip application/java-archive
.aar application/vnd.android.package-archive application/java-archive
.kmz application/zip application/vnd.google-earth.kmz
.3mf application/zip model/3mf
.usdz application/zip model/vnd.usdz+zip
.xpi application/zip application/x-xpinstall
.msix application/zip application/msix
real .ttf font/sfnt font/ttf
.otf application/vnd.ms-opentype font/otf

.aar is the sharp edge: probing libmagic's zip rules shows it has exactly two container shortcuts, on a first entry named AndroidManifest.xml or classes.dex. An Android library starts with AndroidManifest.xml too, so every AAR upload is currently stored as an Android app. Everything else zip-based falls through to application/zip unless libmagic has a full-file heuristic for it — OOXML, ODF and EPUB do, and are left alone.

The fallback is driven by app/config/storage/formats.php, alongside the existing mimes.php / inputs.php / outputs.php. The upload path and the S3 gateway previously each carried their own list and had already drifted apart on which types count as ambiguous; both now read the one table.

The extension is consulted only when sniffing already returned an ambiguous container type, and every type it can resolve to is binary. None of them appears in app/config/storage/mimes.php, so GET /view still serves all of them as text/plain — nothing becomes renderable, and the stored-XSS property of the view endpoint is unchanged.

Test Plan

  • pint → {"tool":"pint","result":"passed"} on all four changed files.
  • Sniffed each row above in the running container: zip-built archives with the first entry each toolchain writes, plus real TTF and OTF fonts. Archives built by the JDK jar tool are already detected as application/java-archive and are not affected. plain.zip stays application/zip, DOCX/EPUB/WOFF2 and an already-correct APK are untouched, and .aab is left alone since no registered type exists for it.
  • Asserted the safety invariant in code rather than by eye: array_intersect(formats.extensions, storage-mimes) is empty, as is the intersection for the ambiguous list.
  • S3 contentType() run in the container against real bodies: a font sent as application/octet-stream is stored as font/ttf, gzip bytes named file.jar stay application/gzip, and an SVG named .ttf stays image/svg+xml. With no body, the extension resolves only an ambiguous or generic type.
  • e2e: public/fonts/Poppins-Bold.ttf, already in the repo, added to testFileViewContentType. It sniffs as font/sfnt and must be stored as font/ttf and viewed as text/plain. No new fixture.

Related PRs and Issues

Two things deliberately left out:

  • Already-stored files keep their existing mimeType. A previously uploaded APK or JAR stays wrong until re-uploaded; backfilling needs a migration.
  • metadata['content_type'] is still the uncorrected sniff, so on S3-backed storage the bucket object keeps Content-Type: application/zip while the Appwrite row is right. Download and view read the row, so the user-visible bug is fixed, but anything reading the bucket directly still sees the container type.

Text formats have the same shape of problem (.md, .yaml, .sql and .toml all flatten to text/plain; a minified .js sniffs as text/csv) but need text/plain and text/csv in the ambiguous set, which is a far wider blast radius than container types. Left for a separate discussion. .ts in particular must stay sniff-first — it is both TypeScript and MPEG transport stream, and both resolve correctly today.

Checklist

  • Have you read the Contributing Guidelines on issues?
  • If the PR includes a change to an API's metadata (desc, label, params, etc.), does it also include updated API specs and example docs? — no metadata change

Content sniffing identifies a zip container from the entries at the head of
the archive, so an APK whose archive starts with res/ is reported as
application/zip while the same app built with AndroidManifest.xml near the
front is reported as application/vnd.android.package-archive. The sniffed
value is persisted as the file's mimeType and echoed back as Content-Type on
download, next to a Content-Disposition filename ending in .apk. Responses
also carry X-Content-Type-Options: nosniff, so a client that trusts the
declared type over the filename saves the download as a .zip.

Fall back to the filename extension when, and only when, sniffing already
returned a generic container type. The fallback can only ever resolve to
another binary type, so a file can never be promoted from its extension to
something a browser would render and the stored-XSS property of the view
endpoint is unchanged.
@greptile-apps

greptile-apps Bot commented Sep 22, 2026 •

Copy link
Copy Markdown
Contributor

RetriggerConfidence Score: 5/5

The PR appears safe to merge; no outstanding correctness, security, or repository-rule violation remains.

Summary

This PR centralizes extension-based correction for formats that content sniffing identifies only as their underlying binary container.

  • Adds a shared table of ambiguous MIME types and their extension-specific formats.
  • Applies the correction to both Storage API and S3 upload paths.
  • Preserves body sniffing for generic S3 content types.
  • Adds an API-level regression case using a real TrueType font while confirming /view remains non-renderable.

Reviews (15) · Last reviewed commit: "Merge branch 'main' into fix/issue-9418"

Comment thread src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Create.php Outdated
Comment thread src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Create.php Outdated
@github-actions

github-actions Bot commented Sep 22, 2026 •

Copy link
Copy Markdown

✨ Benchmark results

Comparing main (before) → fix/issue-9418 (after).

Metric Before After Change
🚀 Requests/sec 206.5 206.54 ⚪ 0%
⏱️ Latency P50 84.03 ms 84.32 ms ⚪ +0.3%
⏱️ Latency P95 194.66 ms 194.82 ms ⚪ +0.1%
Per-scenario breakdown & investigation details

Metrics below reflect the current branch (after). Δ P95 compares against the base.

Scenario P50 (ms) P95 (ms) Requests RPS Δ P95 (ms)
API total 84.32 194.82 12,768 206.54 +0.16
Account 165.09 310.21 672 11.16 +8.82
TablesDB 80.62 145 6,944 114.13 -13.02
Storage 80.01 168.62 3,360 57.18 +6.17
Functions 121 240.02 1,792 31.2 +6.85

Top API waits (after)

API request Max wait (ms)
functions.variables.update 476.75
account.name.update 460.68
account.prefs.update 433
functions.variables.delete 403.36
storage.files.preview 368.92

Comment thread src/Appwrite/Platform/Modules/S3/Http/S3/Base.php Outdated
HarshMN2345 and others added 2 commits September 22, 2026 18:33
The S3 gateway resolves an object's content type itself, so the Storage API
regression does not reach it. Sign a PutObject with SigV4 (the access key is
the project ID, the secret an API key) and read the object back: an .apk
declared as a plain zip downloads as application/vnd.android.package-archive,
while the same bytes under a .zip key keep application/zip.

The e2e client only passed a string body through for text/plain, so any other
declared content type turned the body into a query string. Send a string body
verbatim under any content type instead.
Comment thread tests/e2e/Services/Storage/StorageCustomServerTest.php Outdated
HarshMN2345 and others added 4 commits September 22, 2026 19:05
The test hand-rolled AWS Signature V4 — credential scope, signing key,
canonical headers and signature — which restated the gateway's own
verification rather than exercising observable S3 behaviour. Drive the
PutObject and the mime-type read through Utopia\Storage\Device\S3, which
already signs AWS4-HMAC-SHA256 and accepts a full URL as its host, so the
test only sees what any S3 client sees.

The e2e client no longer needs to pass a raw string body through, so its
body handling goes back to what it was.
The S3 case only read the type back through the gateway's own HEAD, so
it never covered what a client actually receives. Upload through the S3
device, then download each object through the Storage API and assert the
Content-Type header.
…r types

Sniffing reports the container a file is packed in, not the format inside it.
A zip-based JAR, WAR, KMZ, 3MF, USDZ, XPI or MSIX comes back as a plain zip, a
TrueType font as the generic SFNT container it shares with OpenType, and an AAR
as an APK because both start with AndroidManifest.xml.

The upload path and the S3 gateway each carried their own list, and the two had
already drifted apart on which types count as ambiguous. Both now read
app/config/storage/formats.php.

No type the table maps to is in storage-mimes, so the view endpoint still serves
every one of them as text/plain and nothing becomes renderable.
@HarshMN2345 HarshMN2345 changed the title fix(storage): keep the APK mime type on uploads sniffed as plain zip fix(storage): keep the real format for files sniffed as their container Sep 23, 2026
Comment thread src/Appwrite/Platform/Modules/S3/Http/S3/Base.php Outdated
The fixture was a 635-byte zip crafted to sniff as a plain archive, not an APK
any build tool would produce, and the property it turned on — that its first
entry sorts before AndroidManifest.xml — was invisible in review. Building the
same bytes in the test would only move the fabrication, so both cases go.

Real coverage needs an archive from an actual Android build, which is too large
to commit.
Gradle writes JARs without the 0xCAFE extra field the JDK jar tool adds, so
libmagic reports them as application/zip. The fixture is the unmodified
output of a one-class 'java' plugin build.
Reuses public/fonts/Poppins-Bold.ttf, which sniffs as font/sfnt, instead of
committing a new JAR fixture.
A generic or empty Content-Type returned the mapped extension type before the
body was inspected, so gzip bytes named file.jar were stored as a JAR. The
extension now only resolves an ambiguous declared or sniffed type, matching the
Storage API.
@HarshMN2345

Copy link
Copy Markdown
Member Author

@HarshMN2345
HarshMN2345 merged commit d3d38b3 into main Sep 23, 2026
49 of 50 checks passed
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.

🐛 Bug Report: APK files downloads in zip files

2 participants