VYPR
High severityNVD Advisory· Published Jul 23, 2025· Updated Jul 24, 2025

fastapi-guard patch contains bypassable RegEx

CVE-2025-54365

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.

PackageAffected versionsPatched versions
fastapi-guardPyPI
>= 3.0.1, < 3.0.23.0.2

Affected products

1

Patches

2
0829292c322d

Merge commit from fork

https://github.com/rennf93/fastapi-guardRenzo FJul 23, 2025via ghsa
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\-\.\?,'/\\\+&amp;%\$#_]{0,500})?)",
    +        r"(?:\/\/[0-9a-zA-Z]([-.\w]*[0-9a-zA-Z])*(:[0-9]+)?(?:\/?)(?:"
    +        r"[a-zA-Z0-9\-\.\?,'/\\\+&amp;%\$#_]*)?)",
             # 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])
    
d9d50e8130b7

Merge 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\-\.\?,'/\\\+&amp;%\$#_]*)?)",
    -        # 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\-\.\?,'/\\\+&amp;%\$#_]{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

News mentions

0

No linked articles in our index yet.