fastapi-guard patch contains bypassable RegEx
Description
fastapi-guard is a security library for FastAPI that provides middleware to control IPs, log requests, detect penetration attempts and more. In version 3.0.1, the regular expression patched to mitigate the ReDoS vulnerability by limiting the length of string fails to catch inputs that exceed this limit. This type of patch fails to detect cases in which the string representing the attributes of a <script> tag exceeds 100 characters. As a result, most of the regex patterns present in version 3.0.1 can be bypassed. This is fixed in version 3.0.2.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
fastapi-guardPyPI | >= 3.0.1, < 3.0.2 | 3.0.2 |
Affected products
1- Range: >= 3.0.1, < 3.0.2
Patches
20829292c322dMerge commit from fork
15 files changed · +279 −59
CHANGELOG.md+13 −0 modified@@ -3,6 +3,19 @@ Release Notes ___ +v3.0.2 (2025-07-22) +------------------- + +Security Fixes (v3.0.2) +------------ + +- **IMPORTANT**: Enhanced ReDoS prevention - Prevent regex bypass due to length limitations on pattern regex. (GHSA-rrf6-pxg8-684g) +- **CVE ID**: CVE-2025-54365 +- Added timeout to avoid catastrophical backtracking and/or regex bypass by length limitation expression. +- Added new `regex_timeout` parameter to `SecurityConfig` to allow for custom timeout for regex pattern matching. + +___ + v3.0.1 (2025-07-07) -------------------
docs/api/utilities.md+21 −1 modified@@ -118,19 +118,27 @@ detect_penetration_attempt ```python async def detect_penetration_attempt( - request: Request + request: Request, + regex_timeout: float = 2.0 ) -> tuple[bool, str] ``` Detect potential penetration attempts in the request. This function checks various parts of the request (query params, body, path, headers) against a list of suspicious patterns to identify potential security threats. +Parameters: + +- `request`: The FastAPI request object to analyze +- `regex_timeout`: Timeout in seconds for each regex check to prevent ReDoS attacks (default: 2.0 seconds) + Returns a tuple where: - First element is a boolean: `True` if a potential attack is detected, `False` otherwise - Second element is a string with details about what triggered the detection, or empty string if no attack detected +The regex timeout mechanism protects against ReDoS (Regular Expression Denial of Service) attacks by limiting the execution time of each pattern match. If a pattern match exceeds the timeout, it's considered non-matching and a warning is logged. + Example usage: ```python @@ -139,12 +147,21 @@ from guard.utils import detect_penetration_attempt @app.post("/api/submit") async def submit_data(request: Request): + # Use default timeout of 2.0 seconds is_suspicious, trigger_info = await detect_penetration_attempt(request) if is_suspicious: # Log the detection with details logger.warning(f"Attack detected: {trigger_info}") return {"error": "Suspicious activity detected"} return {"success": True} + +@app.post("/api/critical") +async def critical_endpoint(request: Request): + # Use shorter timeout for critical endpoints + is_suspicious, trigger_info = await detect_penetration_attempt(request, regex_timeout=0.5) + if is_suspicious: + return {"error": "Security check failed"} + return {"success": True} ``` extract_client_ip @@ -194,4 +211,7 @@ await log_activity( # Check for penetration attempts is_suspicious, trigger_info = await detect_penetration_attempt(request) + +# Or with custom timeout +is_suspicious, trigger_info = await detect_penetration_attempt(request, regex_timeout=1.0) ```
docs/release-notes.md+13 −0 modified@@ -10,6 +10,19 @@ Release Notes ___ +v3.0.2 (2025-07-22) +------------------- + +Security Fixes (v3.0.2) +------------ + +- **IMPORTANT**: Enhanced ReDoS prevention - Prevent regex bypass due to length limitations on pattern regex. (GHSA-rrf6-pxg8-684g) +- **CVE ID**: CVE-2025-54365 +- Added timeout to avoid catastrophical backtracking and/or regex bypass by length limitation expression. +- Added new `regex_timeout` parameter to `SecurityConfig` to allow for custom timeout for regex pattern matching. + +___ + v3.0.1 (2025-07-07) -------------------
docs/tutorial/security/penetration-detection.md+28 −2 modified@@ -21,7 +21,8 @@ Enable penetration detection: config = SecurityConfig( enable_penetration_detection=True, auto_ban_threshold=5, # Ban after 5 suspicious requests - auto_ban_duration=3600 # Ban duration in seconds + auto_ban_duration=3600, # Ban duration in seconds + regex_timeout=2.0 # Timeout for regex pattern matching (default: 2.0 seconds) ) ``` @@ -45,6 +46,30 @@ The system checks for various attack patterns including: ___ +Regex Timeout Protection +------------------------ + +To prevent ReDoS (Regular Expression Denial of Service) attacks, FastAPI Guard implements a timeout mechanism for regex pattern matching: + +```python +config = SecurityConfig( + regex_timeout=2.0 # Default: 2.0 seconds +) +``` + +The `regex_timeout` parameter: +- Prevents malicious inputs from causing excessive CPU usage through regex backtracking +- Can be configured between 0.1 and 30.0 seconds +- Defaults to 2.0 seconds for balanced security and performance +- If a regex match exceeds the timeout, it's considered non-matching and logged as a potential ReDoS attempt + +When a timeout occurs, you'll see a warning in the logs: +```text +WARNING - Regex timeout exceeded for pattern '<pattern>' - Potential ReDoS attack blocked. +``` + +___ + Custom Detection Logic ---------------------- @@ -55,7 +80,8 @@ from guard.utils import detect_penetration_attempt @app.post("/api/data") async def submit_data(request: Request): - is_suspicious, trigger_info = await detect_penetration_attempt(request) + # Use custom timeout if needed (default is 2.0 seconds) + is_suspicious, trigger_info = await detect_penetration_attempt(request, regex_timeout=1.5) if is_suspicious: return JSONResponse( status_code=400,
docs/versions/versions.json+2 −1 modified@@ -1,4 +1,5 @@ { + "3.0.2": "3.0.2", "3.0.1": "3.0.1", "3.0.0": "3.0.0", "2.1.3": "2.1.3", @@ -19,5 +20,5 @@ "0.3.4": "0.3.4", "0.3.3": "0.3.3", "0.3.2": "0.3.2", - "latest": "3.0.1" + "latest": "3.0.2" } \ 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.1] +- FastAPI Guard version: [e.g. 3.0.2] - 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]
.gitignore+2 −1 modified@@ -178,4 +178,5 @@ cython_debug/ #.idea/ .DS_Store manual_testing/ -.cursor/ \ No newline at end of file +.kiro/ +.claude/ \ No newline at end of file
guard/handlers/suspatterns_handler.py+46 −46 modified@@ -19,97 +19,97 @@ class SusPatternsManager: patterns: list[str] = [ # XSS - r"<script[^>]{0,100}>[^<]{0,1000}<\/script\s{0,10}>", # Basic script tag - r"javascript:\s{0,10}[^\s]{1,200}", # javascript: protocol + r"<script[^>]*>[^<]*<\/script\s*>", # Basic script tag + r"javascript:\s*[^\s]+", # javascript: protocol # Event handlers r"(?:on(?:error|load|click|mouseover|submit|mouse|unload|change|focus|" - r"blur|drag))=(?:[\"'][^\"']{1,100}[\"']|[^\s>]{1,100})", + r"blur|drag))=(?:[\"'][^\"']*[\"']|[^\s>]+)", # Malicious attributes - r"(?:<[^>]{1,200}\s{1,20}(?:href|src|data|action)\s{0,10}=[\s\"\']{0,3}(?:javascript|" + r"(?:<[^>]+\s+(?:href|src|data|action)\s*=[\s\"\']*(?:javascript|" r"vbscript|data):)", # CSS expressions - 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 + r"(?:<[^>]+style\s*=[\s\"\']*[^>\"\']*(?:expression|behavior|url)\s*\(" + r"[^)]*\))", + r"(?:<object[^>]*>[\s\S]*<\/object\s*>)", # Suspicious obj + r"(?:<embed[^>]*>[\s\S]*<\/embed\s*>)", # Suspicious embeds + r"(?:<applet[^>]*>[\s\S]*<\/applet\s*>)", # Java applets # SQL Injection # Basic SELECT statements - r"(?i)SELECT\s{1,20}[\w\s,\*]{1,200}\s{1,20}FROM\s{1,20}[\w\s\._]{1,100}", + r"(?i)SELECT\s+[\w\s,\*]+\s+FROM\s+[\w\s\._]+", # UNION-based queries - r"(?i)UNION\s{1,20}(?:ALL\s{1,20})?SELECT", + r"(?i)UNION\s+(?:ALL\s+)?SELECT", # Logic-based - 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})", + r"(?i)('\s*(?:OR|AND)\s*[\(\s]*'?[\d\w]+\s*(?:=|LIKE|<|>|<=|>=)\s*" + r"[\(\s]*'?[\d\w]+)", # 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"(?i)(UNION\s+(?:ALL\s+)?SELECT\s+(?:NULL[,\s]*)+|\(\s*SELECT\s+" r"(?:@@|VERSION))", - 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 + r"(?i)(?:INTO\s+(?:OUTFILE|DUMPFILE)\s+'[^']+')", # File ops + 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 # Comment-based - r"(?i)(?:\/\*![0-9]{0,10}\s{0,10}(?:OR|AND|UNION|SELECT|INSERT|DELETE|DROP|" + r"(?i)(?:\/\*![0-9]*\s*(?:OR|AND|UNION|SELECT|INSERT|DELETE|DROP|" r"CONCAT|CHAR|UPDATE)\b)", # Directory Traversal - r"(?:\.\./|\.\\/){2,10}", # Multiple traversal + r"(?:\.\.\/|\.\.\\)(?:\.\.\/|\.\.\\)+", # 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{0,10}$", # Windows files + r"(?:boot\.ini|win\.ini|system\.ini|config\.sys)\s*$", # Windows files r"(?:\/proc\/self\/environ$)", # Process information - r"(?:\/var\/log\/[^\/]{1,100}$)", # Log files + r"(?:\/var\/log\/[^\/]+$)", # Log files # Command Injection # Basic commands - 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}", + r";\s*(?:ls|cat|rm|chmod|chown|wget|curl|nc|netcat|ping|telnet)\s+" + r"-[a-zA-Z]+\s+", # Download commands - r"\|\s{0,10}(?:wget|curl|fetch|lwp-download|lynx|links|GET)\s{1,20}", + r"\|\s*(?:wget|curl|fetch|lwp-download|lynx|links|GET)\s+", # Command substitution - r"(?:[;&|`]\s{0,10}(?:\$\([^)]{1,100}\)|\$\{[^}]{1,100}\}))", + r"(?:[;&|`]\s*(?:\$\([^)]+\)|\$\{[^}]+\}))", # Shell execution - r"(?:^|;)\s{0,10}(?:bash|sh|ksh|csh|tsch|zsh|ash)\s{1,20}-[a-zA-Z]{1,20}", + r"(?:^|;)\s*(?:bash|sh|ksh|csh|tsch|zsh|ash)\s+-[a-zA-Z]+", # PHP functions - r"\b(?:eval|system|exec|shell_exec|passthru|popen|proc_open)\s{0,10}\(", + r"\b(?:eval|system|exec|shell_exec|passthru|popen|proc_open)\s*\(", # File Inclusion # Protocols r"(?:php|data|zip|rar|file|glob|expect|input|phpinfo|zlib|phar|ssh2|" - r"rar|ogg|expect)://[^\s]{1,200}", + r"rar|ogg|expect)://[^\s]+", # URLs - 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})?)", + r"(?:\/\/[0-9a-zA-Z]([-.\w]*[0-9a-zA-Z])*(:[0-9]+)?(?:\/?)(?:" + r"[a-zA-Z0-9\-\.\?,'/\\\+&%\$#_]*)?)", # 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 + r"\(\s*[|&]\s*\(\s*[^)]+=[*]", # Wildcards + r"(?:\*(?:[\s\d\w]+\s*=|=\s*[\d\w\s]+))", # Attribute match + r"(?:\(\s*[&|]\s*)", # 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 + r"<!(?:ENTITY|DOCTYPE)[^>]+SYSTEM[^>]+>", # XXE + r"(?:<!\[CDATA\[.*?\]\]>)", # CDATA sections + r"(?:<\?xml.*?\?>)", # XML declarations # SSRF # Local addresses - 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 + 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 # MongoDB - r"\{\s{0,10}\$(?:where|gt|lt|ne|eq|regex|in|nin|all|size|exists|type|mod|" + r"\{\s*\$(?:where|gt|lt|ne|eq|regex|in|nin|all|size|exists|type|mod|" r"options):", - r"(?:\{\s{0,10}\$[a-zA-Z]{1,20}\s{0,10}:\s{0,10}(?:\{|\[))", # Nested operators + r"(?:\{\s*\$[a-zA-Z]+\s*:\s*(?:\{|\[))", # Nested operators # File Upload - r"(?i)filename=[\"'].{0,200}?\.(?:php\d{0,5}|phar|phtml|exe|jsp|asp|aspx|sh|" + r"(?i)filename=[\"'].*?\.(?:php\d*|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 # 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 # Basic template injection - r"\{\{\s{0,10}[^\}]{1,200}(?:system|exec|popen|eval|require|include)\s{0,10}\}\}", + r"\{\{\s*[^\}]+(?:system|exec|popen|eval|require|include)\s*\}\}", # Alternative syntax - r"\{\%\s{0,10}[^\%]{1,200}(?:system|exec|popen|eval|require|include)\s{0,10}\%\}", + r"\{\%\s*[^\%]+(?:system|exec|popen|eval|require|include)\s*\%\}", # HTTP Response Splitting - r"[\r\n]\s{0,10}(?:HTTP\/[0-9.]{1,10}|Location:|Set-Cookie:)", + r"[\r\n]\s*(?:HTTP\/[0-9.]+|Location:|Set-Cookie:)", ] compiled_patterns: list[re.Pattern]
guard/middleware.py+3 −1 modified@@ -337,7 +337,9 @@ async def dispatch( if penetration_enabled and not self._should_bypass_check( "penetration", route_config ): - detection_result, trigger_info = await detect_penetration_attempt(request) + detection_result, trigger_info = await detect_penetration_attempt( + request, self.config.regex_timeout + ) sus_specs = f"{client_ip} - {trigger_info}" if detection_result: self.suspicious_request_counts[client_ip] = (
guard/models.py+13 −0 modified@@ -376,6 +376,19 @@ class SecurityConfig(BaseModel): Whether to enable penetration attempt detection. """ + regex_timeout: float = Field( + default=2.0, + description="Timeout for regex pattern matching to prevent ReDoS attacks", + ge=0.1, + le=30.0, + ) + """ + float: + Timeout in seconds for regex pattern matching. + This prevents ReDoS (Regular Expression Denial of Service) attacks. + Must be between 0.1 and 30.0 seconds. Default is 2.0 seconds. + """ + ipinfo_token: str | None = Field( default=None, description="IPInfo API token for IP geolocation. Deprecated. "
guard/utils.py+36 −3 modified@@ -1,4 +1,5 @@ # fastapi_guard/utils.py +import concurrent.futures import logging import re from ipaddress import ip_address, ip_network @@ -349,7 +350,9 @@ async def is_ip_allowed( return True -async def detect_penetration_attempt(request: Request) -> tuple[bool, str]: +async def detect_penetration_attempt( + request: Request, regex_timeout: float = 2.0 +) -> tuple[bool, str]: """ Detect potential penetration attempts in the request. @@ -363,6 +366,8 @@ async def detect_penetration_attempt(request: Request) -> tuple[bool, str]: Args: request (Request): The FastAPI request object to analyze. + regex_timeout (float): + Timeout in seconds for each regex check. Defaults to 2.0 seconds. Returns: tuple[bool, str]: @@ -372,6 +377,30 @@ async def detect_penetration_attempt(request: Request) -> tuple[bool, str]: suspicious_patterns = await sus_patterns_handler.get_all_compiled_patterns() + def _regex_search_with_timeout( + pattern: re.Pattern, text: str, timeout: float + ) -> bool: + """Execute regex search with a timeout to prevent ReDoS.""" + + def _search() -> bool: + return pattern.search(text) is not None + + with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor: + future = executor.submit(_search) + try: + return future.result(timeout=timeout) + except concurrent.futures.TimeoutError: + logging.warning( + f"Regex timeout exceeded for pattern '{pattern.pattern}' - " + f"Potential ReDoS attack blocked." + ) + # Cancel the future to clean up + future.cancel() + return False + except Exception as e: + logging.error(f"Error in regex search: {str(e)}") + return False + async def check_value(value: str) -> tuple[bool, str]: try: import json @@ -380,15 +409,19 @@ async def check_value(value: str) -> tuple[bool, str]: if isinstance(data, dict): for pattern in suspicious_patterns: for k, v in data.items(): - if isinstance(v, str) and pattern.search(v): + if isinstance(v, str) and _regex_search_with_timeout( + pattern, + v, + regex_timeout, + ): return ( True, f"JSON field '{k}' matched pattern '{pattern.pattern}'", ) return False, "" except json.JSONDecodeError: for pattern in suspicious_patterns: - if pattern.search(value): + if _regex_search_with_timeout(pattern, value, regex_timeout): return True, f"Value matched pattern '{pattern.pattern}'" return False, ""
.mike.yml+2 −1 modified@@ -2,6 +2,7 @@ version_selector: true title_switch: true versions_file: docs/versions/versions.json versions: + - 3.0.2 - 3.0.1 - 3.0.0 - 2.1.3 @@ -23,4 +24,4 @@ versions: - 0.3.2 - latest aliases: - latest: 3.0.1 \ No newline at end of file + latest: 3.0.2 \ No newline at end of file
pyproject.toml+1 −1 modified@@ -1,6 +1,6 @@ [project] name = "fastapi_guard" -version = "3.0.1" +version = "3.0.2" 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"}
tests/test_middleware/test_security_middleware.py+1 −1 modified@@ -1198,7 +1198,7 @@ async def receive() -> dict[str, str | bytes]: assert response.status_code == status.HTTP_200_OK assert call_next_called, "call_next should be called in passive mode" - mock_detect.assert_called_once_with(request) + mock_detect.assert_called_once_with(request, 2.0) mock_log.assert_any_call( request,
tests/test_utils/test_request_checks.py+97 −0 modified@@ -740,3 +740,100 @@ async def test_is_ip_allowed_blocked_country(mocker: MockerFixture) -> None: result = await is_ip_allowed("192.168.1.1", config, mock_ipinfo) assert not result + + +@pytest.mark.asyncio +async def test_detect_penetration_attempt_regex_timeout() -> None: + """Test regex timeout handling in detect_penetration_attempt.""" + import concurrent.futures + from unittest.mock import MagicMock + + async def receive() -> dict[str, str | bytes]: + return {"type": "http.request", "body": b""} + + request = Request( + scope={ + "type": "http", + "method": "GET", + "path": "/", + "headers": [], + "query_string": b"param=test", + "client": ("127.0.0.1", 12345), + }, + receive=receive, + ) + + # Mock the ThreadPoolExecutor and Future + mock_future = MagicMock() + mock_future.result.side_effect = concurrent.futures.TimeoutError() + mock_future.cancel = MagicMock() + + mock_executor = MagicMock() + mock_executor.submit.return_value = mock_future + mock_executor.__enter__ = MagicMock(return_value=mock_executor) + mock_executor.__exit__ = MagicMock(return_value=None) + + with ( + patch("concurrent.futures.ThreadPoolExecutor", return_value=mock_executor), + patch("logging.warning") as mock_warning, + ): + result, trigger = await detect_penetration_attempt(request, regex_timeout=0.1) + + # Should not detect as attack when timeout occurs + assert not result + assert trigger == "" + + # Check that warning was logged + mock_warning.assert_called() + warning_msg = mock_warning.call_args[0][0] + assert "Regex timeout exceeded" in warning_msg + assert "Potential ReDoS attack blocked" in warning_msg + + # Check that future was cancelled (multiple times for multiple patterns) + assert mock_future.cancel.call_count > 0 + + +@pytest.mark.asyncio +async def test_detect_penetration_attempt_regex_exception() -> None: + """Test general exception handling in regex search.""" + from unittest.mock import MagicMock + + async def receive() -> dict[str, str | bytes]: + return {"type": "http.request", "body": b""} + + request = Request( + scope={ + "type": "http", + "method": "GET", + "path": "/", + "headers": [], + "query_string": b"param=test", + "client": ("127.0.0.1", 12345), + }, + receive=receive, + ) + + # Mock the ThreadPoolExecutor and Future to raise a general exception + mock_future = MagicMock() + mock_future.result.side_effect = Exception("Unexpected regex error") + + mock_executor = MagicMock() + mock_executor.submit.return_value = mock_future + mock_executor.__enter__ = MagicMock(return_value=mock_executor) + mock_executor.__exit__ = MagicMock(return_value=None) + + with ( + patch("concurrent.futures.ThreadPoolExecutor", return_value=mock_executor), + patch("logging.error") as mock_error, + ): + result, trigger = await detect_penetration_attempt(request, regex_timeout=2.0) + + # Should not detect as attack when exception occurs + assert not result + assert trigger == "" + + # Check that error was logged + mock_error.assert_called() + error_msg = mock_error.call_args[0][0] + assert "Error in regex search" in error_msg + assert "Unexpected regex error" in str(mock_error.call_args[0])
d9d50e8130b7Merge 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
5- github.com/advisories/GHSA-rrf6-pxg8-684gghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2025-54365ghsaADVISORY
- github.com/rennf93/fastapi-guard/commit/0829292c322d33dc14ab00c5451c5c138148035aghsax_refsource_MISCWEB
- github.com/rennf93/fastapi-guard/commit/d9d50e8130b7b434cdc1b001b8cfd03a06729f7fghsax_refsource_MISCWEB
- github.com/rennf93/fastapi-guard/security/advisories/GHSA-rrf6-pxg8-684gghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.