Fix XSS, CSRF, input validation, and related security issues

This commit is contained in:
2026-04-14 16:00:50 -05:00
parent e0f04ea971
commit 270acc0430
7 changed files with 86 additions and 33 deletions
+40 -20
View File
@@ -3,11 +3,22 @@ from sqlalchemy import create_engine, func
from sqlalchemy.orm import scoped_session, sessionmaker from sqlalchemy.orm import scoped_session, sessionmaker
from datetime import datetime, date, timedelta from datetime import datetime, date, timedelta
import json import re
from models import Base, Battery, BatteryPctLog, Device, CapacityTest, ChargeLog from models import Base, Battery, BatteryPctLog, Device, CapacityTest, ChargeLog
def _parse_date(val: str) -> str | None:
"""Return val if it is a valid YYYY-MM-DD string, else None."""
if not val:
return None
try:
datetime.strptime(val, "%Y-%m-%d")
return val
except ValueError:
return None
def create_app(config_object="config"): def create_app(config_object="config"):
app = Flask(__name__) app = Flask(__name__)
app.config.from_object(config_object) app.config.from_object(config_object)
@@ -28,6 +39,9 @@ def create_app(config_object="config"):
# Home Assistant integration (optional) # Home Assistant integration (optional)
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
from flask_wtf.csrf import CSRFProtect
CSRFProtect(app)
from ha_client import HomeAssistantClient from ha_client import HomeAssistantClient
from ha_poller import HaPoller from ha_poller import HaPoller
@@ -109,9 +123,14 @@ def create_app(config_object="config"):
purchase_date = f.get("purchase_date", "").strip() or None purchase_date = f.get("purchase_date", "").strip() or None
storage_location = f.get("storage_location", "").strip() or None storage_location = f.get("storage_location", "").strip() or None
existing = db.query(func.count(Battery.id)).filter_by(brand=brand).scalar() existing_labels = [
r[0] for r in db.query(Battery.label).filter(Battery.brand == brand).all()
]
nums = [int(m.group(1)) for lbl in existing_labels
if (m := re.search(r'(\d+)$', lbl))]
next_num = max(nums, default=0)
for i in range(count): for i in range(count):
label = f"{brand} {existing + i + 1:03d}" label = f"{brand} {next_num + i + 1:03d}"
db.add(Battery(label=label, brand=brand, status="available", notes=notes, db.add(Battery(label=label, brand=brand, status="available", notes=notes,
size=size, chemistry=chemistry, capacity_mah=capacity_mah, size=size, chemistry=chemistry, capacity_mah=capacity_mah,
purchase_date=purchase_date, storage_location=storage_location)) purchase_date=purchase_date, storage_location=storage_location))
@@ -160,26 +179,26 @@ def create_app(config_object="config"):
.filter_by(battery_id=battery_id) .filter_by(battery_id=battery_id)
.order_by(BatteryPctLog.recorded_at.desc()) .order_by(BatteryPctLog.recorded_at.desc())
.all()) .all())
charge_logs_json = json.dumps([ charge_logs_data = [
{"id": l.id, "date": l.charged_date, "cycles": l.increment_cycles, "notes": l.notes or ""} {"id": l.id, "date": l.charged_date, "cycles": l.increment_cycles, "notes": l.notes or ""}
for l in charge_logs for l in charge_logs
]) ]
capacity_tests_json = json.dumps([ capacity_tests_data = [
{"id": t.id, "date": t.tested_date, "mah": t.tested_capacity_mah, "notes": t.notes or ""} {"id": t.id, "date": t.tested_date, "mah": t.tested_capacity_mah, "notes": t.notes or ""}
for t in sorted(capacity_tests, key=lambda t: (t.tested_date, t.id), reverse=True) for t in sorted(capacity_tests, key=lambda t: (t.tested_date, t.id), reverse=True)
]) ]
pct_logs_json = json.dumps([ pct_logs_data = [
{"recorded_at": str(l.recorded_at), "pct": l.percentage, "source": l.source or ""} {"recorded_at": str(l.recorded_at), "pct": l.percentage, "source": l.source or ""}
for l in pct_logs for l in pct_logs
]) ]
return render_template("battery_detail.html", battery=battery, return render_template("battery_detail.html", battery=battery,
storage_locations=storage_locations, storage_locations=storage_locations,
capacity_tests=capacity_tests, capacity_tests=capacity_tests,
charge_logs=charge_logs, charge_logs=charge_logs,
pct_logs=pct_logs, pct_logs=pct_logs,
charge_logs_json=charge_logs_json, charge_logs_data=charge_logs_data,
capacity_tests_json=capacity_tests_json, capacity_tests_data=capacity_tests_data,
pct_logs_json=pct_logs_json) pct_logs_data=pct_logs_data)
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# Battery — edit notes # Battery — edit notes
@@ -204,7 +223,8 @@ def create_app(config_object="config"):
battery.chemistry = f.get("chemistry", "").strip() or None battery.chemistry = f.get("chemistry", "").strip() or None
battery.capacity_mah = _int("capacity_mah") battery.capacity_mah = _int("capacity_mah")
battery.charge_cycles = _int("charge_cycles") battery.charge_cycles = _int("charge_cycles")
battery.purchase_date = f.get("purchase_date", "").strip() or None purchase_raw = f.get("purchase_date", "").strip()
battery.purchase_date = _parse_date(purchase_raw) if purchase_raw else None
battery.storage_location = f.get("storage_location", "").strip() or None battery.storage_location = f.get("storage_location", "").strip() or None
new_pct = _int("battery_percentage") new_pct = _int("battery_percentage")
if new_pct != battery.battery_percentage: if new_pct != battery.battery_percentage:
@@ -239,10 +259,10 @@ def create_app(config_object="config"):
if battery is None: if battery is None:
abort(404) abort(404)
mah_raw = request.form.get("tested_capacity_mah", "").strip() mah_raw = request.form.get("tested_capacity_mah", "").strip()
date_val = request.form.get("tested_date", "").strip() date_val = _parse_date(request.form.get("tested_date", "").strip())
notes = request.form.get("notes", "").strip() or None notes = request.form.get("notes", "").strip() or None
if not mah_raw or not date_val: if not mah_raw or not date_val:
flash("Capacity (mAh) and date are required.", "error") flash("Capacity (mAh) and a valid date (YYYY-MM-DD) are required.", "error")
return redirect(url_for("battery_detail", battery_id=battery_id)) return redirect(url_for("battery_detail", battery_id=battery_id))
try: try:
mah = int(mah_raw) mah = int(mah_raw)
@@ -281,9 +301,9 @@ def create_app(config_object="config"):
battery = db.get(Battery, battery_id) battery = db.get(Battery, battery_id)
if battery is None: if battery is None:
abort(404) abort(404)
date_val = request.form.get("charged_date", "").strip() date_val = _parse_date(request.form.get("charged_date", "").strip())
if not date_val: if not date_val:
flash("Date is required.", "error") flash("A valid date (YYYY-MM-DD) is required.", "error")
return redirect(url_for("battery_detail", battery_id=battery_id)) return redirect(url_for("battery_detail", battery_id=battery_id))
increment = 1 if request.form.get("increment_cycles") else 0 increment = 1 if request.form.get("increment_cycles") else 0
notes = request.form.get("notes", "").strip() or None notes = request.form.get("notes", "").strip() or None
@@ -544,9 +564,9 @@ def create_app(config_object="config"):
label = field_name.replace("_", " ").title() label = field_name.replace("_", " ").title()
flash(f"Set {label} on {n} batter{'y' if n == 1 else 'ies'}.", "success") flash(f"Set {label} on {n} batter{'y' if n == 1 else 'ies'}.", "success")
elif action == "log_charged": elif action == "log_charged":
date_val = request.form.get("charged_date", "").strip() date_val = _parse_date(request.form.get("charged_date", "").strip())
if not date_val: if not date_val:
flash("Date is required.", "error") flash("A valid date (YYYY-MM-DD) is required.", "error")
return redirect(url_for("dashboard")) return redirect(url_for("dashboard"))
increment = 1 if request.form.get("increment_cycles") else 0 increment = 1 if request.form.get("increment_cycles") else 0
for b in batteries: for b in batteries:
@@ -857,4 +877,4 @@ def create_app(config_object="config"):
app = create_app() app = create_app()
if __name__ == "__main__": if __name__ == "__main__":
app.run(debug=True) app.run(debug=False)
+10 -1
View File
@@ -1,10 +1,19 @@
import os import os
import logging
SQLALCHEMY_DATABASE_URI = os.environ.get( SQLALCHEMY_DATABASE_URI = os.environ.get(
"DATABASE_URL", "DATABASE_URL",
"sqlite:///batteries.db", "sqlite:///batteries.db",
) )
SECRET_KEY = os.environ.get("SECRET_KEY", "dev-secret-change-in-prod")
_secret_key = os.environ.get("SECRET_KEY")
if not _secret_key:
logging.warning(
"SECRET_KEY not set — using insecure default. "
"Set SECRET_KEY env var before running in production."
)
SECRET_KEY = _secret_key or "dev-secret-change-in-prod"
SQLALCHEMY_TRACK_MODIFICATIONS = False SQLALCHEMY_TRACK_MODIFICATIONS = False
# Home Assistant integration (all optional — app works normally when absent) # Home Assistant integration (all optional — app works normally when absent)
+1
View File
@@ -1,4 +1,5 @@
Flask>=3.0,<4.0 Flask>=3.0,<4.0
Flask-WTF>=1.2,<2.0
SQLAlchemy>=2.0,<3.0 SQLAlchemy>=2.0,<3.0
PyMySQL>=1.1,<2.0 PyMySQL>=1.1,<2.0
waitress>=3.0,<4.0 waitress>=3.0,<4.0
+12
View File
@@ -349,6 +349,18 @@
navigator.serviceWorker.register('/sw.js'); navigator.serviceWorker.register('/sw.js');
} }
// Inject CSRF token into all POST forms
document.addEventListener('DOMContentLoaded', function() {
var token = '{{ csrf_token() }}';
document.querySelectorAll('form').forEach(function(form) {
if (form.method.toLowerCase() === 'post') {
var inp = document.createElement('input');
inp.type = 'hidden'; inp.name = 'csrf_token'; inp.value = token;
form.appendChild(inp);
}
});
});
(function() { (function() {
var modal = document.getElementById('confirm-modal'); var modal = document.getElementById('confirm-modal');
var msgEl = document.getElementById('confirm-modal-msg'); var msgEl = document.getElementById('confirm-modal-msg');
+21 -12
View File
@@ -411,7 +411,7 @@ function metaSelectChanged(sel, inputId) {
(function() { (function() {
var canvas = document.getElementById('pct-chart'); var canvas = document.getElementById('pct-chart');
if (!canvas) return; if (!canvas) return;
var rawLogs = {{ pct_logs_json | safe }}; var rawLogs = {{ pct_logs_data | tojson }};
// pct_logs_json is ordered newest-first; chart wants oldest-first // pct_logs_json is ordered newest-first; chart wants oldest-first
var logsAsc = rawLogs.slice().reverse(); var logsAsc = rawLogs.slice().reverse();
var vals = logsAsc.map(function(l) { return l.pct; }); var vals = logsAsc.map(function(l) { return l.pct; });
@@ -472,14 +472,15 @@ function metaSelectChanged(sel, inputId) {
<style> <style>
.hist-modal { display:none; position:fixed; inset:0; z-index:1000; background:rgba(0,0,0,.45); align-items:center; justify-content:center; } .hist-modal { display:none; position:fixed; inset:0; z-index:1000; background:rgba(0,0,0,.45); align-items:center; justify-content:center; }
.hist-modal.open { display:flex; } .hist-modal.open { display:flex; }
.hist-modal-box { background:#fff; border-radius:8px; width:min(700px,96vw); max-height:85vh; display:flex; flex-direction:column; overflow:hidden; box-shadow:0 8px 32px rgba(0,0,0,.2); } .hist-modal-box { background:var(--bg-card,#fff); color:var(--text-body,#222); border-radius:8px; width:min(700px,96vw); max-height:85vh; display:flex; flex-direction:column; overflow:hidden; box-shadow:0 8px 32px rgba(0,0,0,.2); }
.hist-modal-header { display:flex; align-items:center; justify-content:space-between; padding:0.9rem 1.1rem 0.7rem; border-bottom:1px solid var(--border,#e2e8f0); } .hist-modal-header { display:flex; align-items:center; justify-content:space-between; padding:0.9rem 1.1rem 0.7rem; border-bottom:1px solid var(--border,#e2e8f0); }
.hist-modal-header h3 { margin:0; font-size:1.05rem; } .hist-modal-header h3 { margin:0; font-size:1.05rem; color:var(--text-h2,#334155); }
.hist-modal-close { background:none; border:none; font-size:1.2rem; cursor:pointer; color:var(--text-muted,#6b7280); line-height:1; padding:0.2rem 0.4rem; } .hist-modal-close { background:none; border:none; font-size:1.2rem; cursor:pointer; color:var(--text-muted,#6b7280); line-height:1; padding:0.2rem 0.4rem; }
.hist-modal-filters { padding:0.6rem 1.1rem; border-bottom:1px solid var(--border,#e2e8f0); display:flex; gap:0.75rem; align-items:center; flex-wrap:wrap; } .hist-modal-filters { padding:0.6rem 1.1rem; border-bottom:1px solid var(--border,#e2e8f0); display:flex; gap:0.75rem; align-items:center; flex-wrap:wrap; }
.hist-modal-filters:empty { display:none; } .hist-modal-filters:empty { display:none; }
.hist-modal-body { overflow-y:auto; flex:1; padding:0.5rem 0.5rem; } .hist-modal-body { overflow-y:auto; flex:1; padding:0.5rem 0.5rem; }
.hist-modal-footer { display:flex; align-items:center; justify-content:space-between; padding:0.6rem 1.1rem; border-top:1px solid var(--border,#e2e8f0); font-size:0.85rem; color:var(--text-muted,#6b7280); } .hist-modal-footer { display:flex; align-items:center; justify-content:space-between; padding:0.6rem 1.1rem; border-top:1px solid var(--border,#e2e8f0); font-size:0.85rem; color:var(--text-muted,#6b7280); }
.hist-modal-filters select { background:var(--bg-input,#fff); color:var(--text-body,#222); border:1px solid var(--border-input,#d1d5db); border-radius:4px; padding:0.3rem 0.5rem; font-size:0.875rem; font-family:inherit; }
</style> </style>
<!-- Capacity modal --> <!-- Capacity modal -->
@@ -549,6 +550,14 @@ function metaSelectChanged(sel, inputId) {
var HIST_PAGE = 20; var HIST_PAGE = 20;
var _batteryId = {{ battery.id }}; var _batteryId = {{ battery.id }};
function escHtml(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function openHistModal(id) { function openHistModal(id) {
document.getElementById(id).classList.add('open'); document.getElementById(id).classList.add('open');
} }
@@ -618,7 +627,7 @@ function makeModal(cfg) {
} }
// ── Capacity modal ──────────────────────────────────────────────────────── // ── Capacity modal ────────────────────────────────────────────────────────
var _capAll = {{ capacity_tests_json | safe }}; var _capAll = {{ capacity_tests_data | tojson }};
var capModal = makeModal({ var capModal = makeModal({
all: _capAll, all: _capAll,
bodyId: 'cap-modal-body', prevId: 'cap-prev', nextId: 'cap-next', pageInfoId: 'cap-page-info', bodyId: 'cap-modal-body', prevId: 'cap-prev', nextId: 'cap-next', pageInfoId: 'cap-page-info',
@@ -626,9 +635,9 @@ var capModal = makeModal({
thead: '<tr><th>Date</th><th>Capacity</th><th>Notes</th><th></th></tr>', thead: '<tr><th>Date</th><th>Capacity</th><th>Notes</th><th></th></tr>',
renderRow: function(r) { renderRow: function(r) {
return '<tr>' + return '<tr>' +
'<td data-label="Date">' + r.date + '</td>' + '<td data-label="Date">' + escHtml(r.date) + '</td>' +
'<td data-label="Capacity">' + r.mah + ' mAh</td>' + '<td data-label="Capacity">' + r.mah + ' mAh</td>' +
'<td data-label="Notes" class="text-muted">' + (r.notes || '—') + '</td>' + '<td data-label="Notes" class="text-muted">' + (escHtml(r.notes) || '—') + '</td>' +
'<td data-label="">' + '<td data-label="">' +
'<form class="inline" method="post" ' + '<form class="inline" method="post" ' +
'action="/battery/' + _batteryId + '/capacity-test/' + r.id + '/delete" ' + 'action="/battery/' + _batteryId + '/capacity-test/' + r.id + '/delete" ' +
@@ -645,7 +654,7 @@ document.querySelector('[onclick="openHistModal(\'cap-modal\')"]') &&
document.querySelector('[onclick="openHistModal(\'cap-modal\')"]').addEventListener('click', function() { capModal.init(); }); document.querySelector('[onclick="openHistModal(\'cap-modal\')"]').addEventListener('click', function() { capModal.init(); });
// ── Charge modal ────────────────────────────────────────────────────────── // ── Charge modal ──────────────────────────────────────────────────────────
var _chgAll = {{ charge_logs_json | safe }}; var _chgAll = {{ charge_logs_data | tojson }};
var chgModal = makeModal({ var chgModal = makeModal({
all: _chgAll, all: _chgAll,
bodyId: 'chg-modal-body', prevId: 'chg-prev', nextId: 'chg-next', pageInfoId: 'chg-page-info', bodyId: 'chg-modal-body', prevId: 'chg-prev', nextId: 'chg-next', pageInfoId: 'chg-page-info',
@@ -653,9 +662,9 @@ var chgModal = makeModal({
thead: '<tr><th>Date</th><th>+Cycle</th><th>Notes</th><th></th></tr>', thead: '<tr><th>Date</th><th>+Cycle</th><th>Notes</th><th></th></tr>',
renderRow: function(r) { renderRow: function(r) {
return '<tr>' + return '<tr>' +
'<td data-label="Date">' + r.date + '</td>' + '<td data-label="Date">' + escHtml(r.date) + '</td>' +
'<td data-label="+Cycle">' + (r.cycles ? '✓' : '—') + '</td>' + '<td data-label="+Cycle">' + (r.cycles ? '✓' : '—') + '</td>' +
'<td data-label="Notes" class="text-muted">' + (r.notes || '—') + '</td>' + '<td data-label="Notes" class="text-muted">' + (escHtml(r.notes) || '—') + '</td>' +
'<td data-label="">' + '<td data-label="">' +
'<form class="inline" method="post" ' + '<form class="inline" method="post" ' +
'action="/battery/' + _batteryId + '/charge-log/' + r.id + '/delete" ' + 'action="/battery/' + _batteryId + '/charge-log/' + r.id + '/delete" ' +
@@ -669,7 +678,7 @@ document.querySelector('[onclick="openHistModal(\'chg-modal\')"]') &&
document.querySelector('[onclick="openHistModal(\'chg-modal\')"]').addEventListener('click', function() { chgModal.init(); }); document.querySelector('[onclick="openHistModal(\'chg-modal\')"]').addEventListener('click', function() { chgModal.init(); });
// ── Percentage modal ────────────────────────────────────────────────────── // ── Percentage modal ──────────────────────────────────────────────────────
var _pctAll = {{ pct_logs_json | safe }}; var _pctAll = {{ pct_logs_data | tojson }};
var pctModal = makeModal({ var pctModal = makeModal({
all: _pctAll, all: _pctAll,
bodyId: 'pct-modal-body', prevId: 'pct-prev', nextId: 'pct-next', pageInfoId: 'pct-page-info', bodyId: 'pct-modal-body', prevId: 'pct-prev', nextId: 'pct-next', pageInfoId: 'pct-page-info',
@@ -680,9 +689,9 @@ var pctModal = makeModal({
? '<span class="badge badge-warning">⚠ ' + r.pct + '%</span>' ? '<span class="badge badge-warning">⚠ ' + r.pct + '%</span>'
: r.pct + '%'; : r.pct + '%';
return '<tr>' + return '<tr>' +
'<td data-label="Date / Time">' + r.recorded_at + '</td>' + '<td data-label="Date / Time">' + escHtml(r.recorded_at) + '</td>' +
'<td data-label="%">' + pctHtml + '</td>' + '<td data-label="%">' + pctHtml + '</td>' +
'<td data-label="Source" class="text-muted">' + (r.source || '—') + '</td>' + '<td data-label="Source" class="text-muted">' + (escHtml(r.source) || '—') + '</td>' +
'</tr>'; '</tr>';
} }
}); });
+1
View File
@@ -15,6 +15,7 @@ def app():
SECRET_KEY = "test-secret" SECRET_KEY = "test-secret"
SQLALCHEMY_TRACK_MODIFICATIONS = False SQLALCHEMY_TRACK_MODIFICATIONS = False
TESTING = True TESTING = True
WTF_CSRF_ENABLED = False # disable CSRF validation in tests
HOMEASSISTANT_URL = None # prevent HA poller from starting in tests HOMEASSISTANT_URL = None # prevent HA poller from starting in tests
HOMEASSISTANT_API_KEY = None HOMEASSISTANT_API_KEY = None
+1
View File
@@ -33,6 +33,7 @@ def ha_app():
SECRET_KEY = "test-secret" SECRET_KEY = "test-secret"
SQLALCHEMY_TRACK_MODIFICATIONS = False SQLALCHEMY_TRACK_MODIFICATIONS = False
TESTING = True TESTING = True
WTF_CSRF_ENABLED = False # disable CSRF validation in tests
HOMEASSISTANT_URL = "http://ha.test:8123" HOMEASSISTANT_URL = "http://ha.test:8123"
HOMEASSISTANT_API_KEY = "fake-token" HOMEASSISTANT_API_KEY = "fake-token"
HOMEASSISTANT_POLL_INTERVAL = 300 HOMEASSISTANT_POLL_INTERVAL = 300