PyPI · Flask-Reuploaded
Flask-Reuploaded: Extension-denylist bypass via case-folding asymmetry in name-override path (incomplete-fix variant of CVE-2026-27641)
| Field | Value |
|---|---|
| Title | Extension-denylist bypass via case-folding asymmetry in name-override path (incomplete-fix variant of CVE-2026-27641) |
| Project | Flask-Reuploaded (flask_uploads) |
| Affected | <= 1.5.0 (latest release; commit ae31c3f91da40b465ca5e8f57d93f063b4553e23) |
| Relationship | Incomplete-fix variant of CVE-2026-27641 (fixed in v1.5.0; this variant survives that fix) |
| CWE | CWE-434 (Unrestricted Upload of File with Dangerous Type) + CWE-178 (Improper Handling of Case Sensitivity) |
| CVSS v3.1 (proposed) | ~7.0–7.3 (High, conditional) — CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:U/C:H/I:H/A:H. Final score deferred to maintainer. |
| Verified against | pip install Flask-Reuploaded==1.5.0, Python 3.11 |
The v1.5.0 fix for CVE-2026-27641 added an extension re-validation that runs when a caller supplies a name override to UploadSet.save(). It is routed through the case-preserving extension() helper, whereas the default upload path normalizes to lowercase via lowercase_ext() (inside get_basename()). The two paths disagree on case:
default path : get_basename("shell.PHP")
= lowercase_ext(secure_filename("shell.PHP")) = "shell.php"
-> extension("shell.php") = "php"
-> extension_allowed("php") -> DENY (correct)
name-override : secure_filename("shell.PHP") = "shell.PHP" (case preserved)
-> basename = "shell.PHP"
-> ext = extension("shell.PHP") = "PHP" (NOT lowercased)
-> extension_allowed("PHP") -> ALLOW (bypass)
On a denylist UploadSet the allow-check returns True for the mixed-case form:
# extensions.py:97
def __contains__(self, item): return item not in self.items
# "PHP" not in ('js','php','pl','py','rb','sh') -> True -> allowed
extension_allowed("PHP") passes the denylist that extension_allowed("php") is blocked by.
UploadSet.save(storage, name=...) lets the caller override the stored filename. After the CVE-2026-27641 fix, name is sanitized with secure_filename() and its extension re-validated. Because the re-validation compares a case-preserving extension against a policy whose denied tokens are lowercase, an attacker controlling name stores a file with a denied extension by varying case (shell.PHP, evil.pHp). On servers that resolve/execute extensions case-insensitively (Windows/macOS filesystems; Apache AddHandler/AddType), this re-enables the dangerous-upload → code-execution outcome the parent CVE addressed. The bypassed config is the one the library's own docs recommend for blocking scripts (see §6).
Permalinks pinned to commit ae31c3f91da40b465ca5e8f57d93f063b4553e23 (v1.5.0).
| Role | Location |
|---|---|
| Normalizing helper (default path) | src/flask_uploads/flask_uploads.py:283 — return lowercase_ext(secure_filename(filename)) |
save() entry |
src/flask_uploads/flask_uploads.py:285 |
| name-override sanitize | src/flask_uploads/flask_uploads.py:333 — name = secure_filename(name) |
| name-override assign (case kept) | src/flask_uploads/flask_uploads.py:341 — basename = name |
| Re-validation (non-normalizing) | src/flask_uploads/flask_uploads.py:344 — ext = extension(basename) |
| Allow-check | src/flask_uploads/flask_uploads.py:345 |
| Policy check | src/flask_uploads/flask_uploads.py:268 — extension_allowed |
| Containment backstop (path only) | src/flask_uploads/flask_uploads.py:364 |
| Sink | src/flask_uploads/flask_uploads.py:374 — storage.save(target) |
| Case-preserving extractor | src/flask_uploads/extensions.py:101 — def extension |
| Case-normalizing extractor | src/flask_uploads/extensions.py:109 — def lowercase_ext |
| Denylist membership | src/flask_uploads/extensions.py:97 — AllExcept.__contains__ |
| Documented script denylist | src/flask_uploads/extensions.py:34-35 |
name argument of UploadSet.save(storage, name=...) — commonly user-derived; the parent CVE-2026-27641 already treats name as attacker-controllable.secure_filename(name) stops traversal, but the extension policy check at :344-345 uses non-normalizing extension() against a lowercase-tokened policy. The realpath containment at :364 constrains the path, not the extension — orthogonal to this bypass.storage.save(target) at :374 writes the attacker-extensioned file into the served upload directory.CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:U/C:H/I:H/A:H ≈ 7.0–7.3 (High).
UploadSet uses a denylist — AllExcept(...) or populated config.deny. (Allowlists fail closed — §7c.)name to save().AddHandler/AddType).Why High, not a contrived misconfiguration — the bypassed control is the library's documented script-blocking mechanism:
extensions.py:34-35 — SCRIPTS: "…you might want to add php to the DENY setting."extensions.py:24-26 — EXECUTABLES: "…it's better suited for use with AllExcept."An app that followed this guidance to block scripts is exactly what this variant defeats.
Not claimed: not Critical. Pure allowlists are unaffected; execution requires a case-insensitive surface. Final score deferred to the maintainer.
Against shipped Flask-Reuploaded==1.5.0:
default path : lowercase_ext("shell.PHP") -> "php" -> extension_allowed("php") -> DENY
name-override : secure_filename("shell.PHP") -> "shell.PHP" -> extension(...) -> "PHP" -> extension_allowed("PHP") -> ALLOW
AllExcept(SCRIPTS))[1] save(storage("shell.PHP")) -> UploadNotAllowed (default path blocked)
[2] save(storage("upload.bin"), name="shell.PHP") -> saved "shell.PHP", on_disk=True (BYPASS)
[2b] save(storage("upload.bin"), name="evil.pHp") -> saved "evil.pHp", on_disk=True; containment intact
[3] allowlist IMAGES, name="shell.PHP" -> UploadNotAllowed: File extension 'PHP' is not allowed (fail-closed)
[4] allowlist IMAGES, name="photo.jpg" -> saved "photo.jpg" (legit unaffected)
The project ships a case-normalizing extractor (lowercase_ext, used by get_basename) specifically so configured extensions are "compare[d] … in the same case" (its docstring). The v1.5.0 re-validation instead calls the case-preserving extension(), so the new guard does not match the normalization the rest of the system relies on — the classic incomplete-fix shape where a hand-rolled check diverges from the canonical normalizer.
.PHP / .pHp / mixed-case dangerous files inside the served upload directory (path containment holds; extension policy defeated).Normalize before the policy check so the name-override path matches the default path:
Is your project exposed to this? Stateward checks every dependency on every pull request and flags it only if your code actually reaches it.
Check my repoSources: CISA KEV (public domain), OSV.dev & GitHub Advisory Database (CC-BY-4.0), FIRST EPSS, NVD/CWE (public domain). Served live from the Stateward advisory database.