Flask-Reuploaded vulnerable to Remote Code Execution via Server-Side Template Injection
Description
Flask-Reuploaded provides file uploads for Flask. A critical path traversal and extension bypass vulnerability in versions prior to 1.5.0 allows remote attackers to achieve arbitrary file write and remote code execution through Server-Side Template Injection (SSTI). Flask-Reuploaded has been patched in version 1.5.0. Some workarounds are available. Do not pass user input to the name parameter, use auto-generated filenames only, and implement strict input validation if name must be used.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
Flask-Reuploaded <1.5.0 allows path traversal and extension bypass via the `name` parameter, leading to arbitrary file write and RCE through SSTI.
Vulnerability
Flask-Reuploaded prior to version 1.5.0 fails to properly sanitize the name parameter in file uploads, allowing path traversal and extension bypass. An attacker can supply a malicious name value containing path separators (e.g., ../../../) to write files outside the intended upload directory, or bypass extension validation to upload executable files such as templates that lead to Server-Side Template Injection (SSTI) [1][2][3].
Exploitation
The vulnerability is exploitable remotely without authentication, requiring only the ability to send a crafted multipart request to an endpoint that uses the library's save() function with user-controlled name parameter. No special network position is needed; the attack surface is any Flask application using Flask-Reuploaded that passes unsanitized user input to the name argument [3].
Impact
Successful exploitation achieves arbitrary file write and, by uploading a malicious template, remote code execution through SSTI. This grants the attacker full control over the server, potentially leading to data theft, service disruption, or further lateral movement [2][3].
Mitigation
Flask-Reuploaded version 1.5.0 patches the issue by sanitizing filenames with secure_filename() and re-validating extensions after name override [1][4]. Workarounds include avoiding user input in the name parameter, using auto-generated filenames, and implementing strict input validation if name must be used [3].
AI Insight generated on May 19, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
flask-reuploadedPyPI | < 1.5.0 | 1.5.0 |
Affected products
2- Range: <1.5.0
- jugmac00/flask-reuploadedv5Range: < 1.5.0
Patches
1d64c6b2f71cbSecurity fix: prevent path traversal and extension bypass
3 files changed · +302 −5
CHANGES.rst+17 −3 modified@@ -5,11 +5,25 @@ Changelog ------------------ - drop support for Python 3.8 and 3.9 - add support for Python 3.13 - -1.4.1 (unreleased) ------------------- - migrate from setup.py to pyproject.toml configuration - fix doc building on read the docs +- **SECURITY FIX**: Fix critical path traversal and extension bypass vulnerability (CVE pending, CVSS 9.8) + + - Apply ``secure_filename()`` to the ``name`` parameter to prevent path traversal attacks + - Re-validate file extension after ``name`` override to prevent extension bypass + - Add path containment check to ensure files are saved within the upload directory + - Sanitize folder component when extracted from ``name`` parameter + + **Impact**: This vulnerability allowed remote attackers to write files to arbitrary locations + on the filesystem and bypass extension restrictions, potentially leading to remote code + execution via Server-Side Template Injection (SSTI) in Flask applications. + + **Credit**: Jaron Cabral (Cal Poly Humboldt) for discovery and reporting + + **Recommendation**: All users should upgrade to this version immediately. Do not pass + user-controlled input to the ``name`` parameter in older versions. + + 1.4.0 (2023.10.03) ------------------
src/flask_uploads/flask_uploads.py+39 −2 modified@@ -309,8 +309,18 @@ def save( if not isinstance(storage, FileStorage): raise TypeError("storage must be a werkzeug.FileStorage") + # Track if name ends with dot before any processing + name_ends_with_dot = name is not None and name.rstrip().endswith('.') + if folder is None and name is not None and "/" in name: folder, name = os.path.split(name) + # Check again after split + name_ends_with_dot = name.rstrip().endswith('.') + # Sanitize folder and name extracted from name parameter + if folder: + folder = secure_filename(folder) + if name: + name = secure_filename(name) if storage.filename is None: raise ValueError("Filename must not be empty!") basename = self.get_basename(storage.filename) @@ -319,12 +329,27 @@ def save( raise UploadNotAllowed() if name: - if name.endswith('.'): - basename = name + extension(basename) + # Sanitize name parameter to prevent path traversal + name = secure_filename(name) + if not name: + raise ValueError("Invalid filename after sanitization") + + if name_ends_with_dot and not name.endswith('.'): + # Restore the dot if it was removed by secure_filename + basename = name + '.' + extension(basename) else: basename = name + # Re-validate extension after name override + ext = extension(basename) + if ext and not self.extension_allowed(ext): + raise UploadNotAllowed( + f"File extension '{ext}' is not allowed" + ) + if folder: + # Additional sanitization of folder parameter + folder = secure_filename(folder) target_folder = os.path.join(self.config.destination, folder) else: target_folder = self.config.destination @@ -334,6 +359,18 @@ def save( basename = self.resolve_conflict(target_folder, basename) target = os.path.join(target_folder, basename) + + # Verify path containment to prevent directory traversal + target_real = os.path.realpath(target) + dest_real = os.path.realpath(self.config.destination) + if not ( + target_real.startswith(dest_real + os.sep) or + target_real == dest_real + ): + raise ValueError( + "Security: Path traversal attempt detected" + ) + storage.save(target) if folder: return posixpath.join(folder, basename)
tests/test_flask_reuploaded.py+246 −0 modified@@ -6,13 +6,16 @@ import os import os.path +import shutil +import tempfile from unittest.mock import Mock from unittest.mock import patch import pytest from flask import Flask from flask import url_for from flask_uploads import ALL +from flask_uploads import IMAGES from flask_uploads import AllExcept from flask_uploads import TestingFileStorage from flask_uploads import UploadConfiguration @@ -394,3 +397,246 @@ def test_configure_for_set_throws_runtimeerror() -> None: app = Flask(__name__) with pytest.raises(RuntimeError): config_for_set(upload_set, app) + + +class TestSecurityFixes: + """Tests for security vulnerability fixes. + + These tests verify that path traversal and extension bypass vulnerabilities + have been properly fixed. + """ + + def test_path_traversal_prevention_via_name_parameter(self) -> None: + """Verify path traversal via `name` is prevented.""" + with tempfile.TemporaryDirectory() as tmpdir: + uset = UploadSet("files", ALL) + uset._config = Config(tmpdir) + tfs = TestingFileStorage(filename="safe.txt") + + result = uset.save(tfs, name="../../../etc/passwd") + + assert "../" not in result + assert "passwd" in result + assert tfs.saved is not None + assert "passwd" in tfs.saved + assert os.path.realpath(tfs.saved).startswith( + os.path.realpath(tmpdir) + ) + + def test_absolute_path_prevention_via_name_parameter(self) -> None: + """Verify absolute paths in `name` are sanitized.""" + with tempfile.TemporaryDirectory() as tmpdir: + uset = UploadSet("files", ALL) + uset._config = Config(tmpdir) + tfs = TestingFileStorage(filename="safe.txt") + + result = uset.save(tfs, name="/etc/passwd") + + assert "passwd" in result + assert tfs.saved is not None + assert "passwd" in tfs.saved + + def test_extension_bypass_prevention_via_name_parameter(self) -> None: + """Verify extension validation cannot be bypassed via `name`.""" + with tempfile.TemporaryDirectory() as tmpdir: + uset = UploadSet("photos", IMAGES) + uset._config = Config(tmpdir) + tfs = TestingFileStorage(filename="legitimate.jpg") + + with pytest.raises(UploadNotAllowed) as exc_info: + uset.save(tfs, name="backdoor.py") + + assert "py" in str(exc_info.value).lower() + + def test_extension_bypass_with_double_extension(self) -> None: + """Verify double extensions don't bypass validation.""" + with tempfile.TemporaryDirectory() as tmpdir: + uset = UploadSet("photos", IMAGES) + uset._config = Config(tmpdir) + tfs = TestingFileStorage(filename="safe.jpg") + + result = uset.save(tfs, name="backdoor.php.jpg") + assert ".jpg" in result + + def test_folder_extraction_sanitization(self) -> None: + """Verify folder extracted from `name` is sanitized.""" + with tempfile.TemporaryDirectory() as tmpdir: + uset = UploadSet("files", ALL) + uset._config = Config(tmpdir) + tfs = TestingFileStorage(filename="test.txt") + + result = uset.save(tfs, name="../../tmp/file.txt") + + assert "../" not in result + assert tfs.saved is not None + assert os.path.realpath(tfs.saved).startswith( + os.path.realpath(tmpdir) + ) + assert "file.txt" in result + + def test_explicit_folder_parameter_sanitization(self) -> None: + """Verify explicit `folder` parameter is sanitized.""" + with tempfile.TemporaryDirectory() as tmpdir: + uset = UploadSet("files", ALL) + uset._config = Config(tmpdir) + tfs = TestingFileStorage(filename="test.txt") + + result = uset.save(tfs, folder="../../tmp") + + assert "../" not in result + assert tfs.saved is not None + assert os.path.realpath(tfs.saved).startswith( + os.path.realpath(tmpdir) + ) + + def test_path_containment_check(self) -> None: + """Verify final path is contained within upload directory.""" + with tempfile.TemporaryDirectory() as tmpdir: + uset = UploadSet("files", ALL) + uset._config = Config(tmpdir) + tfs = TestingFileStorage(filename="test.txt") + + uset.save(tfs, name="../../../../../../../../tmp/escape.txt") + + assert tfs.saved is not None + real_saved = os.path.realpath(tfs.saved) + real_upload = os.path.realpath(tmpdir) + assert real_saved.startswith(real_upload) + + def test_empty_name_after_sanitization(self) -> None: + """Verify names that become empty after sanitization are rejected.""" + with tempfile.TemporaryDirectory() as tmpdir: + uset = UploadSet("files", ALL) + uset._config = Config(tmpdir) + tfs = TestingFileStorage(filename="test.txt") + + with pytest.raises(ValueError) as exc_info: + uset.save(tfs, name="...") + + assert "sanitization" in str(exc_info.value).lower() + + def test_windows_path_separators(self) -> None: + """Verify Windows-style path separators are sanitized.""" + with tempfile.TemporaryDirectory() as tmpdir: + uset = UploadSet("files", ALL) + uset._config = Config(tmpdir) + tfs = TestingFileStorage(filename="test.txt") + + result = uset.save(tfs, name="..\\..\\temp\\evil.txt") + + assert "\\" not in result + assert tfs.saved is not None + assert os.path.realpath(tfs.saved).startswith( + os.path.realpath(tmpdir) + ) + + def test_legitimate_subfolder_still_works(self) -> None: + """Verify legitimate subfolder usage still works.""" + with tempfile.TemporaryDirectory() as tmpdir: + uset = UploadSet("files", ALL) + uset._config = Config(tmpdir) + tfs = TestingFileStorage(filename="photo.jpg") + + result = uset.save(tfs, name="users/avatar.jpg") + + assert result == "users/avatar.jpg" + assert tfs.saved is not None + assert "users" in tfs.saved + assert "avatar.jpg" in tfs.saved + + def test_legitimate_custom_name_still_works(self) -> None: + """Verify legitimate custom names still work.""" + with tempfile.TemporaryDirectory() as tmpdir: + uset = UploadSet("files", ALL) + uset._config = Config(tmpdir) + tfs = TestingFileStorage(filename="upload.txt") + + result = uset.save(tfs, name="renamed_file.txt") + + assert result == "renamed_file.txt" + assert tfs.saved is not None + assert "renamed_file.txt" in tfs.saved + + def test_legitimate_name_with_extension_placeholder(self) -> None: + """Verify trailing dot preserves extension.""" + with tempfile.TemporaryDirectory() as tmpdir: + uset = UploadSet("photos", IMAGES) + uset._config = Config(tmpdir) + tfs = TestingFileStorage(filename="photo.jpg") + + result = uset.save(tfs, name="image_123.") + + assert result == "image_123.jpg" + assert tfs.saved is not None + assert "image_123.jpg" in tfs.saved + + def test_combined_attack_prevention(self) -> None: + """Verify combined path traversal + extension bypass is prevented.""" + with tempfile.TemporaryDirectory() as tmpdir: + uset = UploadSet("photos", IMAGES) + uset._config = Config(tmpdir) + tfs = TestingFileStorage(filename="payload.jpg") + + with pytest.raises(UploadNotAllowed): + uset.save(tfs, name="../templates/rce.html") + + def test_null_byte_injection(self) -> None: + """Verify null byte injection is sanitized.""" + with tempfile.TemporaryDirectory() as tmpdir: + uset = UploadSet("files", ALL) + uset._config = Config(tmpdir) + tfs = TestingFileStorage(filename="test.txt") + + result = uset.save(tfs, name="file.txt\x00.jpg") + + assert "\x00" not in result + assert tfs.saved is not None + assert os.path.realpath(tfs.saved).startswith( + os.path.realpath(tmpdir) + ) + + def test_special_characters_sanitization(self) -> None: + """Verify special characters are sanitized.""" + with tempfile.TemporaryDirectory() as tmpdir: + uset = UploadSet("files", ALL) + uset._config = Config(tmpdir) + tfs = TestingFileStorage(filename="test.txt") + + result = uset.save(tfs, name='file<>:"|?*.txt') + + for char in '<>:"|?*': + assert char not in result + assert tfs.saved is not None + assert os.path.realpath(tfs.saved).startswith( + os.path.realpath(tmpdir) + ) + + def test_name_already_ends_with_dot(self) -> None: + """Verify trailing dot keeps extension.""" + with tempfile.TemporaryDirectory() as tmpdir: + uset = UploadSet("files", ALL) + uset._config = Config(tmpdir) + tfs = TestingFileStorage(filename="photo.jpg") + + result = uset.save(tfs, name="myfile.") + + assert result == "myfile.jpg" + assert tfs.saved is not None + assert "myfile.jpg" in tfs.saved + + def test_symlink_path_traversal_prevention(self) -> None: + """Verify symlinks cannot be used to escape upload directory.""" + with tempfile.TemporaryDirectory() as tmpdir: + outside_dir = tempfile.mkdtemp() + try: + symlink_path = os.path.join(tmpdir, "link") + os.symlink(outside_dir, symlink_path) + + uset = UploadSet("files", ALL) + uset._config = Config(tmpdir) + tfs = TestingFileStorage(filename="test.txt") + + with pytest.raises(ValueError, match="Path traversal"): + uset.save(tfs, folder="link", name="../../escape.txt") + finally: + shutil.rmtree(outside_dir, ignore_errors=True)
Vulnerability mechanics
Generated on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
5- github.com/advisories/GHSA-65mp-fq8v-56jrghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-27641ghsaADVISORY
- github.com/jugmac00/flask-reuploaded/commit/d64c6b2f71cb73734fc38baa0e3e156926361288ghsax_refsource_MISCWEB
- github.com/jugmac00/flask-reuploaded/pull/180ghsax_refsource_MISCWEB
- github.com/jugmac00/flask-reuploaded/security/advisories/GHSA-65mp-fq8v-56jrghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.