Moderate severityNVD Advisory· Published Jul 7, 2025· Updated Jul 7, 2025
ReDoS in fastapi-guard's penetration attempts detector
CVE-2025-53539
Description
FastAPI Guard is a security library for FastAPI that provides middleware to control IPs, log requests, and detect penetration attempts. fastapi-guard's penetration attempts detection uses regex to scan incoming requests. However, some of the regex patterns used in detection are extremely inefficient and can cause polynomial complexity backtracks when handling specially crafted inputs. This vulnerability is fixed in 3.0.1.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
fastapi-guardPyPI | < 3.0.1 | 3.0.1 |
Affected products
1- Range: < 3.0.1
Patches
1d9d50e8130b7Merge commit from fork
12 files changed · +120 −107
CHANGELOG.md+11 −0 modified@@ -3,6 +3,17 @@ Release Notes ___ +v3.0.1 (2025-07-07) +------------------- + +Security Fixes (v3.0.1) +------------ + +- **IMPORTANT**: Prevented ReDoS (Regular Expression Denial of Service - CWE-1333) attacks by replacing unbounded regex quantifiers with bounded ones. (GHSA-j47q-rc62-w448) +- **CVE ID**: (TBD) + +___ + v3.0.0 (2025-06-21) -------------------
docs/index.md+1 −1 modified@@ -75,7 +75,7 @@ You can also download the example app as a Docker container from [GitHub Contain docker pull ghcr.io/rennf93/fastapi-guard-example:latest # Or pull a specific version (matches library releases) -docker pull ghcr.io/rennf93/fastapi-guard-example:v3.0.0 +docker pull ghcr.io/rennf93/fastapi-guard-example:v3.0.1 ``` ___
docs/release-notes.md+11 −0 modified@@ -10,6 +10,17 @@ Release Notes ___ +v3.0.1 (2025-07-07) +------------------- + +Security Fixes (v3.0.1) +------------ + +- **IMPORTANT**: Prevented ReDoS (Regular Expression Denial of Service - CWE-1333) attacks by replacing unbounded regex quantifiers with bounded ones. (GHSA-j47q-rc62-w448) +- **CVE ID**: (TBD) + +___ + v3.0.0 (2025-06-21) -------------------
docs/versions/versions.json+2 −1 modified@@ -1,4 +1,5 @@ { + "3.0.1": "3.0.1", "3.0.0": "3.0.0", "2.1.3": "2.1.3", "2.1.2": "2.1.2", @@ -18,5 +19,5 @@ "0.3.4": "0.3.4", "0.3.3": "0.3.3", "0.3.2": "0.3.2", - "latest": "3.0.0" + "latest": "3.0.1" } \ No newline at end of file
.github/ISSUE_TEMPLATE/bug_report.md+1 −1 modified@@ -36,7 +36,7 @@ ___ Environment =========== -- FastAPI Guard version: [e.g. 3.0.0] +- FastAPI Guard version: [e.g. 3.0.1] - Python version: [e.g. 3.11.10] - FastAPI version: [e.g. 0.115.0] - OS: [e.g. Ubuntu 22.04, Windows 11, MacOS 15.4]
guard/handlers/cloud_handler.py+1 −1 modified@@ -53,7 +53,7 @@ def fetch_azure_ip_ranges() -> set[ipaddress.IPv4Network | ipaddress.IPv6Network response.raise_for_status() decoded_html = html.unescape(response.text) - pattern = r'href=["\'](https://download\.microsoft\.com/' r'.*?\.json)["\']' + pattern = r'href=["\'](https://download\.microsoft\.com/.{1,500}?\.json)["\']' match = re.search(pattern, decoded_html) if not match:
guard/handlers/ipinfo_handler.py+17 −17 modified@@ -4,7 +4,7 @@ from pathlib import Path from typing import Any -import aiohttp +import httpx import maxminddb from maxminddb import Reader @@ -76,24 +76,24 @@ async def _download_database(self) -> None: retries = 3 backoff = 1 - async with aiohttp.ClientSession() as session: + async with httpx.AsyncClient() as session: for attempt in range(retries): try: - async with session.get(url) as response: - response.raise_for_status() - with open(self.db_path, "wb") as f: - f.write(await response.read()) - - if self.redis_handler is not None: - with open(self.db_path, "rb") as f: - db_content = f.read().decode("latin-1") - await self.redis_handler.set_key( - "ipinfo", - "database", - db_content, - ttl=86400, # 24 hours - ) - return + response = await session.get(url) + response.raise_for_status() + with open(self.db_path, "wb") as f: + f.write(response.content) + + if self.redis_handler is not None: + with open(self.db_path, "rb") as f: + db_content = f.read().decode("latin-1") + await self.redis_handler.set_key( + "ipinfo", + "database", + db_content, + ttl=86400, # 24 hours + ) + return except Exception: if attempt == retries - 1: raise
guard/handlers/suspatterns_handler.py+60 −60 modified@@ -18,98 +18,98 @@ class SusPatternsManager: custom_patterns: set[str] = set() patterns: list[str] = [ - # XSS - Enhanced patterns - r"<script[^>]*>[^<]*<\/script\s*>", # Basic script tag - r"javascript:\s*[^\s]+", # javascript: protocol + # XSS + r"<script[^>]{0,100}>[^<]{0,1000}<\/script\s{0,10}>", # Basic script tag + r"javascript:\s{0,10}[^\s]{1,200}", # javascript: protocol # Event handlers r"(?:on(?:error|load|click|mouseover|submit|mouse|unload|change|focus|" - r"blur|drag))=[\"\']?[^\"\'>\s]+", + r"blur|drag))=(?:[\"'][^\"']{1,100}[\"']|[^\s>]{1,100})", # Malicious attributes - r"(?:<[^>]*\s+(?:href|src|data|action)\s*=[\s\"\']*(?:javascript|" + r"(?:<[^>]{1,200}\s{1,20}(?:href|src|data|action)\s{0,10}=[\s\"\']{0,3}(?:javascript|" r"vbscript|data):)", # CSS expressions - r"(?:<[^>]*\s+style\s*=[\s\"\']*[^>]*(?:expression|behavior|url)\s*\(" - r"[^)]*\))", - r"(?:<object[^>]*>[\s\S]*?<\/object\s*>)", # Suspicious objects - r"(?:<embed[^>]*>[\s\S]*?<\/embed\s*>)", # Suspicious embeds - r"(?:<applet[^>]*>[\s\S]*?<\/applet\s*>)", # Java applets - # SQL Injection - Enhanced patterns + r"(?:<[^>]{1,200}style\s{0,10}=[\s\"\']{0,3}[^>\"\']{1,200}(?:expression|behavior|url)\s{0,10}\(" + r"[^)]{1,200}\))", + r"(?:<object[^>]{1,200}>[\s\S]{1,1000}<\/object\s{0,10}>)", # Suspicious obj + r"(?:<embed[^>]{1,200}>[\s\S]{1,1000}<\/embed\s{0,10}>)", # Suspicious embeds + r"(?:<applet[^>]{1,200}>[\s\S]{1,1000}<\/applet\s{0,10}>)", # Java applets + # SQL Injection # Basic SELECT statements - r"(?i)SELECT\s+[\w\s,\*]+\s+FROM\s+[\w\s\._]+", + r"(?i)SELECT\s{1,20}[\w\s,\*]{1,200}\s{1,20}FROM\s{1,20}[\w\s\._]{1,100}", # UNION-based queries - r"(?i)UNION\s+(?:ALL\s+)?SELECT", + r"(?i)UNION\s{1,20}(?:ALL\s{1,20})?SELECT", # Logic-based - r"(?i)('\s*(?:OR|AND)\s*[\(\s]*'?[\d\w]+\s*(?:=|LIKE|<|>|<=|>=)\s*" - r"[\(\s]*'?[\d\w]+)", - # UNION-based (original pattern) - r"(?i)(UNION\s+(?:ALL\s+)?SELECT\s+(?:NULL[,\s]*)+|\(\s*SELECT\s+" + r"(?i)('\s{0,5}(?:OR|AND)\s{0,5}[\(\s]{0,5}'?[\d\w]{1,50}\s{0,5}(?:=|LIKE|<|>|<=|>=)\s{0,5}" + r"[\(\s]{0,5}'?[\d\w]{1,50})", + # UNION-based + r"(?i)(UNION\s{1,20}(?:ALL\s{1,20})?SELECT\s{1,20}(?:NULL[,\s]{0,10}){1,20}|\(\s{0,10}SELECT\s{1,20}" r"(?:@@|VERSION))", - r"(?i)(?:INTO\s+(?:OUTFILE|DUMPFILE)\s+'[^']+')", # File operations - r"(?i)(?:LOAD_FILE\s*\([^)]+\))", # File reading - r"(?i)(?:BENCHMARK\s*\(\s*\d+\s*,)", # Time-based - r"(?i)(?:SLEEP\s*\(\s*\d+\s*\))", # Time-based + r"(?i)(?:INTO\s{1,20}(?:OUTFILE|DUMPFILE)\s{1,20}'[^']{1,200}')", # File ops + r"(?i)(?:LOAD_FILE\s{0,10}\([^)]{1,200}\))", # File reading + r"(?i)(?:BENCHMARK\s{0,10}\(\s{0,10}\d{1,10}\s{0,10},)", # Time-based + r"(?i)(?:SLEEP\s{0,10}\(\s{0,10}\d{1,10}\s{0,10}\))", # Time-based # Comment-based - r"(?i)(?:\/\*![0-9]*\s*(?:OR|AND|UNION|SELECT|INSERT|DELETE|DROP|" + r"(?i)(?:\/\*![0-9]{0,10}\s{0,10}(?:OR|AND|UNION|SELECT|INSERT|DELETE|DROP|" r"CONCAT|CHAR|UPDATE)\b)", - # Directory Traversal - Enhanced patterns - r"(?:\.\./|\.\\/){2,}", # Multiple traversal + # Directory Traversal + r"(?:\.\./|\.\\/){2,10}", # Multiple traversal # Sensitive files r"(?:/etc/(?:passwd|shadow|group|hosts|motd|issue|mysql/my.cnf|ssh/" r"ssh_config)$)", - r"(?:boot\.ini|win\.ini|system\.ini|config\.sys)\s*$", # Windows files + r"(?:boot\.ini|win\.ini|system\.ini|config\.sys)\s{0,10}$", # Windows files r"(?:\/proc\/self\/environ$)", # Process information - r"(?:\/var\/log\/[^\/]+$)", # Log files - # Command Injection - Enhanced patterns + r"(?:\/var\/log\/[^\/]{1,100}$)", # Log files + # Command Injection # Basic commands - r";\s*(?:ls|cat|rm|chmod|chown|wget|curl|nc|netcat|ping|telnet)\s+" - r"-[a-zA-Z]+\s+", + r";\s{0,10}(?:ls|cat|rm|chmod|chown|wget|curl|nc|netcat|ping|telnet)\s{1,20}" + r"-[a-zA-Z]{1,20}\s{1,20}", # Download commands - r"\|\s*(?:wget|curl|fetch|lwp-download|lynx|links|GET)\s+", + r"\|\s{0,10}(?:wget|curl|fetch|lwp-download|lynx|links|GET)\s{1,20}", # Command substitution - r"(?:[;&|`]\s*(?:\$\([^)]+\)|\$\{[^}]+\}))", + r"(?:[;&|`]\s{0,10}(?:\$\([^)]{1,100}\)|\$\{[^}]{1,100}\}))", # Shell execution - r"(?:^|;)\s*(?:bash|sh|ksh|csh|tsch|zsh|ash)\s+-[a-zA-Z]+", + r"(?:^|;)\s{0,10}(?:bash|sh|ksh|csh|tsch|zsh|ash)\s{1,20}-[a-zA-Z]{1,20}", # PHP functions - r"\b(?:eval|system|exec|shell_exec|passthru|popen|proc_open)\s*\(", - # File Inclusion - Enhanced patterns + r"\b(?:eval|system|exec|shell_exec|passthru|popen|proc_open)\s{0,10}\(", + # File Inclusion # Protocols r"(?:php|data|zip|rar|file|glob|expect|input|phpinfo|zlib|phar|ssh2|" - r"rar|ogg|expect)://[^\s]+", + r"rar|ogg|expect)://[^\s]{1,200}", # URLs - r"(?:\/\/[0-9a-zA-Z]([-.\w]*[0-9a-zA-Z])*(:(0-9)*)*(?:\/?)(?:" - r"[a-zA-Z0-9\-\.\?,'/\\\+&%\$#_]*)?)", - # LDAP Injection - Enhanced patterns - r"\(\s*[|&]\s*\(\s*[^)]+=[*]", # Wildcards - r"(?:\*(?:[\s\d\w]+\s*=|=\s*[\d\w\s]+))", # Attribute matching - r"(?:\(\s*[&|]\s*)", # Logic operations - # XML Injection - Enhanced patterns - r"<!(?:ENTITY|DOCTYPE)[^>]+SYSTEM[^>]+>", # XXE - r"(?:<!\[CDATA\[.*?\]\]>)", # CDATA sections - r"(?:<\?xml.*?\?>)", # XML declarations - # SSRF - Enhanced patterns + r"(?:\/\/[0-9a-zA-Z]([-.\w]{0,50}[0-9a-zA-Z]){0,10}(:[0-9]{0,10}){0,1}(?:\/?)(?:" + r"[a-zA-Z0-9\-\.\?,'/\\\+&%\$#_]{0,500})?)", + # LDAP Injection + r"\(\s{0,10}[|&]\s{0,10}\(\s{0,10}[^)]{1,100}=[*]", # Wildcards + r"(?:\*(?:[\s\d\w]{1,50}\s{0,10}=|=\s{0,10}[\d\w\s]{1,50}))", # Attribute match + r"(?:\(\s{0,10}[&|]\s{0,10})", # Logic operations + # XML Injection + r"<!(?:ENTITY|DOCTYPE)[^>]{1,200}SYSTEM[^>]{1,200}>", # XXE + r"(?:<!\[CDATA\[.{0,1000}?\]\]>)", # CDATA sections + r"(?:<\?xml.{0,200}?\?>)", # XML declarations + # SSRF # Local addresses - r"(?:^|\s|/)(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::(?:\d*)\]|(?:169\.254|192\.168|10\.|" - r"172\.(?:1[6-9]|2[0-9]|3[01]))\.\d+)(?:\s|$|/)", - r"(?:file|dict|gopher|jar|tftp)://[^\s]+", # Dangerous protocols - # NoSQL Injection - Enhanced patterns + r"(?:^|\s|/)(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::(?:\d{0,10})\]|(?:169\.254|192\.168|10\.|" + r"172\.(?:1[6-9]|2[0-9]|3[01]))\.\d{1,3})(?:\s|$|/)", + r"(?:file|dict|gopher|jar|tftp)://[^\s]{1,200}", # Dangerous protocols + # NoSQL Injection # MongoDB - r"\{\s*\$(?:where|gt|lt|ne|eq|regex|in|nin|all|size|exists|type|mod|" + r"\{\s{0,10}\$(?:where|gt|lt|ne|eq|regex|in|nin|all|size|exists|type|mod|" r"options):", - r"(?:\{\s*\$[a-zA-Z]+\s*:\s*(?:\{|\[))", # Nested operators - # File Upload - Enhanced patterns - r"(?i)filename=[\"'].*?\.(?:php\d*|phar|phtml|exe|jsp|asp|aspx|sh|" + r"(?:\{\s{0,10}\$[a-zA-Z]{1,20}\s{0,10}:\s{0,10}(?:\{|\[))", # Nested operators + # File Upload + r"(?i)filename=[\"'].{0,200}?\.(?:php\d{0,5}|phar|phtml|exe|jsp|asp|aspx|sh|" r"bash|rb|py|pl|cgi|com|bat|cmd|vbs|vbe|js|ws|wsf|msi|hta)[\"\']", - # Path Traversal - Enhanced patterns + # Path Traversal # Encoded traversal r"(?:%2e%2e|%252e%252e|%uff0e%uff0e|%c0%ae%c0%ae|%e0%40%ae|%c0%ae" r"%e0%80%ae|%25c0%25ae)/", - # Template Injection - New category + # Template Injection # Basic template injection - r"\{\{\s*[^\}]*(?:system|exec|popen|eval|require|include)\s*\}\}", + r"\{\{\s{0,10}[^\}]{1,200}(?:system|exec|popen|eval|require|include)\s{0,10}\}\}", # Alternative syntax - r"\{\%\s*[^\%]*(?:system|exec|popen|eval|require|include)\s*\%\}", - # HTTP Response Splitting - New category - r"[\r\n]\s*(?:HTTP\/[0-9.]+|Location:|Set-Cookie:)", + r"\{\%\s{0,10}[^\%]{1,200}(?:system|exec|popen|eval|require|include)\s{0,10}\%\}", + # HTTP Response Splitting + r"[\r\n]\s{0,10}(?:HTTP\/[0-9.]{1,10}|Location:|Set-Cookie:)", ] compiled_patterns: list[re.Pattern]
.mike.yml+2 −1 modified@@ -2,6 +2,7 @@ version_selector: true title_switch: true versions_file: docs/versions/versions.json versions: + - 3.0.1 - 3.0.0 - 2.1.3 - 2.1.2 @@ -22,4 +23,4 @@ versions: - 0.3.2 - latest aliases: - latest: 3.0.0 \ No newline at end of file + latest: 3.0.1 \ No newline at end of file
pyproject.toml+2 −7 modified@@ -1,6 +1,6 @@ [project] name = "fastapi_guard" -version = "3.0.0" +version = "3.0.1" description = "A security library for FastAPI to control IPs, log requests, and detect penetration attempts." authors = [ {name = "Renzo Franceschini", email = "rennf93@users.noreply.github.com"} @@ -22,9 +22,9 @@ classifiers = [ "Programming Language :: Python :: 3.13", ] dependencies = [ - "aiohttp", "cachetools", "fastapi", + "httpx", "ipaddress", "maxminddb", "redis", @@ -35,7 +35,6 @@ dependencies = [ [project.optional-dependencies] dev = [ "black", - "httpx", "matplotlib", "mkdocs", "mkdocstrings", @@ -107,10 +106,6 @@ warn_unreachable = true module = "pydantic.*" follow_imports = "skip" -[[tool.mypy.overrides]] -module = "aiohttp.*" -follow_imports = "skip" - [[tool.mypy.overrides]] module = "redis.*" follow_imports = "skip"
README.md+0 −1 modified@@ -622,7 +622,6 @@ Acknowledgements - [FastAPI](https://fastapi.tiangolo.com/) - [IPInfo](https://ipinfo.io/) -- [aiohttp](https://docs.aiohttp.org/) - [cachetools](https://cachetools.readthedocs.io/) - [requests](https://docs.python-requests.org/) - [Redis](https://redis.io/)
tests/test_ipinfo/test_ipinfo.py+12 −17 modified@@ -16,12 +16,10 @@ async def test_ipinfo_db(tmp_path: Path) -> None: mock_response = Mock() mock_response.raise_for_status = Mock() - mock_response.__aenter__ = AsyncMock(return_value=mock_response) - mock_response.__aexit__ = AsyncMock() - mock_response.read = AsyncMock() + mock_response.content = b"test data" with ( - patch("aiohttp.ClientSession.get", return_value=mock_response), + patch("httpx.AsyncClient.get", return_value=mock_response), patch("maxminddb.open_database"), patch("builtins.open", Mock()), patch("os.makedirs"), @@ -41,7 +39,7 @@ def test_ipinfo_missing_token() -> None: async def test_ipinfo_download_failure(tmp_path: Path) -> None: db = IPInfoManager(token="test", db_path=tmp_path / "test.mmdb") with ( - patch("aiohttp.ClientSession.get", side_effect=Exception("Download failed")), + patch("httpx.AsyncClient.get", side_effect=Exception("Download failed")), patch.object(IPInfoManager, "_is_db_outdated", return_value=True), ): await db.initialize() @@ -53,7 +51,7 @@ async def test_ipinfo_download_failure(tmp_path: Path) -> None: async def test_db_initialization_retry(tmp_path: Path) -> None: db = IPInfoManager(token="test", db_path=tmp_path / "test.mmdb") with ( - patch("aiohttp.ClientSession.get", side_effect=Exception("First fail")), + patch("httpx.AsyncClient.get", side_effect=Exception("First fail")), patch("asyncio.sleep") as mock_sleep, patch("builtins.open", Mock()), ): @@ -68,14 +66,12 @@ async def test_database_retry_success(tmp_path: Path) -> None: db = IPInfoManager(token="test", db_path=tmp_path / "test.mmdb") mock_response = Mock() mock_response.raise_for_status = Mock() - mock_response.__aenter__ = AsyncMock(return_value=mock_response) - mock_response.__aexit__ = AsyncMock() - mock_response.read = AsyncMock(return_value=b"test data") + mock_response.content = b"test data" # Use a closure to track the number of calls call_count = 0 - def side_effect_function(*args: Any, **kwargs: Any) -> AsyncMock: + async def side_effect_function(*args: Any, **kwargs: Any) -> Mock: nonlocal call_count call_count += 1 if call_count == 1: @@ -89,7 +85,7 @@ def side_effect_function(*args: Any, **kwargs: Any) -> AsyncMock: mock_open = Mock(return_value=mock_file_context) with ( - patch("aiohttp.ClientSession.get", side_effect=side_effect_function), + patch("httpx.AsyncClient.get", side_effect=side_effect_function), patch("builtins.open", mock_open), patch("os.makedirs"), patch("asyncio.sleep") as mock_sleep, @@ -194,7 +190,7 @@ async def test_corrupted_db_removal(tmp_path: Path) -> None: db.db_path.touch() with ( - patch("aiohttp.ClientSession.get", side_effect=Exception("Download failed")), + patch("httpx.AsyncClient.get", side_effect=Exception("Download failed")), patch.object(IPInfoManager, "_is_db_outdated", return_value=True), ): await db.initialize() @@ -207,7 +203,7 @@ async def test_download_exhausts_retries(tmp_path: Path) -> None: db = IPInfoManager(token="test", db_path=tmp_path / "test.mmdb") with ( - patch("aiohttp.ClientSession.get", side_effect=Exception("Download failed")), + patch("httpx.AsyncClient.get", side_effect=Exception("Download failed")), patch("asyncio.sleep"), ): with pytest.raises(Exception, match="Download failed"): @@ -256,13 +252,12 @@ async def test_redis_cache_update(tmp_path: Path) -> None: db = IPInfoManager(token="test", db_path=tmp_path / "test.mmdb") db.redis_handler = AsyncMock() - mock_response = AsyncMock() - mock_response.__aenter__.return_value = mock_response + mock_response = Mock() mock_response.raise_for_status = Mock() - mock_response.read.return_value = b"new_db_data" + mock_response.content = b"new_db_data" with ( - patch("aiohttp.ClientSession.get", return_value=mock_response), + patch("httpx.AsyncClient.get", return_value=mock_response), patch("maxminddb.open_database"), ): await db._download_database()
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
4- github.com/advisories/GHSA-j47q-rc62-w448ghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2025-53539ghsaADVISORY
- github.com/rennf93/fastapi-guard/commit/d9d50e8130b7b434cdc1b001b8cfd03a06729f7fghsax_refsource_MISCWEB
- github.com/rennf93/fastapi-guard/security/advisories/GHSA-j47q-rc62-w448ghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.