pywebguard 0.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pywebguard/__init__.py +87 -0
- pywebguard/cli.py +1022 -0
- pywebguard/core/__init__.py +0 -0
- pywebguard/core/base.py +792 -0
- pywebguard/core/config.py +455 -0
- pywebguard/filters/__init__.py +0 -0
- pywebguard/filters/base.py +44 -0
- pywebguard/filters/ip_filter.py +233 -0
- pywebguard/filters/user_agent.py +102 -0
- pywebguard/frameworks/__init__.py +0 -0
- pywebguard/frameworks/_fastapi.py +191 -0
- pywebguard/frameworks/_flask.py +182 -0
- pywebguard/limiters/__init__.py +0 -0
- pywebguard/limiters/base.py +46 -0
- pywebguard/limiters/rate_limit.py +415 -0
- pywebguard/logging/__init__.py +0 -0
- pywebguard/logging/backends/__init__.py +17 -0
- pywebguard/logging/backends/_elasticsearch.py +307 -0
- pywebguard/logging/backends/_meilisearch.py +310 -0
- pywebguard/logging/backends/_mongodb.py +303 -0
- pywebguard/logging/base.py +117 -0
- pywebguard/logging/logger.py +408 -0
- pywebguard/security/__init__.py +0 -0
- pywebguard/security/base.py +44 -0
- pywebguard/security/cors.py +204 -0
- pywebguard/security/penetration.py +265 -0
- pywebguard/storage/__init__.py +0 -0
- pywebguard/storage/_mongodb.py +396 -0
- pywebguard/storage/_postgresql.py +539 -0
- pywebguard/storage/_redis.py +314 -0
- pywebguard/storage/_sqlite.py +374 -0
- pywebguard/storage/_tinydb.py +271 -0
- pywebguard/storage/base.py +327 -0
- pywebguard/storage/memory.py +245 -0
- pywebguard/utils/__init__.py +0 -0
- pywebguard/utils/ip.py +84 -0
- pywebguard/utils/request.py +65 -0
- pywebguard-0.0.0.dist-info/LICENSE +21 -0
- pywebguard-0.0.0.dist-info/METADATA +368 -0
- pywebguard-0.0.0.dist-info/RECORD +43 -0
- pywebguard-0.0.0.dist-info/WHEEL +5 -0
- pywebguard-0.0.0.dist-info/entry_points.txt +2 -0
- pywebguard-0.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Flask integration for PyWebGuard.
|
|
3
|
+
|
|
4
|
+
This module provides an extension for integrating PyWebGuard with Flask applications.
|
|
5
|
+
It handles request interception, security checks, and response processing.
|
|
6
|
+
|
|
7
|
+
Classes:
|
|
8
|
+
FlaskGuard: Flask extension for PyWebGuard
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from typing import Optional, Callable, Dict, Any, List, Union, cast
|
|
12
|
+
from functools import wraps
|
|
13
|
+
|
|
14
|
+
# Check if Flask is installed
|
|
15
|
+
try:
|
|
16
|
+
from flask import Flask, request, Response, g, jsonify
|
|
17
|
+
|
|
18
|
+
FLASK_AVAILABLE = True
|
|
19
|
+
except ImportError:
|
|
20
|
+
FLASK_AVAILABLE = False
|
|
21
|
+
|
|
22
|
+
# Create dummy classes for type checking
|
|
23
|
+
class Flask:
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
class Response:
|
|
27
|
+
pass
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
from pywebguard.core.base import Guard
|
|
31
|
+
from pywebguard.core.config import GuardConfig
|
|
32
|
+
from pywebguard.storage.base import BaseStorage
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class FlaskGuard:
|
|
36
|
+
"""
|
|
37
|
+
Flask extension for PyWebGuard.
|
|
38
|
+
|
|
39
|
+
This extension integrates PyWebGuard with Flask applications to provide
|
|
40
|
+
security features like IP filtering, rate limiting, and more.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
def __init__(
|
|
44
|
+
self,
|
|
45
|
+
app: Optional[Flask] = None,
|
|
46
|
+
config: Optional[GuardConfig] = None,
|
|
47
|
+
storage: Optional[BaseStorage] = None,
|
|
48
|
+
route_rate_limits: Optional[List[Dict[str, Any]]] = None,
|
|
49
|
+
):
|
|
50
|
+
"""
|
|
51
|
+
Initialize the Flask extension.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
app: Flask application (optional, can be initialized later with init_app)
|
|
55
|
+
config: GuardConfig object with security settings
|
|
56
|
+
storage: Storage backend for persistent data
|
|
57
|
+
route_rate_limits: List of dictionaries with route-specific rate limits
|
|
58
|
+
Each dict should have: endpoint, requests_per_minute, burst_size, auto_ban_threshold (optional)
|
|
59
|
+
|
|
60
|
+
Raises:
|
|
61
|
+
ImportError: If Flask is not installed
|
|
62
|
+
"""
|
|
63
|
+
if not FLASK_AVAILABLE:
|
|
64
|
+
raise ImportError(
|
|
65
|
+
"Flask is not installed. Install it with 'pip install pywebguard[flask]' "
|
|
66
|
+
"or 'pip install flask>=2.0.0'"
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
self.guard = Guard(
|
|
70
|
+
config=config, storage=storage, route_rate_limits=route_rate_limits
|
|
71
|
+
)
|
|
72
|
+
# Inject the Flask-specific request info extractor
|
|
73
|
+
self.guard._extract_request_info = self._extract_request_info
|
|
74
|
+
|
|
75
|
+
if app is not None:
|
|
76
|
+
self.init_app(app)
|
|
77
|
+
|
|
78
|
+
def init_app(self, app: Flask) -> None:
|
|
79
|
+
"""
|
|
80
|
+
Initialize the extension with a Flask application.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
app: Flask application
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
# Register before_request handler
|
|
87
|
+
@app.before_request
|
|
88
|
+
def before_request() -> Optional[Response]:
|
|
89
|
+
"""
|
|
90
|
+
Check if request should be allowed before processing.
|
|
91
|
+
|
|
92
|
+
Returns:
|
|
93
|
+
Response object if request is blocked, None otherwise
|
|
94
|
+
"""
|
|
95
|
+
# Check if request should be allowed
|
|
96
|
+
result = self.guard.check_request(request)
|
|
97
|
+
|
|
98
|
+
if not result["allowed"]:
|
|
99
|
+
# Return error response if request is blocked
|
|
100
|
+
return (
|
|
101
|
+
jsonify(
|
|
102
|
+
{
|
|
103
|
+
"detail": "Request blocked by security policy",
|
|
104
|
+
"reason": result["details"],
|
|
105
|
+
}
|
|
106
|
+
),
|
|
107
|
+
403,
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
return None
|
|
111
|
+
|
|
112
|
+
# Register after_request handler
|
|
113
|
+
@app.after_request
|
|
114
|
+
def after_request(response: Response) -> Response:
|
|
115
|
+
"""
|
|
116
|
+
Process response after request handling.
|
|
117
|
+
|
|
118
|
+
Args:
|
|
119
|
+
response: Flask response object
|
|
120
|
+
|
|
121
|
+
Returns:
|
|
122
|
+
Processed response object
|
|
123
|
+
"""
|
|
124
|
+
# Update metrics based on request and response
|
|
125
|
+
self.guard.update_metrics(request, response)
|
|
126
|
+
|
|
127
|
+
# Add CORS headers if needed
|
|
128
|
+
if self.guard.cors_handler.config.enabled:
|
|
129
|
+
self.guard.cors_handler.add_cors_headers(request, response)
|
|
130
|
+
|
|
131
|
+
return response
|
|
132
|
+
|
|
133
|
+
def _extract_request_info(self, request) -> Dict[str, Any]:
|
|
134
|
+
"""
|
|
135
|
+
Extract information from a Flask request object.
|
|
136
|
+
|
|
137
|
+
Args:
|
|
138
|
+
request: The Flask request object
|
|
139
|
+
|
|
140
|
+
Returns:
|
|
141
|
+
Dict with request information
|
|
142
|
+
"""
|
|
143
|
+
# Get the real IP from headers if available
|
|
144
|
+
client_host = request.remote_addr
|
|
145
|
+
forwarded_for = request.headers.get("X-Forwarded-For")
|
|
146
|
+
if forwarded_for:
|
|
147
|
+
client_host = forwarded_for.split(",")[0].strip()
|
|
148
|
+
|
|
149
|
+
# Always use the string representation of request.user_agent
|
|
150
|
+
user_agent = str(request.user_agent)
|
|
151
|
+
|
|
152
|
+
# Build headers from environ to avoid UserAgent object
|
|
153
|
+
safe_headers = {}
|
|
154
|
+
for k, v in request.environ.items():
|
|
155
|
+
if k.startswith("HTTP_"):
|
|
156
|
+
header = k[5:].replace("_", "-").title()
|
|
157
|
+
safe_headers[header] = str(v)
|
|
158
|
+
# Add Content-Type and Content-Length if present
|
|
159
|
+
if "CONTENT_TYPE" in request.environ:
|
|
160
|
+
safe_headers["Content-Type"] = str(request.environ["CONTENT_TYPE"])
|
|
161
|
+
if "CONTENT_LENGTH" in request.environ:
|
|
162
|
+
safe_headers["Content-Length"] = str(request.environ["CONTENT_LENGTH"])
|
|
163
|
+
|
|
164
|
+
request_info = {
|
|
165
|
+
"ip": client_host,
|
|
166
|
+
"user_agent": str(user_agent),
|
|
167
|
+
"method": request.method,
|
|
168
|
+
"path": request.path,
|
|
169
|
+
"query": request.args.to_dict(),
|
|
170
|
+
"headers": safe_headers,
|
|
171
|
+
}
|
|
172
|
+
return self._sanitize_for_json(request_info)
|
|
173
|
+
|
|
174
|
+
def _sanitize_for_json(self, obj):
|
|
175
|
+
if isinstance(obj, dict):
|
|
176
|
+
return {k: self._sanitize_for_json(v) for k, v in obj.items()}
|
|
177
|
+
elif isinstance(obj, list):
|
|
178
|
+
return [self._sanitize_for_json(v) for v in obj]
|
|
179
|
+
elif isinstance(obj, (str, int, float, bool)) or obj is None:
|
|
180
|
+
return obj
|
|
181
|
+
else:
|
|
182
|
+
return str(obj)
|
|
File without changes
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Base limiter interfaces for PyWebGuard with both sync and async support.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from typing import Dict, Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class BaseLimiter(ABC):
|
|
10
|
+
"""
|
|
11
|
+
Abstract base class for synchronous limiters.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
@abstractmethod
|
|
15
|
+
def check_limit(self, identifier: str, path: str = None) -> Dict[str, Any]:
|
|
16
|
+
"""
|
|
17
|
+
Check if a request should be limited.
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
identifier: The identifier to check
|
|
21
|
+
path: The request path (for route-specific rate limiting)
|
|
22
|
+
|
|
23
|
+
Returns:
|
|
24
|
+
Dict with allowed status and details
|
|
25
|
+
"""
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class AsyncBaseLimiter(ABC):
|
|
30
|
+
"""
|
|
31
|
+
Abstract base class for asynchronous limiters.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
@abstractmethod
|
|
35
|
+
async def check_limit(self, identifier: str, path: str = None) -> Dict[str, Any]:
|
|
36
|
+
"""
|
|
37
|
+
Check if a request should be limited asynchronously.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
identifier: The identifier to check
|
|
41
|
+
path: The request path (for route-specific rate limiting)
|
|
42
|
+
|
|
43
|
+
Returns:
|
|
44
|
+
Dict with allowed status and details
|
|
45
|
+
"""
|
|
46
|
+
pass
|
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Rate limiting functionality for PyWebGuard with both sync and async support.
|
|
3
|
+
Includes support for per-route rate limiting configurations.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from typing import Dict, Any, Optional, Union
|
|
7
|
+
import time
|
|
8
|
+
from pywebguard.core.config import RateLimitConfig
|
|
9
|
+
from pywebguard.storage.base import BaseStorage, AsyncBaseStorage
|
|
10
|
+
from pywebguard.limiters.base import BaseLimiter, AsyncBaseLimiter
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class RateLimiter(BaseLimiter):
|
|
14
|
+
"""
|
|
15
|
+
Limit request rates based on IP address or other identifiers (synchronous).
|
|
16
|
+
Supports both global and per-route rate limiting.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
config: RateLimitConfig,
|
|
22
|
+
storage: BaseStorage,
|
|
23
|
+
):
|
|
24
|
+
"""
|
|
25
|
+
Initialize the rate limiter.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
config: Rate limit configuration (global default)
|
|
29
|
+
storage: Storage backend for persistent data
|
|
30
|
+
"""
|
|
31
|
+
self.config = config
|
|
32
|
+
self.storage = storage
|
|
33
|
+
self.route_configs = {} # Maps route patterns to custom RateLimitConfig objects
|
|
34
|
+
|
|
35
|
+
def add_route_config(
|
|
36
|
+
self, route_pattern: str, config: Union[RateLimitConfig, Dict[str, Any]]
|
|
37
|
+
):
|
|
38
|
+
"""
|
|
39
|
+
Add a custom rate limit configuration for a specific route pattern.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
route_pattern: The route pattern to match (can include wildcards like * and **)
|
|
43
|
+
config: Custom rate limit configuration for this route
|
|
44
|
+
"""
|
|
45
|
+
if isinstance(config, dict):
|
|
46
|
+
# Convert dict to RateLimitConfig
|
|
47
|
+
route_config = RateLimitConfig(**config)
|
|
48
|
+
else:
|
|
49
|
+
route_config = config
|
|
50
|
+
|
|
51
|
+
self.route_configs[route_pattern] = route_config
|
|
52
|
+
|
|
53
|
+
def get_config_for_route(self, path: str) -> RateLimitConfig:
|
|
54
|
+
"""
|
|
55
|
+
Get the appropriate rate limit configuration for a given path.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
path: The request path
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
The route-specific config if matched, otherwise the default config
|
|
62
|
+
"""
|
|
63
|
+
# Check for exact match first
|
|
64
|
+
if path in self.route_configs:
|
|
65
|
+
return self.route_configs[path]
|
|
66
|
+
|
|
67
|
+
# Check for pattern matches
|
|
68
|
+
for pattern, config in self.route_configs.items():
|
|
69
|
+
if self._match_route_pattern(pattern, path):
|
|
70
|
+
return config
|
|
71
|
+
|
|
72
|
+
# Fall back to default config
|
|
73
|
+
return self.config
|
|
74
|
+
|
|
75
|
+
def _match_route_pattern(self, pattern: str, path: str) -> bool:
|
|
76
|
+
"""
|
|
77
|
+
Check if a path matches a route pattern.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
pattern: The route pattern (can include wildcards)
|
|
81
|
+
path: The request path
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
True if the path matches the pattern, False otherwise
|
|
85
|
+
"""
|
|
86
|
+
# Handle trailing slashes consistently
|
|
87
|
+
if pattern.endswith("/") and not path.endswith("/"):
|
|
88
|
+
pattern = pattern[:-1]
|
|
89
|
+
elif not pattern.endswith("/") and path.endswith("/"):
|
|
90
|
+
path = path[:-1]
|
|
91
|
+
|
|
92
|
+
# Simple wildcard matching implementation
|
|
93
|
+
if pattern == "*":
|
|
94
|
+
return True
|
|
95
|
+
|
|
96
|
+
if pattern.endswith("/**"):
|
|
97
|
+
# Match any path that starts with the pattern prefix
|
|
98
|
+
prefix = pattern[:-3]
|
|
99
|
+
return path.startswith(prefix)
|
|
100
|
+
|
|
101
|
+
if pattern.endswith("/*"):
|
|
102
|
+
# Match any path that starts with the pattern prefix and has no additional segments
|
|
103
|
+
prefix = pattern[:-2]
|
|
104
|
+
if not path.startswith(prefix):
|
|
105
|
+
return False
|
|
106
|
+
remaining = path[len(prefix) :]
|
|
107
|
+
return remaining == "" or (
|
|
108
|
+
remaining.startswith("/") and "/" not in remaining[1:]
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
# Exact match
|
|
112
|
+
return pattern == path
|
|
113
|
+
|
|
114
|
+
def check_limit(self, identifier: str, path: str = None) -> Dict[str, Any]:
|
|
115
|
+
"""
|
|
116
|
+
Check if a request should be rate limited.
|
|
117
|
+
|
|
118
|
+
Args:
|
|
119
|
+
identifier: The identifier to check (usually IP address)
|
|
120
|
+
path: The request path (for route-specific rate limiting)
|
|
121
|
+
|
|
122
|
+
Returns:
|
|
123
|
+
Dict with allowed status, remaining requests, and reset time
|
|
124
|
+
"""
|
|
125
|
+
# Get the appropriate config for this route
|
|
126
|
+
config = self.config
|
|
127
|
+
matched_pattern = None
|
|
128
|
+
if path is not None:
|
|
129
|
+
# Check for exact match first
|
|
130
|
+
if path in self.route_configs:
|
|
131
|
+
config = self.route_configs[path]
|
|
132
|
+
matched_pattern = path
|
|
133
|
+
else:
|
|
134
|
+
# Check for pattern matches
|
|
135
|
+
for pattern, route_config in self.route_configs.items():
|
|
136
|
+
if self._match_route_pattern(pattern, path):
|
|
137
|
+
config = route_config
|
|
138
|
+
matched_pattern = pattern
|
|
139
|
+
break
|
|
140
|
+
|
|
141
|
+
if not config.enabled:
|
|
142
|
+
return {"allowed": True, "remaining": -1, "reset": -1}
|
|
143
|
+
|
|
144
|
+
current_time = int(time.time())
|
|
145
|
+
current_minute = current_time // 60 # Use minute-based window
|
|
146
|
+
|
|
147
|
+
# Use matched pattern in the rate limit key if found
|
|
148
|
+
path_suffix = f":{matched_pattern}" if matched_pattern else ""
|
|
149
|
+
window_key = f"ratelimit:{identifier}{path_suffix}:{current_minute}"
|
|
150
|
+
|
|
151
|
+
# Get current count for this window
|
|
152
|
+
count = self.storage.get(window_key) or 0
|
|
153
|
+
if count < config.requests_per_minute:
|
|
154
|
+
new_count = self.storage.increment(window_key, 1, 60) # 60 second TTL
|
|
155
|
+
remaining = max(0, config.requests_per_minute - new_count)
|
|
156
|
+
reset_time = (current_minute + 1) * 60 # Next minute
|
|
157
|
+
result = {
|
|
158
|
+
"allowed": True,
|
|
159
|
+
"remaining": remaining,
|
|
160
|
+
"reset": reset_time,
|
|
161
|
+
"limit": config.requests_per_minute,
|
|
162
|
+
"reason": None,
|
|
163
|
+
}
|
|
164
|
+
return result
|
|
165
|
+
else:
|
|
166
|
+
# Check if burst is enabled and available
|
|
167
|
+
if config.burst_size > 0:
|
|
168
|
+
burst_key = f"ratelimit:burst:{identifier}{path_suffix}"
|
|
169
|
+
burst_count = self.storage.get(burst_key) or 0
|
|
170
|
+
if burst_count < config.burst_size:
|
|
171
|
+
self.storage.increment(burst_key, 1, 3600)
|
|
172
|
+
reset_time = (current_minute + 1) * 60 # Next minute
|
|
173
|
+
result = {
|
|
174
|
+
"allowed": True,
|
|
175
|
+
"remaining": 0,
|
|
176
|
+
"reset": reset_time,
|
|
177
|
+
"limit": config.requests_per_minute,
|
|
178
|
+
"reason": "Using burst allowance",
|
|
179
|
+
}
|
|
180
|
+
return result
|
|
181
|
+
|
|
182
|
+
# Block the request if no burst available or burst not enabled
|
|
183
|
+
reset_time = (current_minute + 1) * 60
|
|
184
|
+
if config.auto_ban_threshold > 0:
|
|
185
|
+
violation_key = f"ratelimit:violations:{identifier}{path_suffix}"
|
|
186
|
+
violations = self.storage.increment(
|
|
187
|
+
violation_key, 1, 86400
|
|
188
|
+
) # 24 hour TTL
|
|
189
|
+
if violations >= config.auto_ban_threshold:
|
|
190
|
+
ban_key = f"banned_ip:{identifier}"
|
|
191
|
+
self.storage.set(
|
|
192
|
+
ban_key,
|
|
193
|
+
{
|
|
194
|
+
"reason": f"Rate limit exceeded for {path or 'global'}",
|
|
195
|
+
"timestamp": current_time,
|
|
196
|
+
},
|
|
197
|
+
config.auto_ban_duration_minutes * 60,
|
|
198
|
+
)
|
|
199
|
+
result = {
|
|
200
|
+
"allowed": False,
|
|
201
|
+
"remaining": 0,
|
|
202
|
+
"reset": reset_time,
|
|
203
|
+
"limit": config.requests_per_minute,
|
|
204
|
+
"reason": f"Rate limit exceeded for {path or 'global'}",
|
|
205
|
+
}
|
|
206
|
+
return result
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
class AsyncRateLimiter(AsyncBaseLimiter):
|
|
210
|
+
"""
|
|
211
|
+
Limit request rates based on IP address or other identifiers (asynchronous).
|
|
212
|
+
Supports both global and per-route rate limiting.
|
|
213
|
+
"""
|
|
214
|
+
|
|
215
|
+
def __init__(
|
|
216
|
+
self,
|
|
217
|
+
config: RateLimitConfig,
|
|
218
|
+
storage: AsyncBaseStorage,
|
|
219
|
+
):
|
|
220
|
+
"""
|
|
221
|
+
Initialize the async rate limiter.
|
|
222
|
+
|
|
223
|
+
Args:
|
|
224
|
+
config: Rate limit configuration (global default)
|
|
225
|
+
storage: Async storage backend for persistent data
|
|
226
|
+
"""
|
|
227
|
+
self.config = config
|
|
228
|
+
self.storage = storage
|
|
229
|
+
self.route_configs = {} # Maps route patterns to custom RateLimitConfig objects
|
|
230
|
+
|
|
231
|
+
def add_route_config(
|
|
232
|
+
self, route_pattern: str, config: Union[RateLimitConfig, Dict[str, Any]]
|
|
233
|
+
):
|
|
234
|
+
"""
|
|
235
|
+
Add a custom rate limit configuration for a specific route pattern.
|
|
236
|
+
|
|
237
|
+
Args:
|
|
238
|
+
route_pattern: The route pattern to match (can include wildcards like * and **)
|
|
239
|
+
config: Custom rate limit configuration for this route
|
|
240
|
+
"""
|
|
241
|
+
if isinstance(config, dict):
|
|
242
|
+
# Convert dict to RateLimitConfig
|
|
243
|
+
route_config = RateLimitConfig(**config)
|
|
244
|
+
else:
|
|
245
|
+
route_config = config
|
|
246
|
+
|
|
247
|
+
self.route_configs[route_pattern] = route_config
|
|
248
|
+
|
|
249
|
+
def get_config_for_route(self, path: str) -> RateLimitConfig:
|
|
250
|
+
"""
|
|
251
|
+
Get the appropriate rate limit configuration for a given path.
|
|
252
|
+
|
|
253
|
+
Args:
|
|
254
|
+
path: The request path
|
|
255
|
+
|
|
256
|
+
Returns:
|
|
257
|
+
The route-specific config if matched, otherwise the default config
|
|
258
|
+
"""
|
|
259
|
+
# Check for exact match first
|
|
260
|
+
if path in self.route_configs:
|
|
261
|
+
return self.route_configs[path]
|
|
262
|
+
|
|
263
|
+
# Check for pattern matches
|
|
264
|
+
for pattern, config in self.route_configs.items():
|
|
265
|
+
if self._match_route_pattern(pattern, path):
|
|
266
|
+
return config
|
|
267
|
+
|
|
268
|
+
# Fall back to default config
|
|
269
|
+
return self.config
|
|
270
|
+
|
|
271
|
+
def _match_route_pattern(self, pattern: str, path: str) -> bool:
|
|
272
|
+
"""
|
|
273
|
+
Check if a path matches a route pattern.
|
|
274
|
+
|
|
275
|
+
Args:
|
|
276
|
+
pattern: The route pattern (can include wildcards)
|
|
277
|
+
path: The request path
|
|
278
|
+
|
|
279
|
+
Returns:
|
|
280
|
+
True if the path matches the pattern, False otherwise
|
|
281
|
+
"""
|
|
282
|
+
# Handle trailing slashes consistently
|
|
283
|
+
if pattern.endswith("/") and not path.endswith("/"):
|
|
284
|
+
pattern = pattern[:-1]
|
|
285
|
+
elif not pattern.endswith("/") and path.endswith("/"):
|
|
286
|
+
path = path[:-1]
|
|
287
|
+
|
|
288
|
+
# Simple wildcard matching implementation
|
|
289
|
+
if pattern == "*":
|
|
290
|
+
return True
|
|
291
|
+
|
|
292
|
+
if pattern.endswith("/**"):
|
|
293
|
+
# Match any path that starts with the pattern prefix
|
|
294
|
+
prefix = pattern[:-3]
|
|
295
|
+
return path.startswith(prefix)
|
|
296
|
+
|
|
297
|
+
if pattern.endswith("/*"):
|
|
298
|
+
# Match any path that starts with the pattern prefix and has no additional segments
|
|
299
|
+
prefix = pattern[:-2]
|
|
300
|
+
if not path.startswith(prefix):
|
|
301
|
+
return False
|
|
302
|
+
remaining = path[len(prefix) :]
|
|
303
|
+
return remaining == "" or (
|
|
304
|
+
remaining.startswith("/") and "/" not in remaining[1:]
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
# Exact match
|
|
308
|
+
return pattern == path
|
|
309
|
+
|
|
310
|
+
async def check_limit(self, identifier: str, path: str = None) -> Dict[str, Any]:
|
|
311
|
+
"""
|
|
312
|
+
Check if a request should be rate limited asynchronously.
|
|
313
|
+
|
|
314
|
+
Args:
|
|
315
|
+
identifier: The identifier to check (usually IP address)
|
|
316
|
+
path: The request path (for route-specific rate limiting)
|
|
317
|
+
|
|
318
|
+
Returns:
|
|
319
|
+
Dict with allowed status, remaining requests, and reset time
|
|
320
|
+
"""
|
|
321
|
+
# Get the appropriate config for this route
|
|
322
|
+
config = self.config
|
|
323
|
+
matched_pattern = None
|
|
324
|
+
if path is not None:
|
|
325
|
+
# Check for exact match first
|
|
326
|
+
if path in self.route_configs:
|
|
327
|
+
config = self.route_configs[path]
|
|
328
|
+
matched_pattern = path
|
|
329
|
+
else:
|
|
330
|
+
# Check for pattern matches
|
|
331
|
+
for pattern, route_config in self.route_configs.items():
|
|
332
|
+
if self._match_route_pattern(pattern, path):
|
|
333
|
+
config = route_config
|
|
334
|
+
matched_pattern = pattern
|
|
335
|
+
break
|
|
336
|
+
|
|
337
|
+
if not config.enabled:
|
|
338
|
+
return {"allowed": True, "remaining": -1, "reset": -1}
|
|
339
|
+
|
|
340
|
+
current_time = int(time.time())
|
|
341
|
+
current_minute = current_time // 60 # Use minute-based window
|
|
342
|
+
|
|
343
|
+
# Use a consistent window key format that includes the pattern for route-specific limits
|
|
344
|
+
if matched_pattern:
|
|
345
|
+
window_key = f"ratelimit:{identifier}:{matched_pattern}:{current_minute}"
|
|
346
|
+
else:
|
|
347
|
+
window_key = f"ratelimit:{identifier}:global:{current_minute}"
|
|
348
|
+
|
|
349
|
+
# Get current count for this window
|
|
350
|
+
count = await self.storage.get(window_key) or 0
|
|
351
|
+
|
|
352
|
+
# Check if we're under the limit
|
|
353
|
+
if count < config.requests_per_minute:
|
|
354
|
+
# Increment the counter with a TTL that extends to the next minute
|
|
355
|
+
next_minute = (current_minute + 1) * 60
|
|
356
|
+
ttl = next_minute - current_time
|
|
357
|
+
new_count = await self.storage.increment(window_key, 1, ttl)
|
|
358
|
+
remaining = max(0, config.requests_per_minute - new_count)
|
|
359
|
+
reset_time = next_minute
|
|
360
|
+
result = {
|
|
361
|
+
"allowed": True,
|
|
362
|
+
"remaining": remaining,
|
|
363
|
+
"reset": reset_time,
|
|
364
|
+
"limit": config.requests_per_minute,
|
|
365
|
+
"reason": None,
|
|
366
|
+
}
|
|
367
|
+
return result
|
|
368
|
+
else:
|
|
369
|
+
# Check if burst is enabled and available
|
|
370
|
+
if config.burst_size > 0:
|
|
371
|
+
burst_key = (
|
|
372
|
+
f"ratelimit:burst:{identifier}:{matched_pattern or 'global'}"
|
|
373
|
+
)
|
|
374
|
+
burst_count = await self.storage.get(burst_key) or 0
|
|
375
|
+
if burst_count < config.burst_size:
|
|
376
|
+
await self.storage.increment(
|
|
377
|
+
burst_key, 1, 3600
|
|
378
|
+
) # 1 hour TTL for burst
|
|
379
|
+
reset_time = (current_minute + 1) * 60 # Next minute
|
|
380
|
+
result = {
|
|
381
|
+
"allowed": True,
|
|
382
|
+
"remaining": 0,
|
|
383
|
+
"reset": reset_time,
|
|
384
|
+
"limit": config.requests_per_minute,
|
|
385
|
+
"reason": "Using burst allowance",
|
|
386
|
+
}
|
|
387
|
+
return result
|
|
388
|
+
|
|
389
|
+
# Block the request if no burst available or burst not enabled
|
|
390
|
+
reset_time = (current_minute + 1) * 60
|
|
391
|
+
if config.auto_ban_threshold > 0:
|
|
392
|
+
violation_key = (
|
|
393
|
+
f"ratelimit:violations:{identifier}:{matched_pattern or 'global'}"
|
|
394
|
+
)
|
|
395
|
+
violations = await self.storage.increment(
|
|
396
|
+
violation_key, 1, 86400
|
|
397
|
+
) # 24 hour TTL
|
|
398
|
+
if violations >= config.auto_ban_threshold:
|
|
399
|
+
ban_key = f"banned_ip:{identifier}"
|
|
400
|
+
await self.storage.set(
|
|
401
|
+
ban_key,
|
|
402
|
+
{
|
|
403
|
+
"reason": f"Rate limit exceeded for {path or 'global'}",
|
|
404
|
+
"timestamp": current_time,
|
|
405
|
+
},
|
|
406
|
+
config.auto_ban_duration_minutes * 60,
|
|
407
|
+
)
|
|
408
|
+
result = {
|
|
409
|
+
"allowed": False,
|
|
410
|
+
"remaining": 0,
|
|
411
|
+
"reset": reset_time,
|
|
412
|
+
"limit": config.requests_per_minute,
|
|
413
|
+
"reason": f"Rate limit exceeded for {path or 'global'} try again in {reset_time - current_time} seconds",
|
|
414
|
+
}
|
|
415
|
+
return result
|
|
File without changes
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Logging backends for PyWebGuard.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from ._meilisearch import MeilisearchBackend, AsyncMeilisearchBackend
|
|
6
|
+
|
|
7
|
+
# from ._elasticsearch import ElasticsearchBackend, AsyncElasticsearchBackend
|
|
8
|
+
# from ._mongodb import MongoDBBackend, AsyncMongoDBBackend
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"MeilisearchBackend",
|
|
12
|
+
"AsyncMeilisearchBackend",
|
|
13
|
+
# "ElasticsearchBackend",
|
|
14
|
+
# "AsyncElasticsearchBackend",
|
|
15
|
+
# "MongoDBBackend",
|
|
16
|
+
# "AsyncMongoDBBackend",
|
|
17
|
+
]
|