Skip to content

Commit 42f6354

Browse files
itsDNNSclaude
andcommitted
Harden security: password hashing, session persistence, input validation
- Admin password stored as scrypt hash instead of reversible encryption - Session secret key persisted to file (survives container restarts) - All API endpoints protected with @require_auth - Timestamp/date parameters validated against format regex - Security headers (X-Content-Type-Options, X-Frame-Options, etc.) - Docker container runs as non-root user (uid 1000) - 97 tests passing (15 new security tests) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 620bd6a commit 42f6354

File tree

7 files changed

+171
-6
lines changed

7 files changed

+171
-6
lines changed

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,16 @@ All notable changes to this project will be documented in this file.
44

55
Versioning: `YYYY-MM-DD.N` (date + sequential build number per day)
66

7+
## [2026-02-09.14]
8+
9+
### Changed
10+
- **Password hashing**: Admin password stored as scrypt hash instead of reversible encryption
11+
- **Session key persisted**: Flask session secret key saved to file, sessions survive container restarts
12+
- **All API endpoints require auth**: `/api/calendar`, `/api/trends`, `/api/export`, `/api/snapshots`, `/api/snapshot/daily` now protected
13+
- **Input validation**: Timestamp and date parameters validated against format regex
14+
- **Security headers**: `X-Content-Type-Options`, `X-Frame-Options`, `X-XSS-Protection`, `Referrer-Policy` on all responses
15+
- **Non-root Docker user**: Container runs as `appuser` (uid 1000) instead of root
16+
717
## [2026-02-09.13]
818

919
### Added

Dockerfile

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@ WORKDIR /app
33
COPY requirements.txt .
44
RUN pip install --no-cache-dir -r requirements.txt
55
COPY app/ ./app/
6+
RUN adduser --disabled-password --gecos "" --uid 1000 appuser && \
7+
mkdir -p /data && chown appuser:appuser /data
8+
USER appuser
69
HEALTHCHECK --interval=60s --timeout=5s --retries=3 \
710
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8765/health')" || exit 1
811
CMD ["python", "-m", "app.main"]

app/config.py

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,15 @@
66
import stat
77

88
from cryptography.fernet import Fernet
9+
from werkzeug.security import generate_password_hash
910

1011
log = logging.getLogger("docsis.config")
1112

1213
POLL_MIN = 60
1314
POLL_MAX = 3600
1415

15-
SECRET_KEYS = {"fritz_password", "mqtt_password", "admin_password"}
16+
SECRET_KEYS = {"fritz_password", "mqtt_password"}
17+
HASH_KEYS = {"admin_password"}
1618
PASSWORD_MASK = "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022"
1719

1820
DEFAULTS = {
@@ -127,6 +129,11 @@ def get(self, key, default=None):
127129
val = self._file_config[key]
128130
if key in INT_KEYS and not isinstance(val, int):
129131
return int(val)
132+
if key in HASH_KEYS:
133+
# Return werkzeug hash as-is; legacy Fernet-encrypted values get decrypted
134+
if val and (val.startswith("scrypt:") or val.startswith("pbkdf2:")):
135+
return val
136+
return self._decrypt(val)
130137
if key in SECRET_KEYS:
131138
return self._decrypt(val)
132139
return val
@@ -140,10 +147,16 @@ def save(self, data):
140147
os.makedirs(os.path.dirname(self.config_path), exist_ok=True)
141148

142149
# Don't overwrite passwords with the mask placeholder
143-
for key in SECRET_KEYS:
150+
for key in SECRET_KEYS | HASH_KEYS:
144151
if key in data and data[key] == PASSWORD_MASK:
145152
del data[key]
146153

154+
# Hash password keys (admin_password) before storing
155+
for key in HASH_KEYS:
156+
if key in data and data[key]:
157+
if not (data[key].startswith("scrypt:") or data[key].startswith("pbkdf2:")):
158+
data[key] = generate_password_hash(data[key])
159+
147160
# Encrypt secret values before storing
148161
for key in SECRET_KEYS:
149162
if key in data and data[key]:
@@ -187,7 +200,7 @@ def get_all(self, mask_secrets=False):
187200
result = {}
188201
for key in DEFAULTS:
189202
val = self.get(key)
190-
if mask_secrets and key in SECRET_KEYS and val:
203+
if mask_secrets and key in (SECRET_KEYS | HASH_KEYS) and val:
191204
result[key] = PASSWORD_MASK
192205
else:
193206
result[key] = val

app/web.py

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,24 @@
33
import functools
44
import logging
55
import os
6+
import re
7+
import stat
68
import time
79
from datetime import datetime, timedelta
810

911
from flask import Flask, render_template, request, jsonify, redirect, session, url_for
12+
from werkzeug.security import check_password_hash
1013

1114
from .config import POLL_MIN, POLL_MAX, PASSWORD_MASK, SECRET_KEYS
1215
from .i18n import get_translations, LANGUAGES
1316

1417
log = logging.getLogger("docsis.web")
1518

1619
app = 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+
6690
def 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

7398
def _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
272305
def 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
280314
def 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
291328
def 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
324362
def 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
410449
def 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")
418466
def health():
419467
"""Simple health check endpoint."""

tests/test_auth.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,3 +105,24 @@ def test_api_config_requires_auth(self, auth_client):
105105
content_type="application/json",
106106
)
107107
assert resp.status_code == 302
108+
109+
def test_api_calendar_requires_auth(self, auth_client):
110+
resp = auth_client.get("/api/calendar")
111+
assert resp.status_code == 302
112+
113+
def test_api_snapshots_requires_auth(self, auth_client):
114+
resp = auth_client.get("/api/snapshots")
115+
assert resp.status_code == 302
116+
117+
def test_api_export_requires_auth(self, auth_client):
118+
resp = auth_client.get("/api/export")
119+
assert resp.status_code == 302
120+
121+
def test_api_trends_requires_auth(self, auth_client):
122+
resp = auth_client.get("/api/trends")
123+
assert resp.status_code == 302
124+
125+
def test_password_hashed_not_plaintext(self, auth_config):
126+
stored = auth_config.get("admin_password")
127+
assert stored != "secret123"
128+
assert stored.startswith(("scrypt:", "pbkdf2:"))

