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
pywebguard/core/base.py
ADDED
|
@@ -0,0 +1,792 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Base classes for PyWebGuard with both sync and async support.
|
|
3
|
+
|
|
4
|
+
This module provides the core Guard classes that handle security features
|
|
5
|
+
for both synchronous and asynchronous web applications. It includes:
|
|
6
|
+
- IP filtering
|
|
7
|
+
- User agent filtering
|
|
8
|
+
- Rate limiting
|
|
9
|
+
- Penetration detection
|
|
10
|
+
- CORS handling
|
|
11
|
+
- Logging
|
|
12
|
+
|
|
13
|
+
Classes:
|
|
14
|
+
RequestProtocol: Protocol defining the minimum required attributes for a request object
|
|
15
|
+
ResponseProtocol: Protocol defining the minimum required attributes for a response object
|
|
16
|
+
Guard: Base Guard class for synchronous web applications
|
|
17
|
+
AsyncGuard: Base Guard class for asynchronous web applications
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import os
|
|
21
|
+
from typing import List, Optional, Dict, Any, Union, Type, Protocol, TypeVar, cast
|
|
22
|
+
from datetime import datetime
|
|
23
|
+
|
|
24
|
+
from pywebguard.core.config import GuardConfig, RateLimitConfig
|
|
25
|
+
from pywebguard.storage.base import BaseStorage, AsyncBaseStorage
|
|
26
|
+
from pywebguard.storage.memory import MemoryStorage, AsyncMemoryStorage
|
|
27
|
+
|
|
28
|
+
# Conditional imports to handle optional dependencies
|
|
29
|
+
try:
|
|
30
|
+
from pywebguard.storage._redis import RedisStorage, AsyncRedisStorage
|
|
31
|
+
|
|
32
|
+
REDIS_AVAILABLE = True
|
|
33
|
+
except ImportError:
|
|
34
|
+
REDIS_AVAILABLE = False
|
|
35
|
+
|
|
36
|
+
# Create dummy classes for type checking
|
|
37
|
+
class RedisStorage(BaseStorage):
|
|
38
|
+
pass
|
|
39
|
+
|
|
40
|
+
class AsyncRedisStorage(AsyncBaseStorage):
|
|
41
|
+
pass
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
try:
|
|
45
|
+
from pywebguard.storage._sqlite import SQLiteStorage, AsyncSQLiteStorage
|
|
46
|
+
|
|
47
|
+
SQLITE_AVAILABLE = True
|
|
48
|
+
except ImportError:
|
|
49
|
+
SQLITE_AVAILABLE = False
|
|
50
|
+
|
|
51
|
+
# Create dummy classes for type checking
|
|
52
|
+
class SQLiteStorage(BaseStorage):
|
|
53
|
+
pass
|
|
54
|
+
|
|
55
|
+
class AsyncSQLiteStorage(AsyncBaseStorage):
|
|
56
|
+
pass
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
try:
|
|
60
|
+
from pywebguard.storage._tinydb import TinyDBStorage, AsyncTinyDBStorage
|
|
61
|
+
|
|
62
|
+
TINYDB_AVAILABLE = True
|
|
63
|
+
except ImportError:
|
|
64
|
+
TINYDB_AVAILABLE = False
|
|
65
|
+
|
|
66
|
+
# Create dummy classes for type checking
|
|
67
|
+
class TinyDBStorage(BaseStorage):
|
|
68
|
+
pass
|
|
69
|
+
|
|
70
|
+
class AsyncTinyDBStorage(AsyncBaseStorage):
|
|
71
|
+
pass
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
try:
|
|
75
|
+
from pywebguard.storage._mongodb import MongoDBStorage, AsyncMongoDBStorage
|
|
76
|
+
|
|
77
|
+
MONGODB_AVAILABLE = True
|
|
78
|
+
except ImportError:
|
|
79
|
+
MONGODB_AVAILABLE = False
|
|
80
|
+
|
|
81
|
+
# Create dummy classes for type checking
|
|
82
|
+
class MongoDBStorage(BaseStorage):
|
|
83
|
+
pass
|
|
84
|
+
|
|
85
|
+
class AsyncMongoDBStorage(AsyncBaseStorage):
|
|
86
|
+
pass
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
try:
|
|
90
|
+
from pywebguard.storage._postgresql import PostgreSQLStorage, AsyncPostgreSQLStorage
|
|
91
|
+
|
|
92
|
+
POSTGRESQL_AVAILABLE = True
|
|
93
|
+
except ImportError:
|
|
94
|
+
POSTGRESQL_AVAILABLE = False
|
|
95
|
+
|
|
96
|
+
# Create dummy classes for type checking
|
|
97
|
+
class PostgreSQLStorage(BaseStorage):
|
|
98
|
+
pass
|
|
99
|
+
|
|
100
|
+
class AsyncPostgreSQLStorage(AsyncBaseStorage):
|
|
101
|
+
pass
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
from pywebguard.filters.ip_filter import IPFilter, AsyncIPFilter
|
|
105
|
+
from pywebguard.filters.user_agent import UserAgentFilter, AsyncUserAgentFilter
|
|
106
|
+
from pywebguard.limiters.rate_limit import RateLimiter, AsyncRateLimiter
|
|
107
|
+
from pywebguard.security.penetration import (
|
|
108
|
+
PenetrationDetector,
|
|
109
|
+
AsyncPenetrationDetector,
|
|
110
|
+
)
|
|
111
|
+
from pywebguard.security.cors import CORSHandler, AsyncCORSHandler
|
|
112
|
+
from pywebguard.logging.logger import SecurityLogger, AsyncSecurityLogger
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class RequestProtocol(Protocol):
|
|
116
|
+
"""Protocol defining the minimum required attributes for a request object."""
|
|
117
|
+
|
|
118
|
+
remote_addr: str
|
|
119
|
+
user_agent: str
|
|
120
|
+
method: str
|
|
121
|
+
path: str
|
|
122
|
+
query_string: str
|
|
123
|
+
headers: Dict[str, str]
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class ResponseProtocol(Protocol):
|
|
127
|
+
"""Protocol defining the minimum required attributes for a response object."""
|
|
128
|
+
|
|
129
|
+
status_code: int
|
|
130
|
+
headers: Dict[str, str]
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class Guard:
|
|
134
|
+
"""
|
|
135
|
+
Base Guard class that handles security features (synchronous).
|
|
136
|
+
|
|
137
|
+
This is the main class that users will interact with for synchronous
|
|
138
|
+
web applications. It provides a unified interface for all security
|
|
139
|
+
features including IP filtering, rate limiting, and more.
|
|
140
|
+
|
|
141
|
+
Attributes:
|
|
142
|
+
config: The GuardConfig instance containing all security settings
|
|
143
|
+
storage: The storage backend for persistent data
|
|
144
|
+
ip_filter: IP filtering component
|
|
145
|
+
user_agent_filter: User agent filtering component
|
|
146
|
+
rate_limiter: Rate limiting component
|
|
147
|
+
penetration_detector: Penetration detection component
|
|
148
|
+
cors_handler: CORS handling component
|
|
149
|
+
logger: Security logging component
|
|
150
|
+
"""
|
|
151
|
+
|
|
152
|
+
def __init__(
|
|
153
|
+
self,
|
|
154
|
+
config: Optional[GuardConfig] = None,
|
|
155
|
+
storage: Optional[BaseStorage] = None,
|
|
156
|
+
route_rate_limits: Optional[List[Dict[str, Any]]] = None,
|
|
157
|
+
) -> None:
|
|
158
|
+
"""
|
|
159
|
+
Initialize the Guard with configuration and storage.
|
|
160
|
+
|
|
161
|
+
Args:
|
|
162
|
+
config: GuardConfig object with security settings. If None, default config is used.
|
|
163
|
+
storage: Storage backend for persistent data. If None, storage is determined from config.
|
|
164
|
+
route_rate_limits: List of dictionaries with route-specific rate limits.
|
|
165
|
+
Each dict should have: endpoint, requests_per_minute, burst_size, auto_ban_threshold (optional)
|
|
166
|
+
|
|
167
|
+
Raises:
|
|
168
|
+
ImportError: If the required storage backend is not installed
|
|
169
|
+
"""
|
|
170
|
+
# Use environment variables if no config is provided
|
|
171
|
+
if config is None:
|
|
172
|
+
config = GuardConfig.from_env()
|
|
173
|
+
self.config = config
|
|
174
|
+
|
|
175
|
+
# Initialize storage
|
|
176
|
+
if storage is not None:
|
|
177
|
+
self.storage = storage
|
|
178
|
+
else:
|
|
179
|
+
self.storage = self._create_storage_from_config()
|
|
180
|
+
|
|
181
|
+
# Initialize components based on config
|
|
182
|
+
self._initialize_components()
|
|
183
|
+
|
|
184
|
+
# Add route-specific rate limits if provided
|
|
185
|
+
if route_rate_limits:
|
|
186
|
+
self.add_route_rate_limits(route_rate_limits)
|
|
187
|
+
|
|
188
|
+
def _create_storage_from_config(self) -> BaseStorage:
|
|
189
|
+
"""
|
|
190
|
+
Create a storage backend based on configuration.
|
|
191
|
+
|
|
192
|
+
Returns:
|
|
193
|
+
BaseStorage: The storage backend instance
|
|
194
|
+
|
|
195
|
+
Raises:
|
|
196
|
+
ImportError: If the required storage backend is not installed
|
|
197
|
+
ValueError: If the storage type is invalid
|
|
198
|
+
"""
|
|
199
|
+
storage_type = self.config.storage.type.lower()
|
|
200
|
+
|
|
201
|
+
if storage_type == "memory":
|
|
202
|
+
return MemoryStorage()
|
|
203
|
+
|
|
204
|
+
elif storage_type == "redis":
|
|
205
|
+
if not REDIS_AVAILABLE:
|
|
206
|
+
raise ImportError(
|
|
207
|
+
"Redis storage is not available. Install it with 'pip install pywebguard[redis]'"
|
|
208
|
+
)
|
|
209
|
+
return RedisStorage(
|
|
210
|
+
url=self.config.storage.url or "redis://localhost:6379/0",
|
|
211
|
+
prefix=self.config.storage.prefix,
|
|
212
|
+
ttl=self.config.storage.ttl,
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
elif storage_type == "sqlite":
|
|
216
|
+
if not SQLITE_AVAILABLE:
|
|
217
|
+
raise ImportError(
|
|
218
|
+
"SQLite storage is not available. Install it with 'pip install pywebguard[sqlite]'"
|
|
219
|
+
)
|
|
220
|
+
return SQLiteStorage(
|
|
221
|
+
db_path=self.config.storage.url or "pywebguard.db",
|
|
222
|
+
table_prefix=self.config.storage.prefix,
|
|
223
|
+
ttl=self.config.storage.ttl,
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
elif storage_type == "tinydb":
|
|
227
|
+
if not TINYDB_AVAILABLE:
|
|
228
|
+
raise ImportError(
|
|
229
|
+
"TinyDB storage is not available. Install it with 'pip install pywebguard[tinydb]'"
|
|
230
|
+
)
|
|
231
|
+
return TinyDBStorage(
|
|
232
|
+
db_path=self.config.storage.url or "pywebguard.json",
|
|
233
|
+
ttl=self.config.storage.ttl,
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
elif storage_type == "mongodb":
|
|
237
|
+
if not MONGODB_AVAILABLE:
|
|
238
|
+
raise ImportError(
|
|
239
|
+
"MongoDB storage is not available. Install it with 'pip install pywebguard[mongodb]'"
|
|
240
|
+
)
|
|
241
|
+
return MongoDBStorage(
|
|
242
|
+
url=self.config.storage.url or "mongodb://localhost:27017/pywebguard",
|
|
243
|
+
collection_name=self.config.storage.table_name,
|
|
244
|
+
ttl=self.config.storage.ttl,
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
elif storage_type == "postgresql":
|
|
248
|
+
if not POSTGRESQL_AVAILABLE:
|
|
249
|
+
raise ImportError(
|
|
250
|
+
"PostgreSQL storage is not available. Install it with 'pip install pywebguard[postgresql]'"
|
|
251
|
+
)
|
|
252
|
+
return PostgreSQLStorage(
|
|
253
|
+
url=self.config.storage.url
|
|
254
|
+
or "postgresql://postgres:postgres@localhost:5432/pywebguard",
|
|
255
|
+
table_name=self.config.storage.table_name,
|
|
256
|
+
ttl=self.config.storage.ttl,
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
else:
|
|
260
|
+
raise ValueError(f"Invalid storage type: {storage_type}")
|
|
261
|
+
|
|
262
|
+
def _initialize_components(self) -> None:
|
|
263
|
+
"""
|
|
264
|
+
Initialize all security components based on configuration.
|
|
265
|
+
|
|
266
|
+
This method sets up all the security components (IP filter, rate limiter,
|
|
267
|
+
etc.) using the configuration provided during initialization.
|
|
268
|
+
"""
|
|
269
|
+
self.ip_filter = IPFilter(self.config.ip_filter, self.storage)
|
|
270
|
+
self.user_agent_filter = UserAgentFilter(self.config.user_agent, self.storage)
|
|
271
|
+
self.rate_limiter = RateLimiter(self.config.rate_limit, self.storage)
|
|
272
|
+
self.penetration_detector = PenetrationDetector(
|
|
273
|
+
self.config.penetration, self.storage
|
|
274
|
+
)
|
|
275
|
+
self.cors_handler = CORSHandler(self.config.cors)
|
|
276
|
+
self.logger = SecurityLogger(self.config.logging)
|
|
277
|
+
|
|
278
|
+
def add_route_rate_limit(
|
|
279
|
+
self, route_pattern: str, config: Union[RateLimitConfig, Dict[str, Any]]
|
|
280
|
+
) -> None:
|
|
281
|
+
"""
|
|
282
|
+
Add a custom rate limit configuration for a specific route pattern.
|
|
283
|
+
|
|
284
|
+
Args:
|
|
285
|
+
route_pattern: The route pattern to match (can include wildcards like * and **)
|
|
286
|
+
config: Custom rate limit configuration for this route
|
|
287
|
+
"""
|
|
288
|
+
self.rate_limiter.add_route_config(route_pattern, config)
|
|
289
|
+
|
|
290
|
+
def add_route_rate_limits(self, route_configs: List[Dict[str, Any]]) -> None:
|
|
291
|
+
"""
|
|
292
|
+
Add multiple custom rate limit configurations for specific route patterns.
|
|
293
|
+
|
|
294
|
+
Args:
|
|
295
|
+
route_configs: List of dictionaries with route-specific rate limits.
|
|
296
|
+
Each dict should have: endpoint, requests_per_minute, burst_size, auto_ban_threshold (optional)
|
|
297
|
+
"""
|
|
298
|
+
for config in route_configs:
|
|
299
|
+
# Make a deep copy of the config to prevent mutation
|
|
300
|
+
config_copy = {k: v for k, v in config.items()}
|
|
301
|
+
if (
|
|
302
|
+
"endpoint" not in config_copy
|
|
303
|
+
or "requests_per_minute" not in config_copy
|
|
304
|
+
):
|
|
305
|
+
self.logger.logger.warning(
|
|
306
|
+
f"Skipping invalid route config: {config_copy}"
|
|
307
|
+
)
|
|
308
|
+
continue
|
|
309
|
+
endpoint = config_copy.pop("endpoint")
|
|
310
|
+
self.add_route_rate_limit(endpoint, config_copy)
|
|
311
|
+
|
|
312
|
+
def check_request(self, request: RequestProtocol) -> Dict[str, Any]:
|
|
313
|
+
"""
|
|
314
|
+
Check if a request should be allowed based on security rules.
|
|
315
|
+
|
|
316
|
+
This method performs all security checks in sequence:
|
|
317
|
+
1. IP filtering
|
|
318
|
+
2. User agent filtering
|
|
319
|
+
3. Rate limiting
|
|
320
|
+
4. Penetration detection
|
|
321
|
+
|
|
322
|
+
Args:
|
|
323
|
+
request: The framework-specific request object
|
|
324
|
+
|
|
325
|
+
Returns:
|
|
326
|
+
Dict with status and details about the request check:
|
|
327
|
+
{
|
|
328
|
+
"allowed": bool,
|
|
329
|
+
"details": {
|
|
330
|
+
"type": str, # Type of check that failed (if any)
|
|
331
|
+
"reason": str # Reason for failure (if any)
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
"""
|
|
335
|
+
# Extract request information
|
|
336
|
+
request_info = self._extract_request_info(request)
|
|
337
|
+
|
|
338
|
+
# Check IP filter
|
|
339
|
+
ip_result = self.ip_filter.is_allowed(request_info["ip"])
|
|
340
|
+
if not ip_result["allowed"]:
|
|
341
|
+
self.logger.log_blocked_request(
|
|
342
|
+
request_info, "IP filter", ip_result["reason"]
|
|
343
|
+
)
|
|
344
|
+
return {
|
|
345
|
+
"allowed": False,
|
|
346
|
+
"details": {"type": "IP filter", "reason": ip_result["reason"]},
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
# Check user agent filter
|
|
350
|
+
ua_result = self.user_agent_filter.is_allowed(request_info["user_agent"])
|
|
351
|
+
if not ua_result["allowed"]:
|
|
352
|
+
self.logger.log_blocked_request(
|
|
353
|
+
request_info, "User agent filter", ua_result["reason"]
|
|
354
|
+
)
|
|
355
|
+
return {
|
|
356
|
+
"allowed": False,
|
|
357
|
+
"details": {"type": "User agent filter", "reason": ua_result["reason"]},
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
# Check rate limit (now with path for route-specific rate limiting)
|
|
361
|
+
rate_result = self.rate_limiter.check_limit(
|
|
362
|
+
request_info["ip"], request_info["path"]
|
|
363
|
+
)
|
|
364
|
+
if not rate_result["allowed"]:
|
|
365
|
+
self.logger.log_blocked_request(
|
|
366
|
+
request_info, "Rate limit", rate_result["reason"]
|
|
367
|
+
)
|
|
368
|
+
return {
|
|
369
|
+
"allowed": False,
|
|
370
|
+
"details": {"type": "Rate limit", "reason": rate_result["reason"]},
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
# Check for penetration attempts
|
|
374
|
+
pen_result = self.penetration_detector.check_request(request_info)
|
|
375
|
+
if not pen_result["allowed"]:
|
|
376
|
+
self.logger.log_blocked_request(
|
|
377
|
+
request_info, "Penetration detection", pen_result["reason"]
|
|
378
|
+
)
|
|
379
|
+
return {
|
|
380
|
+
"allowed": False,
|
|
381
|
+
"details": {
|
|
382
|
+
"type": "Penetration detection",
|
|
383
|
+
"reason": pen_result["reason"],
|
|
384
|
+
},
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
# All checks passed
|
|
388
|
+
return {"allowed": True, "details": {}}
|
|
389
|
+
|
|
390
|
+
def update_metrics(
|
|
391
|
+
self, request: RequestProtocol, response: ResponseProtocol
|
|
392
|
+
) -> None:
|
|
393
|
+
"""
|
|
394
|
+
Update metrics based on request and response.
|
|
395
|
+
|
|
396
|
+
This method is called after a request has been processed to update
|
|
397
|
+
various metrics and logs.
|
|
398
|
+
|
|
399
|
+
Args:
|
|
400
|
+
request: The framework-specific request object
|
|
401
|
+
response: The framework-specific response object
|
|
402
|
+
"""
|
|
403
|
+
# Extract request information
|
|
404
|
+
request_info = self._extract_request_info(request)
|
|
405
|
+
|
|
406
|
+
# Log the request
|
|
407
|
+
self.logger.log_request(request_info, response)
|
|
408
|
+
|
|
409
|
+
def _extract_request_info(
|
|
410
|
+
self, request: Union[RequestProtocol, Dict[str, Any]]
|
|
411
|
+
) -> Dict[str, Any]:
|
|
412
|
+
"""
|
|
413
|
+
Extract information from a request object.
|
|
414
|
+
|
|
415
|
+
This method should be overridden by framework-specific implementations
|
|
416
|
+
to properly extract request information from the framework's request object.
|
|
417
|
+
|
|
418
|
+
Args:
|
|
419
|
+
request: The framework-specific request object or a dictionary with request info
|
|
420
|
+
|
|
421
|
+
Returns:
|
|
422
|
+
Dict with request information:
|
|
423
|
+
{
|
|
424
|
+
"ip": str,
|
|
425
|
+
"user_agent": str,
|
|
426
|
+
"method": str,
|
|
427
|
+
"path": str,
|
|
428
|
+
"query": Dict[str, str],
|
|
429
|
+
"headers": Dict[str, str]
|
|
430
|
+
}
|
|
431
|
+
"""
|
|
432
|
+
if isinstance(request, dict):
|
|
433
|
+
return request
|
|
434
|
+
|
|
435
|
+
# Parse query string into a dictionary
|
|
436
|
+
query_dict = {}
|
|
437
|
+
if hasattr(request, "query_string") and request.query_string:
|
|
438
|
+
query_dict = dict(
|
|
439
|
+
pair.split("=")
|
|
440
|
+
for pair in request.query_string.split("&")
|
|
441
|
+
if "=" in pair
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
return {
|
|
445
|
+
"ip": getattr(request, "remote_addr", ""),
|
|
446
|
+
"user_agent": getattr(request, "user_agent", ""),
|
|
447
|
+
"method": getattr(request, "method", ""),
|
|
448
|
+
"path": getattr(request, "path", ""),
|
|
449
|
+
"query": query_dict,
|
|
450
|
+
"headers": getattr(request, "headers", {}),
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
def is_ip_banned(self, ip: str) -> bool:
|
|
454
|
+
"""Return True if the IP is banned, else False."""
|
|
455
|
+
result = self.ip_filter.is_allowed(ip)
|
|
456
|
+
return not result["allowed"] and result["reason"] == "IP is banned"
|
|
457
|
+
|
|
458
|
+
def check_rate_limit(self, ip: str, path: str = None) -> Dict[str, Any]:
|
|
459
|
+
"""Check if the request is allowed by the rate limiter."""
|
|
460
|
+
return self.rate_limiter.check_limit(ip, path)
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
class AsyncGuard:
|
|
464
|
+
"""
|
|
465
|
+
Base Guard class that handles security features (asynchronous).
|
|
466
|
+
|
|
467
|
+
This is the main class that users will interact with for asynchronous
|
|
468
|
+
web applications. It provides a unified interface for all security
|
|
469
|
+
features including IP filtering, rate limiting, and more.
|
|
470
|
+
|
|
471
|
+
Attributes:
|
|
472
|
+
config: The GuardConfig instance containing all security settings
|
|
473
|
+
storage: The async storage backend for persistent data
|
|
474
|
+
ip_filter: Async IP filtering component
|
|
475
|
+
user_agent_filter: Async user agent filtering component
|
|
476
|
+
rate_limiter: Async rate limiting component
|
|
477
|
+
penetration_detector: Async penetration detection component
|
|
478
|
+
cors_handler: Async CORS handling component
|
|
479
|
+
logger: Async security logging component
|
|
480
|
+
"""
|
|
481
|
+
|
|
482
|
+
def __init__(
|
|
483
|
+
self,
|
|
484
|
+
config: Optional[GuardConfig] = None,
|
|
485
|
+
storage: Optional[AsyncBaseStorage] = None,
|
|
486
|
+
route_rate_limits: Optional[List[Dict[str, Any]]] = None,
|
|
487
|
+
) -> None:
|
|
488
|
+
"""
|
|
489
|
+
Initialize the AsyncGuard with configuration and storage.
|
|
490
|
+
|
|
491
|
+
Args:
|
|
492
|
+
config: GuardConfig object with security settings. If None, default config is used.
|
|
493
|
+
storage: Async storage backend for persistent data. If None, storage is determined from config.
|
|
494
|
+
route_rate_limits: List of dictionaries with route-specific rate limits.
|
|
495
|
+
Each dict should have: endpoint, requests_per_minute, burst_size, auto_ban_threshold (optional)
|
|
496
|
+
|
|
497
|
+
Raises:
|
|
498
|
+
ImportError: If the required storage backend is not installed
|
|
499
|
+
"""
|
|
500
|
+
# Use environment variables if no config is provided
|
|
501
|
+
if config is None:
|
|
502
|
+
config = GuardConfig.from_env()
|
|
503
|
+
self.config = config
|
|
504
|
+
|
|
505
|
+
# Initialize storage
|
|
506
|
+
if storage is not None:
|
|
507
|
+
self.storage = storage
|
|
508
|
+
else:
|
|
509
|
+
self.storage = self._create_storage_from_config()
|
|
510
|
+
|
|
511
|
+
# Initialize components based on config
|
|
512
|
+
self._initialize_components()
|
|
513
|
+
|
|
514
|
+
# Add route-specific rate limits if provided
|
|
515
|
+
if route_rate_limits:
|
|
516
|
+
self.add_route_rate_limits(route_rate_limits)
|
|
517
|
+
|
|
518
|
+
def _create_storage_from_config(self) -> AsyncBaseStorage:
|
|
519
|
+
"""
|
|
520
|
+
Create an async storage backend based on configuration.
|
|
521
|
+
|
|
522
|
+
Returns:
|
|
523
|
+
AsyncBaseStorage: The async storage backend instance
|
|
524
|
+
|
|
525
|
+
Raises:
|
|
526
|
+
ImportError: If the required storage backend is not installed
|
|
527
|
+
ValueError: If the storage type is invalid
|
|
528
|
+
"""
|
|
529
|
+
storage_type = self.config.storage.type.lower()
|
|
530
|
+
|
|
531
|
+
if storage_type == "memory":
|
|
532
|
+
return AsyncMemoryStorage()
|
|
533
|
+
|
|
534
|
+
elif storage_type == "redis":
|
|
535
|
+
if not REDIS_AVAILABLE:
|
|
536
|
+
raise ImportError(
|
|
537
|
+
"Redis storage is not available. Install it with 'pip install pywebguard[redis]'"
|
|
538
|
+
)
|
|
539
|
+
return AsyncRedisStorage(
|
|
540
|
+
url=self.config.storage.url or "redis://localhost:6379/0",
|
|
541
|
+
prefix=self.config.storage.prefix,
|
|
542
|
+
ttl=self.config.storage.ttl,
|
|
543
|
+
)
|
|
544
|
+
|
|
545
|
+
elif storage_type == "sqlite":
|
|
546
|
+
if not SQLITE_AVAILABLE:
|
|
547
|
+
raise ImportError(
|
|
548
|
+
"SQLite storage is not available. Install it with 'pip install pywebguard[sqlite]'"
|
|
549
|
+
)
|
|
550
|
+
return AsyncSQLiteStorage(
|
|
551
|
+
db_path=self.config.storage.url or "pywebguard.db",
|
|
552
|
+
table_prefix=self.config.storage.prefix,
|
|
553
|
+
ttl=self.config.storage.ttl,
|
|
554
|
+
)
|
|
555
|
+
|
|
556
|
+
elif storage_type == "tinydb":
|
|
557
|
+
if not TINYDB_AVAILABLE:
|
|
558
|
+
raise ImportError(
|
|
559
|
+
"TinyDB storage is not available. Install it with 'pip install pywebguard[tinydb]'"
|
|
560
|
+
)
|
|
561
|
+
return AsyncTinyDBStorage(
|
|
562
|
+
db_path=self.config.storage.url or "pywebguard.json",
|
|
563
|
+
ttl=self.config.storage.ttl,
|
|
564
|
+
)
|
|
565
|
+
|
|
566
|
+
elif storage_type == "mongodb":
|
|
567
|
+
if not MONGODB_AVAILABLE:
|
|
568
|
+
raise ImportError(
|
|
569
|
+
"MongoDB storage is not available. Install it with 'pip install pywebguard[mongodb]'"
|
|
570
|
+
)
|
|
571
|
+
return AsyncMongoDBStorage(
|
|
572
|
+
url=self.config.storage.url or "mongodb://localhost:27017/pywebguard",
|
|
573
|
+
collection_name=self.config.storage.table_name,
|
|
574
|
+
ttl=self.config.storage.ttl,
|
|
575
|
+
)
|
|
576
|
+
|
|
577
|
+
elif storage_type == "postgresql":
|
|
578
|
+
if not POSTGRESQL_AVAILABLE:
|
|
579
|
+
raise ImportError(
|
|
580
|
+
"PostgreSQL storage is not available. Install it with 'pip install pywebguard[postgresql]'"
|
|
581
|
+
)
|
|
582
|
+
return AsyncPostgreSQLStorage(
|
|
583
|
+
url=self.config.storage.url
|
|
584
|
+
or "postgresql://postgres:postgres@localhost:5432/pywebguard",
|
|
585
|
+
table_name=self.config.storage.table_name,
|
|
586
|
+
ttl=self.config.storage.ttl,
|
|
587
|
+
)
|
|
588
|
+
|
|
589
|
+
else:
|
|
590
|
+
raise ValueError(f"Invalid storage type: {storage_type}")
|
|
591
|
+
|
|
592
|
+
def _initialize_components(self) -> None:
|
|
593
|
+
"""
|
|
594
|
+
Initialize all security components based on configuration.
|
|
595
|
+
|
|
596
|
+
This method sets up all the security components (IP filter, rate limiter,
|
|
597
|
+
etc.) using the configuration provided during initialization.
|
|
598
|
+
"""
|
|
599
|
+
self.ip_filter = AsyncIPFilter(self.config.ip_filter, self.storage)
|
|
600
|
+
self.user_agent_filter = AsyncUserAgentFilter(
|
|
601
|
+
self.config.user_agent, self.storage
|
|
602
|
+
)
|
|
603
|
+
self.rate_limiter = AsyncRateLimiter(self.config.rate_limit, self.storage)
|
|
604
|
+
self.penetration_detector = AsyncPenetrationDetector(
|
|
605
|
+
self.config.penetration, self.storage
|
|
606
|
+
)
|
|
607
|
+
self.cors_handler = AsyncCORSHandler(self.config.cors)
|
|
608
|
+
self.logger = AsyncSecurityLogger(self.config.logging)
|
|
609
|
+
|
|
610
|
+
def add_route_rate_limit(
|
|
611
|
+
self, route_pattern: str, config: Union[RateLimitConfig, Dict[str, Any]]
|
|
612
|
+
) -> None:
|
|
613
|
+
"""
|
|
614
|
+
Add a custom rate limit configuration for a specific route pattern.
|
|
615
|
+
|
|
616
|
+
Args:
|
|
617
|
+
route_pattern: The route pattern to match (can include wildcards like * and **)
|
|
618
|
+
config: Custom rate limit configuration for this route
|
|
619
|
+
"""
|
|
620
|
+
self.rate_limiter.add_route_config(route_pattern, config)
|
|
621
|
+
|
|
622
|
+
def add_route_rate_limits(self, route_configs: List[Dict[str, Any]]) -> None:
|
|
623
|
+
"""
|
|
624
|
+
Add multiple custom rate limit configurations for specific route patterns.
|
|
625
|
+
|
|
626
|
+
Args:
|
|
627
|
+
route_configs: List of dictionaries with route-specific rate limits.
|
|
628
|
+
Each dict should have: endpoint, requests_per_minute, burst_size, auto_ban_threshold (optional)
|
|
629
|
+
"""
|
|
630
|
+
for config in route_configs:
|
|
631
|
+
# Make a deep copy of the config to prevent mutation
|
|
632
|
+
config_copy = {k: v for k, v in config.items()}
|
|
633
|
+
if (
|
|
634
|
+
"endpoint" not in config_copy
|
|
635
|
+
or "requests_per_minute" not in config_copy
|
|
636
|
+
):
|
|
637
|
+
self.logger.logger.warning(
|
|
638
|
+
f"Skipping invalid route config: {config_copy}"
|
|
639
|
+
)
|
|
640
|
+
continue
|
|
641
|
+
endpoint = config_copy.pop("endpoint")
|
|
642
|
+
self.add_route_rate_limit(endpoint, config_copy)
|
|
643
|
+
|
|
644
|
+
async def check_request(self, request: RequestProtocol) -> Dict[str, Any]:
|
|
645
|
+
"""
|
|
646
|
+
Check if a request should be allowed based on security rules.
|
|
647
|
+
|
|
648
|
+
This method performs all security checks in sequence:
|
|
649
|
+
1. IP filtering
|
|
650
|
+
2. User agent filtering
|
|
651
|
+
3. Rate limiting
|
|
652
|
+
4. Penetration detection
|
|
653
|
+
|
|
654
|
+
Args:
|
|
655
|
+
request: The framework-specific request object
|
|
656
|
+
|
|
657
|
+
Returns:
|
|
658
|
+
Dict with status and details about the request check:
|
|
659
|
+
{
|
|
660
|
+
"allowed": bool,
|
|
661
|
+
"details": {
|
|
662
|
+
"type": str, # Type of check that failed (if any)
|
|
663
|
+
"reason": str # Reason for failure (if any)
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
"""
|
|
667
|
+
# Extract request information
|
|
668
|
+
request_info = self._extract_request_info(request)
|
|
669
|
+
|
|
670
|
+
# Check IP filter
|
|
671
|
+
ip_result = await self.ip_filter.is_allowed(request_info["ip"])
|
|
672
|
+
if not ip_result["allowed"]:
|
|
673
|
+
await self.logger.log_blocked_request(
|
|
674
|
+
request_info, "IP filter", ip_result["reason"]
|
|
675
|
+
)
|
|
676
|
+
return {
|
|
677
|
+
"allowed": False,
|
|
678
|
+
"details": {"type": "IP filter", "reason": ip_result["reason"]},
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
# Check user agent filter
|
|
682
|
+
ua_result = await self.user_agent_filter.is_allowed(request_info["user_agent"])
|
|
683
|
+
if not ua_result["allowed"]:
|
|
684
|
+
await self.logger.log_blocked_request(
|
|
685
|
+
request_info, "User agent filter", ua_result["reason"]
|
|
686
|
+
)
|
|
687
|
+
return {
|
|
688
|
+
"allowed": False,
|
|
689
|
+
"details": {"type": "User agent filter", "reason": ua_result["reason"]},
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
# Check rate limit (now with path for route-specific rate limiting)
|
|
693
|
+
rate_result = await self.rate_limiter.check_limit(
|
|
694
|
+
request_info["ip"], request_info["path"]
|
|
695
|
+
)
|
|
696
|
+
if not rate_result["allowed"]:
|
|
697
|
+
await self.logger.log_blocked_request(
|
|
698
|
+
request_info, "Rate limit", rate_result["reason"]
|
|
699
|
+
)
|
|
700
|
+
return {
|
|
701
|
+
"allowed": False,
|
|
702
|
+
"details": {"type": "Rate limit", "reason": rate_result["reason"]},
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
# Check for penetration attempts
|
|
706
|
+
pen_result = await self.penetration_detector.check_request(request_info)
|
|
707
|
+
if not pen_result["allowed"]:
|
|
708
|
+
await self.logger.log_blocked_request(
|
|
709
|
+
request_info, "Penetration detection", pen_result["reason"]
|
|
710
|
+
)
|
|
711
|
+
return {
|
|
712
|
+
"allowed": False,
|
|
713
|
+
"details": {
|
|
714
|
+
"type": "Penetration detection",
|
|
715
|
+
"reason": pen_result["reason"],
|
|
716
|
+
},
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
# All checks passed
|
|
720
|
+
return {"allowed": True, "details": {}}
|
|
721
|
+
|
|
722
|
+
async def update_metrics(
|
|
723
|
+
self, request: RequestProtocol, response: ResponseProtocol
|
|
724
|
+
) -> None:
|
|
725
|
+
"""
|
|
726
|
+
Update metrics based on request and response.
|
|
727
|
+
|
|
728
|
+
This method is called after a request has been processed to update
|
|
729
|
+
various metrics and logs.
|
|
730
|
+
|
|
731
|
+
Args:
|
|
732
|
+
request: The framework-specific request object
|
|
733
|
+
response: The framework-specific response object
|
|
734
|
+
"""
|
|
735
|
+
# Extract request information
|
|
736
|
+
request_info = self._extract_request_info(request)
|
|
737
|
+
|
|
738
|
+
# Log the request
|
|
739
|
+
await self.logger.log_request(request_info, response)
|
|
740
|
+
|
|
741
|
+
def _extract_request_info(
|
|
742
|
+
self, request: Union[RequestProtocol, Dict[str, Any]]
|
|
743
|
+
) -> Dict[str, Any]:
|
|
744
|
+
"""
|
|
745
|
+
Extract information from a request object.
|
|
746
|
+
|
|
747
|
+
This method should be overridden by framework-specific implementations
|
|
748
|
+
to properly extract request information from the framework's request object.
|
|
749
|
+
|
|
750
|
+
Args:
|
|
751
|
+
request: The framework-specific request object or a dictionary with request info
|
|
752
|
+
|
|
753
|
+
Returns:
|
|
754
|
+
Dict with request information:
|
|
755
|
+
{
|
|
756
|
+
"ip": str,
|
|
757
|
+
"user_agent": str,
|
|
758
|
+
"method": str,
|
|
759
|
+
"path": str,
|
|
760
|
+
"query": Dict[str, str],
|
|
761
|
+
"headers": Dict[str, str]
|
|
762
|
+
}
|
|
763
|
+
"""
|
|
764
|
+
if isinstance(request, dict):
|
|
765
|
+
return request
|
|
766
|
+
|
|
767
|
+
# Parse query string into a dictionary
|
|
768
|
+
query_dict = {}
|
|
769
|
+
if hasattr(request, "query_string") and request.query_string:
|
|
770
|
+
query_dict = dict(
|
|
771
|
+
pair.split("=")
|
|
772
|
+
for pair in request.query_string.split("&")
|
|
773
|
+
if "=" in pair
|
|
774
|
+
)
|
|
775
|
+
|
|
776
|
+
return {
|
|
777
|
+
"ip": getattr(request, "remote_addr", ""),
|
|
778
|
+
"user_agent": getattr(request, "user_agent", ""),
|
|
779
|
+
"method": getattr(request, "method", ""),
|
|
780
|
+
"path": getattr(request, "path", ""),
|
|
781
|
+
"query": query_dict,
|
|
782
|
+
"headers": getattr(request, "headers", {}),
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
async def is_ip_banned(self, ip: str) -> bool:
|
|
786
|
+
"""Return True if the IP is banned, else False (async)."""
|
|
787
|
+
result = await self.ip_filter.is_allowed(ip)
|
|
788
|
+
return not result["allowed"] and result["reason"] == "IP is banned"
|
|
789
|
+
|
|
790
|
+
async def check_rate_limit(self, ip: str, path: str = None) -> Dict[str, Any]:
|
|
791
|
+
"""Check if the request is allowed by the rate limiter (async)."""
|
|
792
|
+
return await self.rate_limiter.check_limit(ip, path)
|