33import functools
44import logging
55import os
6+ import re
7+ import stat
68import time
79from datetime import datetime , timedelta
810
911from flask import Flask , render_template , request , jsonify , redirect , session , url_for
12+ from werkzeug .security import check_password_hash
1013
1114from .config import POLL_MIN , POLL_MAX , PASSWORD_MASK , SECRET_KEYS
1215from .i18n import get_translations , LANGUAGES
1316
1417log = logging .getLogger ("docsis.web" )
1518
1619app = Flask (__name__ , template_folder = "templates" )
17- app .secret_key = os .environ .get ("FLASK_SECRET_KEY" , os .urandom (32 ))
20+ app .secret_key = os .urandom (32 ) # overwritten by _init_session_key
21+
22+ _TS_RE = re .compile (r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$" )
23+ _DATE_RE = re .compile (r"^\d{4}-\d{2}-\d{2}$" )
1824
1925
2026@app .template_filter ("fmt_k" )
@@ -63,11 +69,30 @@ def init_storage(storage):
6369 _storage = storage
6470
6571
72+ def _init_session_key (data_dir ):
73+ """Load or generate a persistent session secret key."""
74+ key_path = os .path .join (data_dir , ".session_key" )
75+ if os .path .exists (key_path ):
76+ with open (key_path , "rb" ) as f :
77+ app .secret_key = f .read ()
78+ else :
79+ key = os .urandom (32 )
80+ os .makedirs (data_dir , exist_ok = True )
81+ with open (key_path , "wb" ) as f :
82+ f .write (key )
83+ try :
84+ os .chmod (key_path , stat .S_IRUSR | stat .S_IWUSR )
85+ except OSError :
86+ pass
87+ app .secret_key = key
88+
89+
6690def init_config (config_manager , on_config_changed = None ):
6791 """Set the config manager and optional change callback."""
6892 global _config_manager , _on_config_changed
6993 _config_manager = config_manager
7094 _on_config_changed = on_config_changed
95+ _init_session_key (config_manager .data_dir )
7196
7297
7398def _auth_required ():
@@ -100,7 +125,12 @@ def login():
100125 error = None
101126 if request .method == "POST" :
102127 pw = request .form .get ("password" , "" )
103- if pw == _config_manager .get ("admin_password" , "" ):
128+ stored = _config_manager .get ("admin_password" , "" )
129+ if stored .startswith (("scrypt:" , "pbkdf2:" )):
130+ success = check_password_hash (stored , pw )
131+ else :
132+ success = (pw == stored ) # legacy plaintext / env var
133+ if success :
104134 session ["authenticated" ] = True
105135 return redirect ("/" )
106136 error = t .get ("login_failed" , "Invalid password" )
@@ -148,6 +178,8 @@ def index():
148178 conn_info = _state .get ("connection_info" ) or {}
149179
150180 ts = request .args .get ("t" )
181+ if ts and not _TS_RE .match (ts ):
182+ return redirect ("/" )
151183 if ts and _storage :
152184 snapshot = _storage .get_snapshot (ts )
153185 if snapshot :
@@ -269,6 +301,7 @@ def api_test_mqtt():
269301
270302
271303@app .route ("/api/calendar" )
304+ @require_auth
272305def api_calendar ():
273306 """Return dates that have snapshot data."""
274307 if _storage :
@@ -277,17 +310,21 @@ def api_calendar():
277310
278311
279312@app .route ("/api/snapshot/daily" )
313+ @require_auth
280314def api_snapshot_daily ():
281315 """Return the daily snapshot closest to the configured snapshot_time."""
282316 date = request .args .get ("date" )
283317 if not date or not _storage :
284318 return jsonify (None )
319+ if not _DATE_RE .match (date ):
320+ return jsonify ({"error" : "Invalid date format" }), 400
285321 target_time = _config_manager .get ("snapshot_time" , "06:00" ) if _config_manager else "06:00"
286322 snap = _storage .get_daily_snapshot (date , target_time )
287323 return jsonify (snap )
288324
289325
290326@app .route ("/api/trends" )
327+ @require_auth
291328def api_trends ():
292329 """Return trend data for a date range.
293330 ?range=day|week|month&date=YYYY-MM-DD (date defaults to today)."""
@@ -321,6 +358,7 @@ def api_trends():
321358
322359
323360@app .route ("/api/export" )
361+ @require_auth
324362def api_export ():
325363 """Generate a structured markdown report for LLM analysis."""
326364 analysis = _state .get ("analysis" )
@@ -407,13 +445,23 @@ def api_export():
407445
408446
409447@app .route ("/api/snapshots" )
448+ @require_auth
410449def api_snapshots ():
411450 """Return list of available snapshot timestamps."""
412451 if _storage :
413452 return jsonify (_storage .get_snapshot_list ())
414453 return jsonify ([])
415454
416455
456+ @app .after_request
457+ def add_security_headers (response ):
458+ response .headers ["X-Content-Type-Options" ] = "nosniff"
459+ response .headers ["X-Frame-Options" ] = "DENY"
460+ response .headers ["X-XSS-Protection" ] = "1; mode=block"
461+ response .headers ["Referrer-Policy" ] = "strict-origin-when-cross-origin"
462+ return response
463+
464+
417465@app .route ("/health" )
418466def health ():
419467 """Simple health check endpoint."""
0 commit comments