|
14 | 14 | from wtforms import StringField, PasswordField, SubmitField, IntegerField |
15 | 15 | from wtforms.validators import DataRequired, Length, Regexp |
16 | 16 | from cachelib import FileSystemCache |
| 17 | +from flask_talisman import Talisman |
17 | 18 | from pyrad.client import Client |
18 | 19 | from pyrad.dictionary import Dictionary |
19 | 20 | import pyrad.packet |
|
39 | 40 | # Input validation constants |
40 | 41 | MAX_USERNAME_LENGTH = 63 # RADIUS standard |
41 | 42 | MAX_PASSWORD_LENGTH = 128 # RADIUS standard |
42 | | -# RADIUS-compatible character pattern (alphanumeric + common symbols) |
43 | | -RADIUS_CHAR_PATTERN = re.compile(r'^[a-zA-Z0-9@._-]+$') |
| 43 | +# RADIUS-compatible character pattern (configurable via config.py) |
| 44 | +RADIUS_CHAR_PATTERN = re.compile(app.config.get('RADIUS_CHAR_PATTERN', r'^[a-zA-Z0-9@._-]+$')) |
44 | 45 |
|
45 | 46 | # Configure CacheLib session backend (replaces deprecated filesystem backend) |
46 | 47 | app.config['SESSION_CACHELIB'] = FileSystemCache(cache_dir='flask_session', threshold=500) |
|
51 | 52 | csrf = CSRFProtect(app) |
52 | 53 | # Initialize the Flask Session |
53 | 54 | Session(app) |
| 55 | +# Initialize Flask-Talisman for security headers including CSP |
| 56 | +csp = { |
| 57 | + 'default-src': "'self'", |
| 58 | + 'script-src': "'self'", |
| 59 | + 'style-src': "'self' 'unsafe-inline'", # Bootstrap requires inline styles |
| 60 | + 'img-src': "'self' data:", |
| 61 | + 'font-src': "'self'", |
| 62 | + 'connect-src': "'self'", |
| 63 | + 'frame-ancestors': "'none'" |
| 64 | +} |
| 65 | +talisman = Talisman( |
| 66 | + app, |
| 67 | + content_security_policy=csp, |
| 68 | + content_security_policy_nonce_in=['script-src'], |
| 69 | + strict_transport_security=True, |
| 70 | + strict_transport_security_max_age=31536000, |
| 71 | + strict_transport_security_include_subdomains=True, |
| 72 | + force_https=False # Allow HTTP for development |
| 73 | +) |
54 | 74 | # Initialize the Background Session Scheduler |
55 | 75 | background_scheduler = BackgroundScheduler() |
56 | 76 | background_scheduler.start() |
|
77 | 97 | srv = srv_primary |
78 | 98 |
|
79 | 99 |
|
80 | | -@app.after_request |
81 | | -def add_security_headers(response): |
82 | | - """Add security headers to all responses.""" |
83 | | - # Content Security Policy |
84 | | - response.headers['Content-Security-Policy'] = ( |
85 | | - "default-src 'self'; " |
86 | | - "script-src 'self' 'unsafe-inline'; " |
87 | | - "style-src 'self' 'unsafe-inline'; " |
88 | | - "img-src 'self' data:; " |
89 | | - "font-src 'self'; " |
90 | | - "connect-src 'self'; " |
91 | | - "frame-ancestors 'none'" |
92 | | - ) |
93 | | - |
94 | | - # Security headers |
95 | | - response.headers['X-Content-Type-Options'] = 'nosniff' |
96 | | - response.headers['X-Frame-Options'] = 'DENY' |
97 | | - response.headers['X-XSS-Protection'] = '1; mode=block' |
98 | | - response.headers['Referrer-Policy'] = 'strict-origin-when-cross-origin' |
99 | | - response.headers['Permissions-Policy'] = 'geolocation=(), microphone=(), camera=()' |
100 | | - |
101 | | - # HSTS header (only if serving HTTPS) |
102 | | - if request.is_secure: |
103 | | - response.headers['Strict-Transport-Security'] = 'max-age=31536000; includeSubDomains' |
104 | | - |
105 | | - return response |
| 100 | +# Security headers are now handled by Flask-Talisman |
106 | 101 |
|
107 | 102 |
|
108 | 103 | class LoginForm(FlaskForm): |
109 | 104 | """CSRF-protected login form with validation.""" |
110 | 105 | username = StringField('Username', validators=[ |
111 | 106 | DataRequired(), |
112 | 107 | Length(max=MAX_USERNAME_LENGTH, message=f'Username too long (max {MAX_USERNAME_LENGTH} characters)'), |
113 | | - Regexp(RADIUS_CHAR_PATTERN, message='Username contains invalid characters (only alphanumeric, @, ., _, - allowed)') |
| 108 | + Regexp(RADIUS_CHAR_PATTERN, message='Username contains invalid characters') |
114 | 109 | ]) |
115 | 110 | password = PasswordField('Password', validators=[ |
116 | 111 | DataRequired(), |
@@ -138,7 +133,7 @@ def validate_input(username, password): |
138 | 133 | if len(username) > MAX_USERNAME_LENGTH: |
139 | 134 | errors.append(f"Username too long (max {MAX_USERNAME_LENGTH} characters)") |
140 | 135 | if not RADIUS_CHAR_PATTERN.match(username): |
141 | | - errors.append("Username contains invalid characters (only alphanumeric, @, ., _, - allowed)") |
| 136 | + errors.append("Username contains invalid characters") |
142 | 137 |
|
143 | 138 | # Check password length |
144 | 139 | if len(password) > MAX_PASSWORD_LENGTH: |
|
0 commit comments