@@ -130,11 +130,41 @@ def get_privatemode_key_status() -> tuple[str, str]:
130130CSRF_TTL = 3600 # 1 hour
131131
132132
133+ PBKDF2_SALT = os .environ .get ('PBKDF2_SALT' , '' ).encode ()
134+ if len (PBKDF2_SALT ) < 16 :
135+ raise ValueError ("PBKDF2_SALT environment variable must be at least 16 bytes" )
136+
137+ # Cache the derived Fernet key at module level so the expensive PBKDF2
138+ # computation only runs once (avoids blocking the async event loop on
139+ # every encrypt/decrypt call).
140+ _FERNET_KEY : bytes | None = None
141+
142+
133143def _get_fernet_key () -> bytes :
134- """Derive a Fernet key from admin password."""
135- # Use SHA256 to get 32 bytes, then base64 encode for Fernet
136- key_material = hashlib .sha256 (ADMIN_PASSWORD .encode ()).digest ()
137- return base64 .urlsafe_b64encode (key_material )
144+ """Derive a Fernet key from admin password using PBKDF2.
145+
146+ Uses PBKDF2-HMAC-SHA256 with a configurable salt for key derivation.
147+ The key is computed once and cached to avoid blocking the async event loop.
148+ """
149+ global _FERNET_KEY
150+ if _FERNET_KEY is not None :
151+ return _FERNET_KEY
152+ # Use PBKDF2 with 600,000 iterations (OWASP recommended for HMAC-SHA256)
153+ key_material = hashlib .pbkdf2_hmac (
154+ 'sha256' ,
155+ ADMIN_PASSWORD .encode (),
156+ PBKDF2_SALT ,
157+ iterations = 600_000 ,
158+ dklen = 32 # Fernet requires 32 bytes
159+ )
160+ _FERNET_KEY = base64 .urlsafe_b64encode (key_material )
161+ return _FERNET_KEY
162+
163+
164+ # Derive the key once at import time so the expensive PBKDF2 computation
165+ # happens during startup, not on the first request (which would block the
166+ # async event loop).
167+ _get_fernet_key ()
138168
139169
140170def _encrypt_key_for_display (key : str ) -> str :
@@ -613,7 +643,8 @@ async def admin_login_page(request: web.Request) -> web.Response:
613643 raise web .HTTPFound ('/admin' )
614644
615645 error = request .query .get ('error' , '' )
616- error_html = f'<p class="error">{ error } </p>' if error else ''
646+ # Escape error message to prevent XSS attacks
647+ error_html = f'<p class="error">{ escape (error )} </p>' if error else ''
617648
618649 csrf_token = generate_csrf_token ()
619650 html = HTML_TEMPLATE .format (
@@ -771,7 +802,7 @@ async def admin_dashboard(request: web.Request) -> web.Response:
771802 # Determine base URL for usage examples
772803 scheme = request .headers .get ('X-Forwarded-Proto' , request .scheme )
773804 host = request .headers .get ('X-Forwarded-Host' , request .host )
774- base_url = f"{ scheme } ://{ host } "
805+ base_url = escape ( f"{ scheme } ://{ host } " )
775806
776807 content = DASHBOARD_CONTENT .format (
777808 keys_table = keys_table ,
@@ -1487,27 +1518,21 @@ async def admin_static(request: web.Request) -> web.Response:
14871518 """Serve static files (logo, etc.)."""
14881519 filename = request .match_info .get ('filename' , '' )
14891520
1490- # Security: only allow specific files
1491- allowed_files = {'logo.png' }
1492- if filename not in allowed_files :
1493- raise web .HTTPNotFound ()
1494-
1521+ # Security: allowlist maps filenames to (relative path, content type).
1522+ # The user-controlled value is only used as a dict key — the filesystem
1523+ # path is constructed entirely from hardcoded values, breaking taint flow.
14951524 static_dir = Path (__file__ ).parent / 'static'
1496- file_path = static_dir / filename
1525+ allowed_files = {
1526+ 'logo.png' : (static_dir / 'logo.png' , 'image/png' ),
1527+ }
14971528
1498- if not file_path .exists ():
1529+ entry = allowed_files .get (filename )
1530+ if entry is None :
14991531 raise web .HTTPNotFound ()
15001532
1501- # Determine content type
1502- content_types = {
1503- '.png' : 'image/png' ,
1504- '.jpg' : 'image/jpeg' ,
1505- '.jpeg' : 'image/jpeg' ,
1506- '.svg' : 'image/svg+xml' ,
1507- '.ico' : 'image/x-icon'
1508- }
1509- suffix = file_path .suffix .lower ()
1510- content_type = content_types .get (suffix , 'application/octet-stream' )
1533+ file_path , content_type = entry
1534+ if not file_path .exists ():
1535+ raise web .HTTPNotFound ()
15111536
15121537 with open (file_path , 'rb' ) as f :
15131538 return web .Response (body = f .read (), content_type = content_type )
0 commit comments