Skip to content

Commit e36840c

Browse files
committed
Deep audit wave 2: PBKDF2 auth, XSS hardening, input validation, bug fixes
- Upgrade PIN/password hashing from plain SHA-256 to PBKDF2-SHA256 with random salt; transparent upgrade of legacy hashes on login - Fix alert_rules cooldown: use utcnow() to match CURRENT_TIMESTAMP, handle timezone-aware strings without crash - Fix AI context window trimming: preserve system message on truncation - Fix monthly task recurrence drift: calendar-aware month arithmetic instead of timedelta(days=30) - Fix garden seed expiration date: use calendar.monthrange() instead of arbitrary day cap at 28 - Fix version mismatch: nomad.py now uses Config.VERSION instead of stale hardcoded 7.10.0 - Add coordinate validation (-90..90 lat, -180..180 lng) to waypoint create/update and emergency rally point create/update - Add triage category validation (immediate/delayed/minimal/expectant) - Fix inventory CSV import data loss: per-row error handling instead of all-or-nothing crash - Escape 15+ innerHTML XSS vectors in system info, benchmarks, maps, models, widget config, and phrase cards - All 913 tests pass
1 parent cfddb90 commit e36840c

13 files changed

Lines changed: 188 additions & 81 deletions

File tree

nomad.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ def _check_deps():
5555
from web.app import create_app, set_version
5656
from db import init_db, get_db, log_activity, backup_db
5757

58-
VERSION = '7.10.0'
58+
VERSION = Config.VERSION
5959
PORT = Config.APP_PORT
6060
HOST = Config.APP_HOST
6161

web/blueprints/ai.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,15 +37,23 @@ def _trim_messages_to_fit(messages, max_tokens=4096, system_tokens=0):
3737
if not messages:
3838
return messages
3939
budget = max_tokens - system_tokens - 200 # Reserve 200 for response
40+
# Preserve system message if present — must never be dropped
41+
system_msg = None
42+
rest = messages
43+
if messages[0].get('role') == 'system':
44+
system_msg = messages[0]
45+
rest = messages[1:]
46+
budget -= _estimate_tokens(system_msg.get('content', ''))
4047
result = []
4148
total = 0
42-
# Always keep the system message (first) and iterate from newest
43-
for msg in reversed(messages):
49+
for msg in reversed(rest):
4450
msg_tokens = _estimate_tokens(msg.get('content', ''))
4551
if total + msg_tokens > budget and result:
4652
break
4753
result.insert(0, msg)
4854
total += msg_tokens
55+
if system_msg:
56+
result.insert(0, system_msg)
4957
return result
5058

5159

web/blueprints/alert_rules.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -137,12 +137,14 @@ def api_alert_rules_evaluate():
137137
is_triggered = _compare(current_value, comp, threshold)
138138

139139
if is_triggered:
140-
# Check cooldown
140+
# Check cooldown (CURRENT_TIMESTAMP stores UTC)
141141
if rule['last_triggered']:
142142
from datetime import datetime, timedelta
143143
try:
144144
last = datetime.fromisoformat(rule['last_triggered'])
145-
if datetime.now() - last < timedelta(minutes=rule['cooldown_minutes']):
145+
if last.tzinfo is not None:
146+
last = last.replace(tzinfo=None)
147+
if datetime.utcnow() - last < timedelta(minutes=rule['cooldown_minutes']):
146148
continue # Still in cooldown
147149
except (ValueError, TypeError):
148150
pass

web/blueprints/emergency.py

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -408,6 +408,20 @@ def api_rally_point_create(pid):
408408
if not name:
409409
return jsonify({'error': 'name is required'}), 400
410410