tests/test_config.py

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import json
44
import os
55
import pytest
6-
from app.config import ConfigManager, DEFAULTS, SECRET_KEYS, PASSWORD_MASK
6+
from app.config import ConfigManager, DEFAULTS, SECRET_KEYS, HASH_KEYS, PASSWORD_MASK
77

88

99
@pytest.fixture
@@ -93,6 +93,30 @@ def test_get_all_shows_secrets(self, config):
9393
all_config = config.get_all(mask_secrets=False)
9494
assert all_config["fritz_password"] == "secret"
9595

96+
def test_admin_password_hashed_at_rest(self, config, tmp_data_dir):
97+
config.save({"admin_password": "admin123"})
98+
with open(os.path.join(tmp_data_dir, "config.json")) as f:
99+
raw = json.load(f)
100+
assert raw["admin_password"] != "admin123"
101+
assert raw["admin_password"].startswith(("scrypt:", "pbkdf2:"))
102+
103+
def test_admin_password_hash_returned(self, config):
104+
config.save({"admin_password": "admin123"})
105+
stored = config.get("admin_password")
106+
assert stored.startswith(("scrypt:", "pbkdf2:"))
107+
108+
def test_admin_password_mask_not_saved(self, tmp_data_dir):
109+
config = ConfigManager(tmp_data_dir)
110+
config.save({"admin_password": "original"})
111+
hash1 = config.get("admin_password")
112+
config.save({"admin_password": PASSWORD_MASK, "fritz_user": "updated"})
113+
assert config.get("admin_password") == hash1
114+
115+
def test_admin_password_masked_in_get_all(self, config):
116+
config.save({"admin_password": "secret"})
117+
all_config = config.get_all(mask_secrets=True)
118+
assert all_config["admin_password"] == PASSWORD_MASK
119+
96120

97121
class TestConfigEnvOverride:
98122
def test_env_overrides_file(self, tmp_data_dir, monkeypatch):

tests/test_web.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,52 @@ def test_save_no_data(self, client):
188188
assert resp.status_code in (400, 500)
189189

190190

191+
class TestSecurityHeaders:
192+
def test_headers_present(self, client, sample_analysis):
193+
update_state(analysis=sample_analysis)
194+
resp = client.get("/")
195+
assert resp.headers["X-Content-Type-Options"] == "nosniff"
196+
assert resp.headers["X-Frame-Options"] == "DENY"
197+
assert resp.headers["X-XSS-Protection"] == "1; mode=block"
198+
assert resp.headers["Referrer-Policy"] == "strict-origin-when-cross-origin"
199+
200+
def test_headers_on_health(self, client):
201+
resp = client.get("/health")
202+
assert resp.headers["X-Content-Type-Options"] == "nosniff"
203+
204+
205+
class TestTimestampValidation:
206+
def test_invalid_timestamp_rejected(self, client, sample_analysis):
207+
update_state(analysis=sample_analysis)
208+
resp = client.get("/?t=../../etc/passwd")
209+
assert resp.status_code == 302
210+
assert resp.headers["Location"] == "/"
211+
212+
def test_valid_timestamp_accepted(self, client, sample_analysis):
213+
update_state(analysis=sample_analysis)
214+
# No storage, so snapshot lookup returns None and falls through to live view
215+
resp = client.get("/?t=2026-01-01T06:00:00")
216+
assert resp.status_code == 200
217+
218+
219+
class TestSessionKeyPersistence:
220+
def test_session_key_file_created(self, tmp_path):
221+
data_dir = str(tmp_path / "data_sk")
222+
mgr = ConfigManager(data_dir)
223+
init_config(mgr)
224+
import os
225+
assert os.path.exists(os.path.join(data_dir, ".session_key"))
226+
227+
def test_session_key_persisted(self, tmp_path):
228+
data_dir = str(tmp_path / "data_sk2")
229+
mgr = ConfigManager(data_dir)
230+
init_config(mgr)
231+
key1 = app.secret_key
232+
# Re-init should load same key
233+
init_config(mgr)
234+
assert app.secret_key == key1
235+
236+
191237
class TestFormatK:
192238
def test_large_number(self):
193239
from app.web import format_k

0 commit comments

Comments
 (0)