150 lines
5.3 KiB
Python
150 lines
5.3 KiB
Python
|
|
"""
|
||
|
|
Input validation schemas using basic validation
|
||
|
|
For production, consider migrating to Pydantic for comprehensive validation
|
||
|
|
"""
|
||
|
|
|
||
|
|
import re
|
||
|
|
import bleach
|
||
|
|
from functools import wraps
|
||
|
|
from flask import request, jsonify
|
||
|
|
|
||
|
|
class ValidationError(Exception):
|
||
|
|
"""Custom validation error"""
|
||
|
|
pass
|
||
|
|
|
||
|
|
def sanitize_html(text):
|
||
|
|
"""
|
||
|
|
Sanitize HTML to prevent XSS attacks
|
||
|
|
Allows only safe tags and attributes
|
||
|
|
"""
|
||
|
|
if not text:
|
||
|
|
return text
|
||
|
|
|
||
|
|
# Allow minimal formatting tags
|
||
|
|
allowed_tags = ['p', 'br', 'strong', 'em', 'u', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'li', 'ul', 'ol', 'pre', 'code']
|
||
|
|
allowed_attributes = {}
|
||
|
|
|
||
|
|
return bleach.clean(
|
||
|
|
text,
|
||
|
|
tags=allowed_tags,
|
||
|
|
attributes=allowed_attributes,
|
||
|
|
strip=True
|
||
|
|
)
|
||
|
|
|
||
|
|
def validate_string(value, field_name, min_length=None, max_length=None, pattern=None, required=True):
|
||
|
|
"""Validate string field"""
|
||
|
|
if value is None or value == '':
|
||
|
|
if required:
|
||
|
|
raise ValidationError(f"{field_name} is required")
|
||
|
|
return True
|
||
|
|
|
||
|
|
if not isinstance(value, str):
|
||
|
|
raise ValidationError(f"{field_name} must be a string")
|
||
|
|
|
||
|
|
if min_length and len(value) < min_length:
|
||
|
|
raise ValidationError(f"{field_name} must be at least {min_length} characters")
|
||
|
|
|
||
|
|
if max_length and len(value) > max_length:
|
||
|
|
raise ValidationError(f"{field_name} must not exceed {max_length} characters")
|
||
|
|
|
||
|
|
if pattern and not re.match(pattern, value):
|
||
|
|
raise ValidationError(f"{field_name} has invalid format")
|
||
|
|
|
||
|
|
return True
|
||
|
|
|
||
|
|
def validate_email(email, required=False):
|
||
|
|
"""Validate email format"""
|
||
|
|
if not email and not required:
|
||
|
|
return True
|
||
|
|
|
||
|
|
pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
|
||
|
|
if not re.match(pattern, email):
|
||
|
|
raise ValidationError("Invalid email format")
|
||
|
|
return True
|
||
|
|
|
||
|
|
def sanitize_filename(filename):
|
||
|
|
"""Sanitize filename to prevent path traversal"""
|
||
|
|
# Remove any path separators
|
||
|
|
filename = filename.replace('..', '').replace('/', '').replace('\\', '')
|
||
|
|
# Allow only alphanumeric, dash, underscore, and dot
|
||
|
|
filename = re.sub(r'[^a-zA-Z0-9._-]', '_', filename)
|
||
|
|
return filename[:255] # Limit length
|
||
|
|
|
||
|
|
def validate_uuid(value):
|
||
|
|
"""Validate UUID format"""
|
||
|
|
uuid_pattern = r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'
|
||
|
|
if not re.match(uuid_pattern, str(value).lower()):
|
||
|
|
raise ValidationError("Invalid UUID format")
|
||
|
|
return True
|
||
|
|
|
||
|
|
# Request validation schemas
|
||
|
|
PROFILE_SCHEMA = {
|
||
|
|
'name': {'type': 'string', 'required': True, 'min_length': 1, 'max_length': 255},
|
||
|
|
'email': {'type': 'email', 'required': False},
|
||
|
|
'contact_number': {'type': 'string', 'required': False, 'max_length': 50},
|
||
|
|
'default_key': {'type': 'string', 'required': False, 'max_length': 10},
|
||
|
|
'notes': {'type': 'string', 'required': False, 'max_length': 5000}
|
||
|
|
}
|
||
|
|
|
||
|
|
SONG_SCHEMA = {
|
||
|
|
'title': {'type': 'string', 'required': True, 'min_length': 1, 'max_length': 500},
|
||
|
|
'artist': {'type': 'string', 'required': False, 'max_length': 500},
|
||
|
|
'band': {'type': 'string', 'required': False, 'max_length': 500},
|
||
|
|
'singer': {'type': 'string', 'required': False, 'max_length': 500},
|
||
|
|
'lyrics': {'type': 'string', 'required': False, 'max_length': 50000},
|
||
|
|
'chords': {'type': 'string', 'required': False, 'max_length': 50000}
|
||
|
|
}
|
||
|
|
|
||
|
|
PLAN_SCHEMA = {
|
||
|
|
'name': {'type': 'string', 'required': True, 'min_length': 1, 'max_length': 500},
|
||
|
|
'date': {'type': 'string', 'required': False}, # Will validate format separately
|
||
|
|
'notes': {'type': 'string', 'required': False, 'max_length': 5000}
|
||
|
|
}
|
||
|
|
|
||
|
|
def validate_request_data(schema):
|
||
|
|
"""
|
||
|
|
Decorator to validate request JSON data against schema
|
||
|
|
|
||
|
|
Usage:
|
||
|
|
@app.route('/api/profiles', methods=['POST'])
|
||
|
|
@validate_request_data(PROFILE_SCHEMA)
|
||
|
|
def create_profile():
|
||
|
|
data = request.get_json()
|
||
|
|
...
|
||
|
|
"""
|
||
|
|
def decorator(f):
|
||
|
|
@wraps(f)
|
||
|
|
def wrapped(*args, **kwargs):
|
||
|
|
if not request.is_json:
|
||
|
|
return jsonify({'error': 'Content-Type must be application/json'}), 400
|
||
|
|
|
||
|
|
data = request.get_json()
|
||
|
|
if not data:
|
||
|
|
return jsonify({'error': 'Request body is required'}), 400
|
||
|
|
|
||
|
|
try:
|
||
|
|
# Validate each field in schema
|
||
|
|
for field, rules in schema.items():
|
||
|
|
value = data.get(field)
|
||
|
|
|
||
|
|
if rules['type'] == 'string':
|
||
|
|
validate_string(
|
||
|
|
value,
|
||
|
|
field,
|
||
|
|
min_length=rules.get('min_length'),
|
||
|
|
max_length=rules.get('max_length'),
|
||
|
|
pattern=rules.get('pattern'),
|
||
|
|
required=rules.get('required', False)
|
||
|
|
)
|
||
|
|
elif rules['type'] == 'email':
|
||
|
|
if value:
|
||
|
|
validate_email(value, required=rules.get('required', False))
|
||
|
|
|
||
|
|
return f(*args, **kwargs)
|
||
|
|
|
||
|
|
except ValidationError as e:
|
||
|
|
return jsonify({'error': 'validation_error', 'message': str(e)}), 400
|
||
|
|
|
||
|
|
return wrapped
|
||
|
|
return decorator
|