Repository navigation
Expand file tree
/
Copy pathmiddleware.py
More file actions
311 lines (260 loc) · 11.9 KB
/
Copy pathmiddleware.py
File metadata and controls
311 lines (260 loc) · 11.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
"""
Flask middleware for security validations.
Includes Content-Type validation, request size limits, and header validation.
"""
import logging
from functools import wraps
from flask import request, jsonify
try:
from .error_responses import invalid_json_error
from .security_parsers import (
DEFAULT_ALLOWED_CONTENT_TYPES,
validate_content_type as _validate_content_type_header,
)
from .validators import validate_password_strength
except ImportError:
from error_responses import invalid_json_error
from security_parsers import DEFAULT_ALLOWED_CONTENT_TYPES, validate_content_type as _validate_content_type_header
from validators import validate_password_strength
logger = logging.getLogger(__name__)
def validate_content_type_middleware(f=None, *, allowed_types=None):
"""
Decorator to validate Content-Type header for POST/PATCH/PUT requests.
Skips validation for GET requests and requests without body.
Usage:
@app.route('/api/v1/endpoint', methods=['POST'])
@validate_content_type_middleware
def my_endpoint():
...
"""
if f is None:
return lambda wrapped_function: validate_content_type_middleware(
wrapped_function,
allowed_types=allowed_types,
)
@wraps(f)
def decorated_function(*args, **kwargs):
# Skip for GET and HEAD requests (no body)
if request.method in ['GET', 'HEAD', 'DELETE']:
return f(*args, **kwargs)
# Skip for requests without body
if request.content_length is None or request.content_length == 0:
return f(*args, **kwargs)
effective_allowed_types = allowed_types or list(DEFAULT_ALLOWED_CONTENT_TYPES)
is_valid, error_message = _validate_content_type_header(effective_allowed_types)
if not is_valid:
logger.warning(
f"Invalid Content-Type '{request.content_type}' for {request.method} "
f"{request.path} from {request.remote_addr}"
)
return invalid_json_error(error_message)
return f(*args, **kwargs)
return decorated_function
def validate_request_size(max_size_bytes: int = 1_000_000):
"""
Decorator to validate request body size.
Args:
max_size_bytes: Maximum allowed request body size (default 1MB)
Usage:
@app.route('/api/v1/upload', methods=['POST'])
@validate_request_size(max_size_bytes=5_000_000) # 5MB
def upload_endpoint():
...
"""
def decorator(f):
@wraps(f)
def decorated_function(*args, **kwargs):
content_length = request.content_length
if content_length is not None and content_length > max_size_bytes:
size_mb = content_length / (1024 * 1024)
max_mb = max_size_bytes / (1024 * 1024)
logger.warning(
f"Request to {request.path} exceeds size limit: "
f"{size_mb:.2f}MB > {max_mb:.2f}MB"
)
return jsonify({
'success': False,
'error': f'Request body too large. Maximum: {max_mb:.0f}MB'
}), 413 # 413 Payload Too Large
return f(*args, **kwargs)
return decorated_function
return decorator
def require_json_content_type(f):
"""
Decorator for endpoints that MUST receive JSON.
More strict than validate_content_type_middleware.
"""
@wraps(f)
def decorated_function(*args, **kwargs):
if not request.is_json:
logger.warning(
f"Non-JSON request to {request.method} {request.path} "
f"(Content-Type: {request.content_type}) from {request.remote_addr}"
)
return invalid_json_error("Request body must be valid JSON")
if request.content_length is None or request.content_length == 0:
logger.warning(
f"Empty request to {request.method} {request.path} "
f"from {request.remote_addr}"
)
return invalid_json_error("Request body cannot be empty")
return f(*args, **kwargs)
return decorated_function
def safe_request_handler(
validate_content_type: bool = True,
max_size_bytes: int = 1_000_000,
require_json: bool = True,
allowed_types=None
):
"""
Composite decorator for comprehensive request validation.
Combines Content-Type validation, size limits, and JSON requirements.
Usage:
@app.route('/api/v1/endpoint', methods=['POST'])
@safe_request_handler(
require_json=True,
max_size_bytes=2_000_000 # 2MB
)
def my_endpoint():
# Request is guaranteed to be safe and properly formatted
success, data, error = safe_get_json()
...
"""
def decorator(f):
@wraps(f)
def decorated_function(*args, **kwargs):
# Content-Type validation
if validate_content_type and request.method not in ['GET', 'HEAD']:
effective_allowed_types = allowed_types or (
['application/json'] if require_json else list(DEFAULT_ALLOWED_CONTENT_TYPES)
)
is_valid, error_message = _validate_content_type_header(effective_allowed_types)
if not is_valid:
logger.warning(f"Content-Type validation failed for {request.path}")
return invalid_json_error(error_message)
# Size validation
if max_size_bytes and request.content_length is not None:
if request.content_length > max_size_bytes:
logger.warning(f"Request size exceeded for {request.path}")
return jsonify({
'success': False,
'error': 'Request body too large'
}), 413
# JSON requirement
if require_json and request.method in ['POST', 'PATCH', 'PUT']:
if not request.is_json:
logger.warning(f"Non-JSON request to {request.path}")
return invalid_json_error("Request must be JSON")
return f(*args, **kwargs)
return decorated_function
return decorator
# ─────────────────────────────────────────────────────────────────────────────
# PASSWORD STRENGTH ENFORCEMENT (Issue #790)
# ─────────────────────────────────────────────────────────────────────────────
def require_strong_password(f):
"""
Decorator that enforces strong password policy on any endpoint that
receives a 'password' field in its JSON body (e.g. /api/v1/register).
This acts as a second layer of defence — RegisterRequest in validators.py
already runs the same check via Pydantic. Having it here means the rule
is enforced even if the route is called without the Pydantic schema, and
it returns a consistent, descriptive 400 response before any business
logic runs.
Usage:
@app.route('/api/v1/register', methods=['POST'])
@require_strong_password
def register():
...
Response on failure (HTTP 400):
{
"success": false,
"error": "Password does not meet security requirements",
"requirements": [
"Minimum 8 characters required",
"At least one uppercase letter (A–Z) required"
]
}
"""
@wraps(f)
def decorated_function(*args, **kwargs):
# Only check POST / PUT / PATCH — skip GET, HEAD, DELETE
if request.method not in ('POST', 'PUT', 'PATCH'):
return f(*args, **kwargs)
data = request.get_json(silent=True) or {}
password = data.get('password', '')
if not password:
logger.warning(
f"[require_strong_password] Missing password field "
f"on {request.method} {request.path} from {request.remote_addr}"
)
return jsonify({
'success': False,
'error': 'Password is required',
'requirements': []
}), 400
is_valid, errors = validate_password_strength(password)
if not is_valid:
logger.warning(
f"[require_strong_password] Weak password rejected "
f"on {request.path} from {request.remote_addr} — "
f"failed rules: {errors}"
)
return jsonify({
'success': False,
'error': 'Password does not meet security requirements',
'requirements': errors
}), 400
return f(*args, **kwargs)
return decorated_function
# ─────────────────────────────────────────────────────────────────────────────
# END PASSWORD STRENGTH ENFORCEMENT
# ─────────────────────────────────────────────────────────────────────────────
def csrf_token_required(f):
"""
=============================================================================
EXPLICIT CSRF TOKEN VALIDATION DECORATOR
=============================================================================
While CSRFProtect (Flask-WTF) handles validation globally for all
state-mutating requests, this decorator can be used for explicit validation
or when fine-grained control is required over CSRF error handling.
It manually triggers the CSRF validation logic and returns a standardized
BiblioDrift error response upon failure.
Why this matters?:
By providing an explicit decorator, we empower developers to selectively
enforce or bypass CSRF on specific high-risk endpoints, and provide
clearer error context than the generic global handler.
=============================================================================
"""
@wraps(f)
def decorated_function(*args, **kwargs):
from flask_wtf.csrf import validate_csrf, CSRFError
try:
# We look for the token in the 'X-CSRF-Token' header by default
token = request.headers.get('X-CSRF-Token')
if not token:
# Fallback to form data if header is missing
token = request.form.get('csrf_token')
if not token:
logger.warning(f"CSRF Token missing in request from {request.remote_addr}")
return jsonify({
"success": False,
"error": "CSRF_TOKEN_MISSING",
"message": "Security token is required for this action."
}), 400
validate_csrf(token)
return f(*args, **kwargs)
except CSRFError as e:
logger.error(f"CSRF Validation failure in middleware: {e.description}")
return jsonify({
"success": False,
"error": "CSRF_TOKEN_INVALID",
"message": f"Security validation failed: {e.description}"
}), 400
except Exception as e:
logger.error(f"Unexpected error during CSRF validation: {str(e)}")
return jsonify({
"success": False,
"error": "SECURITY_ERROR",
"message": "An internal security error occurred."
}), 500
return decorated_function