411+
# Validate coordinates if provided
412+
lat = data.get('lat')
413+
lng = data.get('lng')
414+
if lat is not None or lng is not None:
415+
try:
416+
lat = float(lat) if lat is not None else None
417+
lng = float(lng) if lng is not None else None
418+
except (ValueError, TypeError):
419+
return jsonify({'error': 'lat and lng must be numeric'}), 400
420+
if lat is not None and not (-90 <= lat <= 90):
421+
return jsonify({'error': 'lat must be -90..90'}), 400
422+
if lng is not None and not (-180 <= lng <= 180):
423+
return jsonify({'error': 'lng must be -180..180'}), 400
424+
411425
with db_session() as db:
412426
plan = db.execute(
413427
'SELECT id FROM evac_plans WHERE id = ?', (pid,)
@@ -425,8 +439,8 @@ def api_rally_point_create(pid):
425439
pid,
426440
name,
427441
data.get('location', ''),
428-
data.get('lat'),
429-
data.get('lng'),
442+
lat,
443+
lng,
430444
data.get('point_type', 'assembly'),
431445
int(data.get('sequence_order', 0)),
432446
data.get('notes', ''),
@@ -451,6 +465,19 @@ def api_rally_point_update(rid):
451465
fields = {k: v for k, v in data.items() if k in allowed}
452466
if not fields:
453467
return jsonify({'error': 'no valid fields to update'}), 400
468+
# Validate coordinates if provided
469+
if 'lat' in fields or 'lng' in fields:
470+
try:
471+
if 'lat' in fields:
472+
lat_val = float(fields['lat'])
473+
if not (-90 <= lat_val <= 90):
474+
return jsonify({'error': 'lat must be -90..90'}), 400
475+
if 'lng' in fields:
476+
lng_val = float(fields['lng'])
477+
if not (-180 <= lng_val <= 180):
478+
return jsonify({'error': 'lng must be -180..180'}), 400
479+
except (ValueError, TypeError):
480+
return jsonify({'error': 'lat and lng must be numeric'}), 400
454481

455482
with db_session() as db:
456483
existing = db.execute(

web/blueprints/garden.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Garden & agriculture routes."""
22

3+
import calendar
34
import json
45
import time
56
from datetime import date, timedelta
@@ -684,7 +685,7 @@ def api_preservation_expiring():
684685
continue
685686
exp_year = batch.year + (batch.month + d['shelf_life_months'] - 1) // 12
686687
exp_month = (batch.month + d['shelf_life_months'] - 1) % 12 + 1
687-
exp_day = min(batch.day, 28)
688+
exp_day = min(batch.day, calendar.monthrange(exp_year, exp_month)[1])
688689
expiration = date(exp_year, exp_month, exp_day)
689690
if expiration <= threshold:
690691
d['expiration_date'] = expiration.isoformat()

web/blueprints/inventory.py

Lines changed: 20 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -602,23 +602,27 @@ def api_inventory_import_csv():
602602
reader = csv.DictReader(io.StringIO(content))
603603
with db_session() as db:
604604
imported = 0
605-
for row in reader:
606-
name = row.get('Name', row.get('name', '')).strip()
607-
if not name:
608-
continue
609-
db.execute(
610-
'INSERT INTO inventory (name, category, quantity, unit, min_quantity, daily_usage, location, expiration, notes) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
611-
(name, row.get('Category', row.get('category', 'other')),
612-
float(row.get('Quantity', row.get('quantity', 0)) or 0),
613-
row.get('Unit', row.get('unit', 'ea')),
614-
float(row.get('Min Qty', row.get('min_quantity', 0)) or 0),
615-
float(row.get('Daily Usage', row.get('daily_usage', 0)) or 0),
616-
row.get('Location', row.get('location', '')),
617-
row.get('Expiration', row.get('expiration', '')),
618-
row.get('Notes', row.get('notes', ''))))
619-
imported += 1
605+
errors = []
606+
for i, row in enumerate(reader, 1):
607+
try:
608+
name = row.get('Name', row.get('name', '')).strip()
609+
if not name:
610+
continue
611+
db.execute(
612+
'INSERT INTO inventory (name, category, quantity, unit, min_quantity, daily_usage, location, expiration, notes) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
613+
(name, row.get('Category', row.get('category', 'other')),
614+
float(row.get('Quantity', row.get('quantity', 0)) or 0),
615+
row.get('Unit', row.get('unit', 'ea')),
616+
float(row.get('Min Qty', row.get('min_quantity', 0)) or 0),
617+
float(row.get('Daily Usage', row.get('daily_usage', 0)) or 0),
618+
row.get('Location', row.get('location', '')),
619+
row.get('Expiration', row.get('expiration', '')),
620+
row.get('Notes', row.get('notes', ''))))
621+
imported += 1
622+
except Exception as e:
623+
errors.append(f'Row {i}: {str(e)}')
620624
db.commit()
621-
return jsonify({'status': 'imported', 'count': imported})
625+
return jsonify({'status': 'imported', 'count': imported, 'errors': errors})
622626
except Exception as e:
623627
log.exception('Inventory CSV import failed')
624628
return jsonify({'error': 'Import failed — check file format'}), 500

web/blueprints/maps.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -702,11 +702,18 @@ def api_waypoints_list():
702702
@maps_bp.route('/api/waypoints', methods=['POST'])
703703
def api_waypoints_create():
704704
data = request.get_json() or {}
705+
try:
706+
lat = float(data.get('lat', 0))
707+
lng = float(data.get('lng', 0))
708+
except (ValueError, TypeError):
709+
return jsonify({'error': 'lat and lng must be numeric'}), 400
710+
if not (-90 <= lat <= 90) or not (-180 <= lng <= 180):
711+
return jsonify({'error': 'lat must be -90..90, lng must be -180..180'}), 400
705712
cat = data.get('category', 'general')
706713
color = WAYPOINT_COLORS.get(cat, '#9e9e9e')
707714
with db_session() as db:
708715
cur = db.execute('INSERT INTO waypoints (name, lat, lng, category, color, notes) VALUES (?, ?, ?, ?, ?, ?)',
709-
(data.get('name', 'Waypoint'), data.get('lat', 0), data.get('lng', 0),
716+
(data.get('name', 'Waypoint'), lat, lng,
710717
cat, color, data.get('notes', '')))
711718
db.commit()
712719
row = db.execute('SELECT * FROM waypoints WHERE id = ?', (cur.lastrowid,)).fetchone()
@@ -729,6 +736,17 @@ def api_waypoint_update(wid):
729736
fields = {k: v for k, v in data.items() if k in allowed}
730737
if not fields:
731738
return jsonify({'error': 'No valid fields provided'}), 400
739+
# Validate coordinates if provided
740+
if 'lat' in fields or 'lng' in fields:
741+
try:
742+
lat = float(fields.get('lat', 0))
743+
lng = float(fields.get('lng', 0))
744+
except (ValueError, TypeError):
745+
return jsonify({'error': 'lat and lng must be numeric'}), 400
746+
if 'lat' in fields and not (-90 <= lat <= 90):
747+
return jsonify({'error': 'lat must be -90..90'}), 400
748+
if 'lng' in fields and not (-180 <= lng <= 180):
749+
return jsonify({'error': 'lng must be -180..180'}), 400
732750
# Auto-set color when category changes
733751
if 'category' in fields:
734752
fields['color'] = WAYPOINT_COLORS.get(fields['category'], '#9e9e9e')

web/blueprints/medical.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -837,6 +837,9 @@ def api_triage_update(pid):
837837
if 'triage_category' in data:
838838
old_category = old_row['triage_category'] or ''
839839
new_category = data['triage_category']
840+
valid_triage = {'immediate', 'delayed', 'minimal', 'expectant', 'unassigned', ''}
841+
if new_category not in valid_triage:
842+
return jsonify({'error': f'Invalid triage category. Must be one of: {", ".join(sorted(valid_triage - {""}))}'}), 400
840843
db.execute('UPDATE patients SET triage_category = ? WHERE id = ?', (new_category, pid))
841844
# Log to triage_history
842845
reason = data.get('reason', '')

web/blueprints/platform_security.py

Lines changed: 44 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import json
55
import logging
66
import hashlib
7+
import os
78
import secrets
89
from datetime import datetime, timedelta
910

@@ -38,14 +39,42 @@
3839

3940
# ─── Helpers ────────────────────────────────────────────────────────
4041

42+
def _hash_credential(value):
43+
"""Hash a PIN or password using PBKDF2-SHA256 with random salt."""
44+
salt = os.urandom(16)
45+
h = hashlib.pbkdf2_hmac('sha256', value.encode(), salt, 100_000)
46+
return f'pbkdf2${salt.hex()}${h.hex()}'
47+
48+
49+
def _verify_credential(value, stored_hash):
50+
"""Verify a PIN or password against a stored hash.
51+
Supports PBKDF2 (preferred) and legacy plain SHA-256."""
52+
if not value or not stored_hash:
53+
return False
54+
if stored_hash.startswith('pbkdf2$'):
55+
parts = stored_hash.split('$')
56+
if len(parts) != 3:
57+
return False
58+
salt = bytes.fromhex(parts[1])
59+
h = hashlib.pbkdf2_hmac('sha256', value.encode(), salt, 100_000)
60+
return h.hex() == parts[2]
61+
# Legacy SHA-256 fallback
62+
return hashlib.sha256(value.encode()).hexdigest() == stored_hash
63+
64+
65+
def _needs_rehash(stored_hash):
66+
"""Check if a hash uses the legacy format and needs upgrading."""
67+
return bool(stored_hash) and not stored_hash.startswith('pbkdf2$')
68+
69+
4170
def _hash_pin(pin):
42-
"""Hash a PIN using SHA-256."""
43-
return hashlib.sha256(pin.encode()).hexdigest()
71+
"""Hash a PIN using PBKDF2-SHA256."""
72+
return _hash_credential(pin)
4473

4574

4675
def _verify_pin(pin, pin_hash):
4776
"""Compare a plain PIN against a stored hash."""
48-
return _hash_pin(pin) == pin_hash
77+
return _verify_credential(pin, pin_hash)
4978

5079

5180
def _safe_user(row):
@@ -104,7 +133,7 @@ def users_create():
104133
pin = (data.get('pin') or '').strip()
105134
pin_hash = _hash_pin(pin) if pin else ''
106135
password = (data.get('password') or '').strip()
107-
password_hash = hashlib.sha256(password.encode()).hexdigest() if password else ''
136+
password_hash = _hash_credential(password) if password else ''
108137
permissions = json.dumps(data.get('permissions', []))
109138
settings = json.dumps(data.get('settings', {}))
110139
with db_session() as db:
@@ -173,7 +202,7 @@ def users_update(uid):
173202
password = (data['password'] or '').strip()
174203
if password:
175204
sets.append('password_hash = ?')
176-
params.append(hashlib.sha256(password.encode()).hexdigest())
205+
params.append(_hash_credential(password))
177206
if not sets:
178207
return jsonify({'error': 'no fields to update'}), 400
179208
sets.append("updated_at = datetime('now')")
@@ -259,7 +288,7 @@ def auth_login():
259288
if pin and user['pin_hash']:
260289
valid = _verify_pin(pin, user['pin_hash'])
261290
if not valid and password and user['password_hash']:
262-
valid = hashlib.sha256(password.encode()).hexdigest() == user['password_hash']
291+
valid = _verify_credential(password, user['password_hash'])
263292
if not valid:
264293
attempts = (user['failed_attempts'] or 0) + 1
265294
updates = {'failed_attempts': attempts}
@@ -280,6 +309,13 @@ def auth_login():
280309
log_activity('login_failed', service='platform_security',
281310
detail=f'Failed login for {username} (attempt {attempts})')
282311
return jsonify({'error': 'invalid credentials'}), 401
312+
# Transparently upgrade legacy SHA-256 hashes to PBKDF2
313+
if pin and user['pin_hash'] and _needs_rehash(user['pin_hash']):
314+
db.execute("UPDATE app_users SET pin_hash = ? WHERE id = ?",
315+
(_hash_credential(pin), user['id']))
316+
if password and user['password_hash'] and _needs_rehash(user['password_hash']):
317+
db.execute("UPDATE app_users SET password_hash = ? WHERE id = ?",
318+
(_hash_credential(password), user['id']))
283319
# Success — create session
284320
token = secrets.token_urlsafe(32)
285321
expires = (now + timedelta(hours=SESSION_TIMEOUT_HOURS)).strftime('%Y-%m-%dT%H:%M:%SZ')
@@ -385,9 +421,9 @@ def auth_change_password():
385421
'SELECT password_hash FROM app_users WHERE id = ?', (sess['user_id'],)
386422
).fetchone()
387423
if user and user['password_hash']:
388-
if hashlib.sha256(current.encode()).hexdigest() != user['password_hash']:
424+
if not _verify_credential(current, user['password_hash']):
389425
return jsonify({'error': 'current password is incorrect'}), 401
390-
new_hash = hashlib.sha256(new_password.encode()).hexdigest()
426+
new_hash = _hash_credential(new_password)
391427
r = db.execute(
392428
"UPDATE app_users SET password_hash = ?, updated_at = datetime('now') WHERE id = ?",
393429
(new_hash, sess['user_id'])

web/blueprints/tasks.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Tasks, timers, watch-schedules, and sun routes."""
22

3+
import calendar
34
import json
45
import math
56
import logging
@@ -189,7 +190,10 @@ def api_tasks_complete(task_id):
189190
elif rec == 'weekly':
190191
next_due = (base + timedelta(weeks=1)).strftime('%Y-%m-%d %H:%M:%S')
191192
elif rec == 'monthly':
192-
next_due = (base + timedelta(days=30)).strftime('%Y-%m-%d %H:%M:%S')
193+
y = base.year + (base.month // 12)
194+
m = (base.month % 12) + 1
195+
d = min(base.day, calendar.monthrange(y, m)[1])
196+
next_due = base.replace(year=y, month=m, day=d).strftime('%Y-%m-%d %H:%M:%S')
193197
else:
194198
next_due = None # one-time task stays completed
195199
db.execute('UPDATE scheduled_tasks SET completed_count = ?, last_completed = ?, next_due = ? WHERE id = ?',

0 commit comments

Comments
 (0)