# Command Sovereignty โ Production Hardening Implementation Plan
**Date:** 2026-07-13 ยท **Scope:** all 20 audit findings ยท **Stack:** Flask + SQLite + React SPA, systemd user service on port 5003 behind Cloudflare Tunnel.
Verified facts from recon (used throughout this plan):
- App factory: `app/__init__.py` (184 lines). Limiter at L28โ31 uses `memory://`. Session config L50โ53 (`SESSION_COOKIE_SECURE` already env-driven, **but systemd unit sets it to `false`**). No `MAX_CONTENT_LENGTH`, no security headers, no global JSON error handler (only 404/429 handlers at L104โ119).
- `require_csrf` decorator exists at `app/routes/api_proxy.py` L50โ75.
- IDOR: `app/routes/dashboard.py` โ `company_id = request.args.get('company_id', companies[0].id)` at L93, L116, L164, L185, L208 with **no membership check**; `/api/kpis/<company_id>` (L237โ242) and `/api/forecast/<company_id>` (L245โ254) take path param with **no check at all**.
- Systemd unit: `~/.config/systemd/user/command-sovereignty.service` โ runs `.venv/bin/python run.py` (Werkzeug dev server), plaintext `SECRET_KEY`, `CONNECTOR_ENCRYPTION_KEY`, `MAIL_PASSWORD`, `SMTP_PASSWORD` inline, `SESSION_COOKIE_SECURE=false`, `REDIS_URL` already set.
- `gunicorn==22.0.0` already in `requirements.txt` L11. Redis running locally.
- `UserSession` model exists (`app/models.py` L698โ714) and is *written* on login (`auth_api.py` L104โ127) and listed/deleted in `security_api.py` L365โ459 โ but deletion does **not** revoke the actual Flask session cookie.
- Scheduler: `app/scheduler.py` (406 lines) โ `threading.Timer`-based, in-process.
- Twilio: `app/routes/sms_webhooks.py` L85โ109 โ `connector.settings.get('verify_webhook_signature', True)` lets a tenant disable verification (fails open at L92โ93).
- Logo upload: `app/routes/enterprise.py` L641โ692 โ accepts `image/svg+xml`, saves raw bytes, trusts client `Content-Type`, no size/dimension checks โ stored XSS.
- Auth: `app/routes/auth_api.py` โ signup L172+ (only `len >= 8`), change-password L419+. **No forgot/reset routes exist.**
- Password hashing: werkzeug `generate_password_hash` (default scrypt on 3.12 โ fine).
- Billing/webhooks: `app/routes/billing.py` โ trial-abuse guard exists at L140โ142 (checkout side) but webhook processing `_process_stripe_event` (L267+) syncs tier from event metadata with no out-of-order/downgrade protection.
- `.all()` counts per routes file: api_proxy 37, analytics 32, admin_api 18, dashboard 11, sms 7, multi_market 6, email_campaigns 5, business_api 5, security_api 4, auth_api 4 (โ139 total).
- `docker-compose.yml` exists at repo root (dead config).
- Note: two venvs exist (`venv/` py3.13 and `.venv/` py3.12). Systemd uses `.venv` โ **all installs below go into `.venv`**.
Global rollback primitive (applies to every phase): the repo is git-managed โ tag before each phase:
```bash
cd /home/vincent/projects/command-sovereignty
git tag pre-phase-N && git push --tags # rollback: git checkout pre-phase-N -- <files> && systemctl --user restart command-sovereignty
```
DB rollback primitive: `python seed.py --backup-only` before any migration (already hardened; creates `instance/auth.db.backup.TIMESTAMP`).
---
# PHASE 1 โ LAUNCH BLOCKERS (est. 1.5โ2 days)
| # | Task | Est. | Depends on |
|---|------|------|-----------|
| 1.1 | Tenancy decorator + IDOR fix | 3โ4 h | โ |
| 1.2 | Gunicorn behind systemd | 1โ2 h | โ |
| 1.3 | Password reset flow | 4โ6 h | mail config (exists); migration |
| 1.4 | WAL mode + automated backups | 1โ2 h | โ |
## 1.1 Cross-tenant IDOR (Finding #1) โ CRITICAL
**New file:** `app/utils/tenancy.py`
```python
"""Company membership enforcement."""
from functools import wraps
from flask import request, jsonify, abort
from flask_login import current_user
from ..models import UserCompany
def user_in_company(user_id, company_id) -> bool:
if not company_id:
return False
return UserCompany.query.filter_by(
user_id=user_id, company_id=str(company_id)
).first() is not None
def resolve_company_id(default=None):
"""company_id from args/json/view_args, validated against membership.
Returns validated id or aborts 403/404."""
cid = (request.view_args or {}).get('company_id') \
or request.args.get('company_id') \
or (request.get_json(silent=True) or {}).get('company_id') \
or default
if cid is None:
abort(404)
if not user_in_company(current_user.id, cid):
abort(403, description='Not a member of this company')
return str(cid)
def require_company_access(f):
"""Decorator for routes with <company_id> path param or ?company_id."""
@wraps(f)
def wrapper(*args, **kwargs):
cid = kwargs.get('company_id') or request.args.get('company_id')
if cid and not user_in_company(current_user.id, cid):
return jsonify({'error': 'Forbidden'}), 403
return f(*args, **kwargs)
return wrapper
```
**Changes in `app/routes/dashboard.py`:**
- L93, L116, L164, L185, L208 โ replace:
```python
company_id = request.args.get('company_id', companies[0].id)
```
with:
```python
company_id = request.args.get('company_id', companies[0].id)
if not user_in_company(current_user.id, company_id):
flash('Access denied', 'error'); return redirect(url_for('dashboard.index'))
```
(or use `resolve_company_id(default=companies[0].id)`).
- L237โ242 and L245โ254 โ add `@require_company_access` under `@login_required`.
**Sweep the rest of the app** (this is the audit finding's tip of the iceberg):
```bash
grep -rn "request.args.get('company_id'\|request.args.get(\"company_id\"\|<company_id>" app/routes/ | grep -v tenancy
```
Apply `@require_company_access` / `resolve_company_id` to every hit in `api_proxy.py`, `analytics.py`, `business_api.py`, `multi_market.py`, `coaching.py`, `email_campaigns.py`, `sms.py`, `portfolios.py`. Exempt: super-admin routes already gated by `require_super_admin()` (admin_api.py).
**Testing:**
```bash
# Two users in different companies (seed data). Login as user A, request user B's company:
curl -b cookies_a.txt "http://localhost:5003/dashboard/api/kpis/<companyB_id>" # expect 403
curl -b cookies_a.txt "http://localhost:5003/dashboard/forecast?company_id=<companyB_id>" # expect redirect/403
curl -b cookies_a.txt "http://localhost:5003/dashboard/api/kpis/<companyA_id>" # expect 200
```
Add pytest `tests/test_tenancy.py` with two seeded users asserting 403/200 matrix.
**Rollback:** revert dashboard.py + delete tenancy.py; no DB change.
## 1.2 Gunicorn (Finding #2) โ CRITICAL
**New file:** `gunicorn.conf.py` (repo root)
```python
bind = "127.0.0.1:5003" # Cloudflare Tunnel connects locally; no 0.0.0.0
workers = 2 # SQLite: keep low to limit write contention
threads = 4
worker_class = "gthread"
timeout = 60
graceful_timeout = 30
keepalive = 5
accesslog = "logs/gunicorn-access.log"
errorlog = "logs/gunicorn-error.log"
loglevel = "info"
preload_app = False # IMPORTANT: scheduler must not fork-duplicate; see 3.5
```
**New file:** `wsgi.py` (repo root)
```python
from dotenv import load_dotenv
import os
load_dotenv(os.path.join(os.path.dirname(__file__), '.env'))
from app import create_app
app = create_app()
```
**Edit systemd unit** `~/.config/systemd/user/command-sovereignty.service`:
```ini
ExecStart=/home/vincent/projects/command-sovereignty/.venv/bin/gunicorn -c gunicorn.conf.py wsgi:app
ExecReload=/bin/kill -HUP $MAINPID
```
(secrets lines are removed in 2.3 โ can be batched together).
**Dependency note:** with `workers=2` the Threading.Timer scheduler would run **twice**. Until 3.5 lands, gate scheduler startup: only start when `os.environ.get('RUN_SCHEDULER') == '1'`, and set that on exactly one worker โ or simpler, keep `workers=1, threads=8` until Phase 3.5, then bump to 2. **Recommended: workers=1/threads=8 now, revisit in 3.5.**
**Deploy/test:**
```bash
cd /home/vincent/projects/command-sovereignty && .venv/bin/pip show gunicorn # already pinned 22.0.0
systemctl --user daemon-reload && systemctl --user restart command-sovereignty
curl -s http://localhost:5003/health # {"status":"healthy",...}
ps -ef | grep gunicorn # master + worker visible
```
**Rollback:** restore `ExecStart=... python run.py`, daemon-reload, restart.
## 1.3 Password reset flow (Finding #3) โ CRITICAL
**Migration** โ new table. New file `scripts/migrate_password_reset.py` (follow pattern of `scripts/migrate_company_flags.py`):
```python
CREATE TABLE IF NOT EXISTS password_reset_tokens (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL, -- sha256 of token; never store raw
created_at DATETIME NOT NULL,
expires_at DATETIME NOT NULL,
used_at DATETIME
);
CREATE INDEX IF NOT EXISTS idx_prt_user ON password_reset_tokens(user_id);
CREATE INDEX IF NOT EXISTS idx_prt_hash ON password_reset_tokens(token_hash);
```
Run: `python seed.py --backup-only && .venv/bin/python scripts/migrate_password_reset.py`
**Model** โ append to `app/models.py` (after UserSession, ~L715):
```python
class PasswordResetToken(db.Model):
__tablename__ = 'password_reset_tokens'
id = db.Column(db.String(36), primary_key=True, default=gen_uuid)
user_id = db.Column(db.String(36), db.ForeignKey('profiles.id', ondelete='CASCADE'), nullable=False, index=True)
token_hash = db.Column(db.String(64), nullable=False, index=True)
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
expires_at = db.Column(db.DateTime, nullable=False)
used_at = db.Column(db.DateTime, nullable=True)
```
**Routes** โ add to `app/routes/auth_api.py` (uses existing `mail` ext + limiter):
```python
@auth_api_bp.route('/api/auth/forgot-password', methods=['POST'])
@limiter.limit('5 per hour')
def api_forgot_password():
email = (request.get_json(silent=True) or {}).get('email', '').strip().lower()
generic = jsonify({'message': 'If that email exists, a reset link has been sent.'})
user = User.query.filter_by(email=email).first()
if user:
raw = secrets.token_urlsafe(32)
db.session.add(PasswordResetToken(
user_id=user.id,
token_hash=hashlib.sha256(raw.encode()).hexdigest(),
expires_at=datetime.now(timezone.utc) + timedelta(hours=1)))
db.session.commit()
send_email(user.email, 'Reset your password',
f'https://app.commandsovereignty.com/reset-password?token={raw}\nLink expires in 1 hour.')
return generic, 200 # same response either way โ no user enumeration
@auth_api_bp.route('/api/auth/reset-password', methods=['POST'])
@limiter.limit('10 per hour')
def api_reset_password():
data = request.get_json(silent=True) or {}
raw, new_pw = data.get('token', ''), data.get('password', '')
err = validate_password_strength(new_pw) # from 3.1; until then: len>=8 check
if err: return jsonify({'error': err}), 400
h = hashlib.sha256(raw.encode()).hexdigest()
rec = PasswordResetToken.query.filter_by(token_hash=h, used_at=None).first()
if not rec or rec.expires_at.replace(tzinfo=timezone.utc) < datetime.now(timezone.utc):
return jsonify({'error': 'Invalid or expired reset link'}), 400
user = db.session.get(User, rec.user_id)
user.set_password(new_pw)
rec.used_at = datetime.now(timezone.utc)
UserSession.query.filter_by(user_id=user.id).delete() # revoke all sessions
db.session.commit()
return jsonify({'message': 'Password updated. Please log in.'})
```
Use existing `app/utils/mail.py` / `email_service.py` helper for `send_email` (check exact function name there). Frontend: add `/forgot-password` + `/reset-password` SPA pages (new routes โ no breaking change).
**Testing:**
```bash
curl -X POST localhost:5003/api/auth/forgot-password -H 'Content-Type: application/json' -d '{"email":"seeduser@example.com"}'
sqlite3 instance/auth.db "SELECT * FROM password_reset_tokens;" # token row exists, hash not raw
# take raw token from mail log / test hook, then:
curl -X POST localhost:5003/api/auth/reset-password -d '{"token":"...","password":"N3w-Passw0rd!"}' -H 'Content-Type: application/json'
# verify: old password fails login, new works, reusing token โ 400, expired token โ 400
```
**Rollback:** routes are additive; drop table if needed. No existing endpoint touched.
## 1.4 WAL mode + automated backups (Finding #4) โ CRITICAL
**Edit `app/__init__.py` L20โ25** โ extend pragma listener:
```python
@event.listens_for(Engine, "connect")
def set_sqlite_pragma(dbapi_connection, connection_record):
cursor = dbapi_connection.cursor()
cursor.execute("PRAGMA foreign_keys=ON")
cursor.execute("PRAGMA journal_mode=WAL")
cursor.execute("PRAGMA synchronous=NORMAL")
cursor.execute("PRAGMA busy_timeout=5000")
cursor.close()
```
(WAL is persistent once set, but setting on connect is idempotent and safe. `busy_timeout` also mitigates gunicorn-thread write contention.)
**New file:** `scripts/backup_db.sh`
```bash
#!/usr/bin/env bash
set -euo pipefail
DB=/home/vincent/projects/command-sovereignty/instance/auth.db
DEST=/home/vincent/backups/command-sovereignty
mkdir -p "$DEST"
STAMP=$(date +%Y%m%d_%H%M%S)
OUT="$DEST/auth.db.$STAMP"
sqlite3 "$DB" ".backup '$OUT'" # online backup API โ WAL-safe
sqlite3 "$OUT" "PRAGMA integrity_check;" | grep -q '^ok$' || { echo "BACKUP CORRUPT"; rm -f "$OUT"; exit 1; }
gzip "$OUT"
find "$DEST" -name 'auth.db.*.gz' -mtime +14 -delete # 14-day retention
echo "OK $OUT.gz"
```
**Cron:** `crontab -e` โ
```
17 */6 * * * /home/vincent/projects/command-sovereignty/scripts/backup_db.sh >> /home/vincent/backups/command-sovereignty/backup.log 2>&1
```
(Optionally rsync `$DEST` off-box โ single-server constraint noted, but at least copy to a second disk/remote if available.)
**Testing:**
```bash
chmod +x scripts/backup_db.sh && ./scripts/backup_db.sh && ls ~/backups/command-sovereignty/
sqlite3 instance/auth.db "PRAGMA journal_mode;" # โ wal (after service restart)
# restore drill:
gunzip -k ~/backups/command-sovereignty/auth.db.<stamp>.gz
sqlite3 ~/backups/command-sovereignty/auth.db.<stamp> "SELECT COUNT(*) FROM profiles;"
```
**Rollback:** `PRAGMA journal_mode=DELETE` to revert; remove cron line.
---
# PHASE 2 โ SECURITY HARDENING (est. 2โ2.5 days)
| # | Task | Est. | Depends |
|---|------|------|---------|
| 2.1 | CSRF coverage sweep | 3โ4 h | โ |
| 2.2 | Session security + revocation | 3 h | 1.2 (restart cadence) |
| 2.3 | Secrets externalization | 1 h | 1.2 (unit edit batched) |
| 2.4 | Security headers | 1 h | โ |
| 2.5 | Rate limiter โ Redis | 30 min | Redis (running) |
| 2.6 | SVG sanitization | 2 h | 2.7 |
| 2.7 | MAX_CONTENT_LENGTH | 15 min | โ |
## 2.1 CSRF coverage (Finding #5)
Move `require_csrf` from `api_proxy.py` L50โ75 into a shared module to avoid circular imports:
- **New:** `app/utils/csrf.py` (copy decorator verbatim).
- `api_proxy.py`: `from ..utils.csrf import require_csrf` (keep name re-exported for back-compat).
**Audit command:**
```bash
grep -rn "methods=\[.*\(POST\|PUT\|DELETE\|PATCH\)" app/routes/ -A3 | grep -B2 -L require_csrf
# more precise: python one-off that walks app.url_map at startup
```
Better โ add a **startup assertion** in `create_app()` (dev only):
```python
if app.debug or os.environ.get('CSRF_AUDIT'):
from .utils.csrf import CSRF_EXEMPT
for rule in app.url_map.iter_rules():
if rule.methods & {'POST','PUT','DELETE','PATCH'}:
fn = app.view_functions[rule.endpoint]
if not getattr(fn, '_csrf_protected', False) and rule.endpoint not in CSRF_EXEMPT:
app.logger.warning(f'CSRF GAP: {rule.rule} [{rule.endpoint}]')
```
(mark decorator: set `decorated._csrf_protected = True` inside `require_csrf`.)
Apply `@require_csrf` to all authenticated state-changing routes in: `auth_api.py` (update-user, change-password, logout, invite, settings), `business_api.py`, `connectors.py`, `analytics.py`, `multi_market.py`, `coaching.py`, `billing.py` (checkout/portal โ **NOT** `/stripe/webhook`), `portfolios.py`, `email_campaigns.py`, `sms.py`, `sms_call.py`, `enterprise.py` (incl. upload-logo), `admin_api.py`, `security_api.py`.
**Exempt list (`CSRF_EXEMPT`):** login, signup, forgot/reset-password, demo-request, `/stripe/webhook`, `/webhooks/sms/*` (Twilio), SAML ACS/SLO.
**Test:** authenticated POST without header โ 403; with token from `/api/csrf-token` โ 200. Run SPA smoke test (frontend already sends `X-CSRF-Token` via Axios interceptor โ no frontend change).
**Rollback:** decorators are additive; remove per-route if a legit client breaks.
## 2.2 Session security + revocation (Finding #6)
**Config (`app/__init__.py` L50โ53):**
```python
app.config['SESSION_COOKIE_SECURE'] = os.environ.get('SESSION_COOKIE_SECURE', 'true').lower() in ('true','1','yes')
app.config['PERMANENT_SESSION_LIFETIME'] = 86400 * 7 # 30d โ 7d
app.config['SESSION_REFRESH_EACH_REQUEST'] = True # sliding window
```
**Systemd unit:** change `SESSION_COOKIE_SECURE=false` โ delete the line (default true). Cloudflare Tunnel serves HTTPS, so secure cookies work; local curl tests must use `-k https` via tunnel or set the env var in a dev shell only.
**Revocation** โ the gap: deleting a `UserSession` row doesn't kill the cookie. Fix by binding Flask session to the row:
`auth_api.py` login (~L104โ127): after creating `new_session`, store its id in the cookie session:
```python
flask_session['us_id'] = new_session.id
```
**New `before_request` in `create_app()`:**
```python
@app.before_request
def enforce_session_revocation():
from flask import session as fs
from flask_login import current_user, logout_user
if current_user.is_authenticated:
us_id = fs.get('us_id')
if us_id:
from .models import UserSession
rec = db.session.get(UserSession, us_id)
if rec is None: # revoked from security page
logout_user(); fs.clear()
if request.path.startswith('/api/'):
return jsonify({'error': 'Session revoked'}), 401
```
Back-compat: sessions without `us_id` (issued pre-deploy) stay valid until natural expiry โ acceptable, or force logout-all on deploy.
Perf note: one PK lookup per request on SQLite โ microseconds; acceptable.
`security_api.py` L391โ441 already deletes rows โ now actually revokes. Also update `last_seen_at` opportunistically (throttle to 1/min per session to avoid write churn).
**Test:** login in browser A; from browser B (same account) revoke A's session via security page; next request in A โ 401/redirect to login. Verify cookie has `Secure; HttpOnly; SameSite=Lax` in devtools over the tunnel domain.
## 2.3 Secrets externalization (Finding #7)
**New file:** `/home/vincent/projects/command-sovereignty/.env.production` โ `chmod 600`, owner vincent:
```
SECRET_KEY=<ROTATE โ new 64-hex value; old one is burned, it sat in the unit file>
CONNECTOR_ENCRYPTION_KEY=eF50... # CANNOT rotate blindly โ Fernet-encrypted connector creds
MAIL_PASSWORD=...
SMTP_PASSWORD=...
DATABASE_URL=sqlite:////home/vincent/projects/command-sovereignty/instance/auth.db
REDIS_URL=redis://localhost:6379/0
... (all current Environment= lines)
```
**Systemd unit:** delete every `Environment=` secret line; add:
```ini
EnvironmentFile=/home/vincent/projects/command-sovereignty/.env.production
```
Ensure `.env.production` is in `.gitignore`. To rotate `CONNECTOR_ENCRYPTION_KEY` later use `app/utils/migrate_encryption.py` (exists) with old+new keys โ do NOT just swap it or all connector creds become undecryptable.
**Rotating SECRET_KEY invalidates all sessions/CSRF** โ deploy together with 2.2 and announce forced re-login.
**Test:** `systemctl --user daemon-reload && restart`, `/health` OK, login works, `systemctl --user show command-sovereignty -p Environment` shows no secrets.
**Rollback:** restore old unit from git/backup copy.
## 2.4 Security headers (Finding #8)
Add to `create_app()` (after error handlers):
```python
@app.after_request
def security_headers(resp):
resp.headers.setdefault('X-Content-Type-Options', 'nosniff')
resp.headers.setdefault('X-Frame-Options', 'DENY')
resp.headers.setdefault('Referrer-Policy', 'strict-origin-when-cross-origin')
resp.headers.setdefault('Permissions-Policy', 'camera=(), microphone=(), geolocation=()')
resp.headers.setdefault('Strict-Transport-Security', 'max-age=31536000; includeSubDomains')
resp.headers.setdefault('Content-Security-Policy',
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; "
"img-src 'self' data: blob:; font-src 'self' data:; "
"connect-src 'self' https://api.stripe.com; frame-src https://js.stripe.com https://checkout.stripe.com; "
"object-src 'none'; base-uri 'self'; frame-ancestors 'none'")
if request.path.startswith('/uploads/'):
resp.headers['Content-Security-Policy'] = "default-src 'none'; style-src 'unsafe-inline'; sandbox"
resp.headers['Content-Disposition'] = resp.headers.get('Content-Disposition', 'inline')
return resp
```
**CSP tuning required:** Vite SPA may need `'unsafe-inline'` for styles (kept) and Stripe.js sources โ start with `Content-Security-Policy-Report-Only` for 24 h, watch console, then enforce. The `/uploads/` sandbox CSP is the second layer of the SVG-XSS fix (2.6).
**Test:** `curl -sI localhost:5003/ | grep -Ei 'content-security|strict-trans|x-frame'`; click through the SPA with devtools console open (no CSP violations).
## 2.5 Rate limiter โ Redis (Finding #9)
`app/__init__.py` L28โ31:
```python
limiter = Limiter(
key_func=get_remote_address,
storage_uri=os.environ.get('REDIS_URL', 'memory://'), # REDIS_URL already in unit
storage_options={'socket_connect_timeout': 2},
)
```
Add `redis` to `requirements.txt` if missing; `.venv/bin/pip install redis limits[redis]`.
Also: behind Cloudflare Tunnel `get_remote_address` sees the tunnel IP. Use CF header:
```python
def real_ip():
return request.headers.get('CF-Connecting-IP') or get_remote_address()
limiter = Limiter(key_func=real_ip, ...)
```
**Test:** hammer login: `for i in $(seq 12); do curl -s -o /dev/null -w '%{http_code}\n' -X POST localhost:5003/api/auth/login -d '{}' -H 'Content-Type: application/json'; done` โ 429 appears; `redis-cli keys 'LIMITS*'` shows entries; restart service and confirm counters survive.
**Rollback:** revert storage_uri to `memory://`.
## 2.6 SVG upload sanitization (Finding #10)
`app/routes/enterprise.py` L641โ692 (`upload_logo`). Fixes:
1. Don't trust client `Content-Type`; sniff bytes.
2. For raster: validate with Pillow, re-encode (strips payloads), cap dimensions.
3. For SVG: **simplest safe option โ drop SVG support** (PNG/JPEG/WebP only). If SVG must stay, sanitize:
```python
# pip install Pillow defusedxml
ALLOWED_EXT = {'.png', '.jpg', '.jpeg', '.webp', '.svg'}
MAX_DIM = 2000
def sanitize_svg(data: bytes) -> bytes:
from defusedxml import ElementTree as DET
import xml.etree.ElementTree as ET
root = DET.fromstring(data) # rejects DTD/entities (XXE/billion-laughs)
BAD_TAGS = {'script', 'foreignObject', 'animate', 'set', 'iframe', 'embed', 'object'}
for parent in root.iter():
for child in list(parent):
tag = child.tag.split('}')[-1].lower()
if tag in BAD_TAGS:
parent.remove(child); continue
for attr in list(child.attrib):
a = attr.split('}')[-1].lower()
if a.startswith('on') or (a in ('href','xlink:href') and
child.attrib[attr].strip().lower().startswith(('javascript:','data:'))):
del child.attrib[attr]
# also scrub root element attribs the same way
return ET.tostring(root)
```
In the route, replace the naive save block (L658โ672):
```python
ext = os.path.splitext(file.filename)[1].lower()
if ext not in ALLOWED_EXT: return jsonify({'error': 'Invalid file type'}), 400
data = file.read()
if len(data) > 2 * 1024 * 1024: return jsonify({'error': 'Logo must be under 2MB'}), 400
if ext == '.svg':
if b'<svg' not in data[:4096].lower(): return jsonify({'error': 'Not a valid SVG'}), 400
try: data = sanitize_svg(data)
except Exception: return jsonify({'error': 'Could not parse SVG'}), 400
else:
from PIL import Image
import io
try:
img = Image.open(io.BytesIO(data)); img.verify()
img = Image.open(io.BytesIO(data))
if max(img.size) > MAX_DIM: return jsonify({'error': f'Max {MAX_DIM}px'}), 400
buf = io.BytesIO(); img.save(buf, format=img.format); data = buf.getvalue() # re-encode
except Exception: return jsonify({'error': 'Invalid image'}), 400
filename = f'{uuid.uuid4().hex}{ext}'
...
with open(filepath, 'wb') as fh: fh.write(data)
```
Also add `@require_csrf` (2.1) and serve `/uploads/` with sandbox CSP (2.4).
`.venv/bin/pip install Pillow defusedxml` + pin in requirements.txt.
**Test:** upload `<svg onload="alert(1)"><script>alert(1)</script></svg>` โ stored file has no script/onload; fetch it, confirm `Content-Security-Policy: sandbox` header; upload 50MB file โ 413 (2.7); upload PNG-with-.svg-name โ 400.
## 2.7 MAX_CONTENT_LENGTH (Finding #11)
`app/__init__.py` after L53:
```python
app.config['MAX_CONTENT_LENGTH'] = 10 * 1024 * 1024 # 10MB global cap
@app.errorhandler(413)
def too_large(e):
return jsonify({'error': 'Request too large (max 10MB)'}), 413
```
**Test:** `head -c 11M /dev/zero > /tmp/big && curl -F file=@/tmp/big -b cookies.txt localhost:5003/enterprise/branding/upload-logo` โ 413 JSON.
---
# PHASE 3 โ RELIABILITY (est. 3โ4 days)
| # | Task | Est. | Depends |
|---|------|------|---------|
| 3.1 | Password policy + lockout | 3โ4 h | migration |
| 3.2 | Datetime consistency | 2โ3 h | โ |
| 3.3 | Pagination | 1โ1.5 d (139 sites; triage) | โ |
| 3.4 | Webhook trial/tier races | 3 h | โ |
| 3.5 | APScheduler | 3โ4 h | 1.2 |
| 3.6 | Twilio verification mandatory | 30 min | โ |
| 3.7 | Invite fix + email send | 2โ3 h | mail helper |
## 3.1 Password policy + account lockout (Finding #12)
**New:** `app/utils/passwords.py`
```python
import hashlib, re, requests
def validate_password_strength(pw: str) -> str | None:
if len(pw) < 12: return 'Password must be at least 12 characters'
if len(pw) > 128: return 'Password too long'
checks = [r'[a-z]', r'[A-Z]', r'\d']
if sum(bool(re.search(c, pw)) for c in checks) < 3:
return 'Password must include upper, lower, and a digit'
if is_breached(pw): return 'This password appears in known breaches; choose another'
return None
def is_breached(pw: str) -> bool:
"""HIBP k-anonymity; fail-open on network error."""
sha = hashlib.sha1(pw.encode()).hexdigest().upper()
try:
r = requests.get(f'https://api.pwnedpasswords.com/range/{sha[:5]}', timeout=2)
return any(line.split(':')[0] == sha[5:] for line in r.text.splitlines())
except requests.RequestException:
return False
```
Wire into `auth_api.py` signup (L196โ198), change-password (L431), reset-password (1.3). **Back-compat:** enforce only on new/changed passwords; existing users unaffected at login.
**Lockout** โ migration adds columns to `profiles`:
```sql
ALTER TABLE profiles ADD COLUMN failed_login_count INTEGER DEFAULT 0;
ALTER TABLE profiles ADD COLUMN locked_until DATETIME;
```
`api_auth_login` (L92โ130):
```python
if user and user.locked_until and as_utc(user.locked_until) > now_utc():
return jsonify({'error': 'Account temporarily locked. Try again later.'}), 423
# on bad password:
user.failed_login_count += 1
if user.failed_login_count >= 8:
user.locked_until = now_utc() + timedelta(minutes=15)
user.failed_login_count = 0
db.session.commit()
# on success: user.failed_login_count = 0; user.locked_until = None
```
Keep `@limiter.limit` on login as the first layer (per-IP); lockout is per-account.
**Test:** 8 bad logins โ 423 even with correct password; wait/clear `locked_until` in sqlite3 โ login OK. Signup with `password123` โ 400 (breached).
## 3.2 Datetime consistency (Finding #13)
Root cause: SQLite `DateTime` columns come back **naive** even when stored from aware `datetime.now(timezone.utc)`; comparisons against aware datetimes raise `TypeError` (breaks invite expiry).
**New helpers** in `app/utils/time.py`:
```python
from datetime import datetime, timezone
def now_utc(): return datetime.now(timezone.utc)
def as_utc(dt):
if dt is None: return None
return dt.replace(tzinfo=timezone.utc) if dt.tzinfo is None else dt.astimezone(timezone.utc)
```
**Sweep:**
```bash
grep -rn "datetime.utcnow\|datetime.now()" app/ --include='*.py' # replace with now_utc()
grep -rn "expires_at [<>]\|< datetime\|> datetime" app/ --include='*.py' # wrap column side in as_utc()
```
Priority fix sites: invite accept flow (`auth_api.py` L348+), trial checks (`billing.py`), token expiry (1.3 code already uses the pattern), session expiry (`security_api.py`), scheduler.
**Test:** unit test comparing a stored-then-loaded `expires_at` against `now_utc()` โ must not raise; invite created โ accepted after reload works.
## 3.3 Pagination (Finding #14) โ triage, don't boil the ocean
139 `.all()` sites; most are already bounded by `company_id` filter + small tables. **Paginate only unbounded/user-facing list endpoints:**
Priority targets (grep `\.all()` per file):
- `admin_api.py` (18): super-admin lists over ALL companies/users โ highest risk.
- `analytics.py` (32): raw KPI/event rows over time ranges.
- `api_proxy.py` (37): activity logs, audit logs, notifications lists.
**Standard pattern (backward compatible โ same shape when params absent is NOT possible if we wrap; so keep response shape, add envelope only when `page` param present):**
```python
def paginated(query, default_per_page=50, max_per_page=200):
page = request.args.get('page', type=int)
per_page = min(request.args.get('per_page', default_per_page, type=int), max_per_page)
if page is None:
return query.limit(1000).all(), None # legacy path, hard cap 1000
p = query.paginate(page=page, per_page=per_page, error_out=False)
return p.items, {'page': p.page, 'pages': p.pages, 'total': p.total, 'per_page': per_page}
```
Legacy callers (SPA today) get identical arrays capped at 1000; SPA can adopt `?page=` incrementally. Frontend unaffected โ constraint satisfied.
**Test:** `curl 'localhost:5003/api/activity?page=1&per_page=10'` returns envelope; without params returns plain array (existing SPA tests pass); seed 2000 rows โ legacy response is 1000, no OOM.
## 3.4 Stripe webhook races (Finding #15)
`app/routes/billing.py` `_process_stripe_event` (L267+). Three fixes:
1. **Trial abuse (webhook side):** on `checkout.session.completed`/`customer.subscription.created`, record permanently:
```sql
ALTER TABLE companies ADD COLUMN has_had_subscription BOOLEAN DEFAULT 0; -- migration
```
Set `company.has_had_subscription = True` on first subscription event; change L140โ142 checkout guard to check this flag (survives subscription deletion, unlike querying live subs).
2. **Out-of-order events:** Stripe doesn't guarantee ordering. Store `event.created` timestamp:
```sql
ALTER TABLE companies ADD COLUMN tier_synced_at DATETIME; -- migration (or on subscription table)
```
```python
evt_ts = datetime.fromtimestamp(event['created'], tz=timezone.utc)
if company.tier_synced_at and as_utc(company.tier_synced_at) >= evt_ts:
return # stale event; idempotency table already dedupes exact retries
... apply tier ...
company.tier_synced_at = evt_ts
```
3. **Downgrade authority:** derive tier from `subscription.status` + price/plan id fetched fresh via `stripe.Subscription.retrieve(sub_id)` rather than trusting event metadata; on `customer.subscription.deleted` โ downgrade to `'launch'` (DEFAULT_PLAN in config.py L31).
**Test:** `stripe listen --forward-to localhost:5003/api/billing/stripe/webhook`; `stripe trigger customer.subscription.updated` then replay an older event fixture โ tier unchanged; cancel sub in test mode โ company tier drops to launch; re-checkout โ `trial_period_days=0`.
## 3.5 Scheduler โ APScheduler (Finding #16)
Keep single-process constraint, remove Threading.Timer fragility. `.venv/bin/pip install APScheduler` (+pin).
**Rewrite `app/scheduler.py`** core (keep public API `start_scheduler(app)` / job funcs so callers don't change):
```python
from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.triggers.interval import IntervalTrigger
_sched = None
def start_scheduler(app):
global _sched
if _sched: return _sched
if os.environ.get('RUN_SCHEDULER', '1') != '1': return None
_sched = BackgroundScheduler(daemon=True, job_defaults={
'coalesce': True, 'max_instances': 1, 'misfire_grace_time': 300})
_sched.add_job(lambda: _sync_all(app), IntervalTrigger(minutes=5), id='sync_realtime')
_sched.add_job(lambda: _refresh_tokens(app), IntervalTrigger(minutes=15), id='token_refresh')
_sched.start()
return _sched
```
Each job wraps work in `with app.app_context():` and try/except with logging (Timer chains die silently on exception โ APScheduler doesn't).
**Gunicorn interplay (from 1.2):** stay at `workers=1` and start scheduler in-app, **or** (cleaner) move to a separate systemd unit `command-sovereignty-scheduler.service` running `.venv/bin/python -m app.scheduler_main` with `RUN_SCHEDULER=1`, and set web unit `RUN_SCHEDULER=0` โ then bump gunicorn to `workers=2`. **Recommended: separate unit.**
**Test:** start service, `grep -i 'scheduler\|sync' logs/app.log` shows jobs firing at expected cadence; kill worker mid-job โ job resumes on next tick; raise an exception in a job โ next run still fires.
**Rollback:** old scheduler.py from git; both expose `start_scheduler(app)`.
## 3.6 Twilio verification mandatory (Finding #17)
`app/routes/sms_webhooks.py` L85โ93 โ delete the opt-out:
```python
def verify_twilio_signature(connector, sms_conn) -> bool:
# Signature verification is ALWAYS enforced (removed per-connector opt-out, audit #17)
signature = request.headers.get('X-Twilio-Signature', '')
if not signature:
logger.warning('Missing X-Twilio-Signature header')
return False
...
```
Also verify the provider's `verify_webhook_signature` fails closed (returns False on missing auth token) โ check `app/connectors/sms.py`; if auth token absent, return False, never True. Remove/ignore the `verify_webhook_signature` key from connector settings UI (leave key harmless in stored settings โ back-compat).
**Test:** `curl -X POST localhost:5003/webhooks/sms/<connector_id> -d 'Body=hi'` (no header) โ 401; replay a captured valid Twilio request โ 200; connector with `verify_webhook_signature: false` in settings โ still 401 without valid signature.
## 3.7 Invite system (Finding #18)
Locate bug: `app/routes/auth_api.py` `api_auth_invite` (L275+) / accept (L348+).
```bash
grep -n "user_id" app/routes/auth_api.py | sed -n '1,40p'
```
Fix pattern (typical form of this bug โ invite row created with `user_id=None` and never linked, plus lookup by wrong field on accept):
1. On invite create: `user_id` legitimately None (invitee has no account) โ but ensure `email`, `company_id`, `role`, `token`, `expires_at` are set and **status filter on accept matches** (`status='pending'`, token match, `as_utc(expires_at) > now_utc()` โ depends on 3.2).
2. On accept: create/find user by invite email, create `UserCompany(user_id=user.id, company_id=invite.company_id, role=invite.role)`, set `invite.user_id = user.id`, `invite.status='accepted'`.
3. **Actually send the email** (currently missing):
```python
from ..utils.mail import send_email # confirm helper name in app/utils/mail.py
send_email(invite_email, f'You are invited to {company.name} on Command Sovereignty',
f'Accept your invite: https://app.commandsovereignty.com/accept-invite?token={raw_token}\n'
f'This link expires in 7 days.')
```
Wrap send in try/except โ on failure return 502 with 'Invite created but email failed' + keep invite row so it can be resent; add `POST /api/auth/invite/<id>/resend` (with `@require_csrf`).
**Test:** invite fresh email โ row has correct company/email, mail log shows send; accept with token โ user created, `UserCompany` row exists, `invite.user_id` populated, invite unusable twice; expired invite โ 400.
---
# PHASE 4 โ CLEANUP (est. 2โ3 h)
## 4.1 docker-compose.yml (Finding #19)
Deployment is systemd; the compose file exposes a Postgres with default creds and misleads operators.
```bash
git rm docker-compose.yml && git commit -m 'Remove dead docker-compose (deployment is systemd, audit #19)'
```
Also delete/ignore stale root-level cruft spotted during recon if unused: `app.py`, `fix_duplicate.py`, `integrate_security.py`, `security_routes_addition.py`, `auth_cookies.txt` (**contains cookies โ delete and git-filter if ever committed**). Verify each with `git log --oneline -1 -- <file>` + grep for imports before removal.
**Test:** `systemctl --user restart command-sovereignty && curl localhost:5003/health`.
## 4.2 Global JSON error handler (Finding #20)
`app/__init__.py` โ replace/extend handlers at L104โ119:
```python
from werkzeug.exceptions import HTTPException
@app.errorhandler(HTTPException)
def handle_http_error(e):
if not request.path.startswith('/api/') and e.code == 404:
return send_from_directory(SPA_DIR, 'index.html') # keep SPA fallback
return jsonify({'error': e.description or e.name, 'code': e.code}), e.code
@app.errorhandler(Exception)
def handle_unexpected(e):
app.logger.exception('Unhandled exception on %s %s', request.method, request.path)
if app.debug:
raise e
return jsonify({'error': 'Internal server error', 'code': 500}), 500
```
This standardizes 400/401/403/405/413/429/500 to `{"error": ..., "code": ...}` and guarantees no stack traces/HTML leak to clients. Keep the existing 429 message text for back-compat.
**Test:** `curl localhost:5003/api/nonexistent` โ JSON 404; force an exception (temp route) โ JSON 500, full traceback only in `logs/app.log`; `curl -X PUT localhost:5003/health` โ JSON 405.
---
# DEPENDENCY GRAPH (summary)
```
1.4 WAL โโโโโโโโโโโโโโโ
1.2 Gunicorn โโโฌโโโโโโโผโโ 2.3 secrets (same unit edit) โโ SECRET_KEY rotation โโ 2.2 sessions
โโโ 3.5 APScheduler (worker count decision)
1.1 IDOR (independent, do first)
1.3 reset flow โโ needs migration; strength check upgraded by 3.1
2.1 CSRF โ csrf.py extraction (do before adding decorators elsewhere)
2.4 headers โโ /uploads sandbox supports 2.6 SVG
2.7 MAX_CONTENT_LENGTH before 2.6 (upload tests)
3.2 datetime helpers โ used by 3.1, 3.4, 3.7, 1.3
```
Suggested order inside phases: 1.1 โ 1.4 โ 1.2 โ 1.3, then 2.7 โ 2.5 โ 2.4 โ 2.1 โ 2.6 โ 2.3+2.2 (one restart, forced re-login), then 3.2 โ 3.1 โ 3.7 โ 3.4 โ 3.6 โ 3.5 โ 3.3, then Phase 4.
# MIGRATIONS (all follow scripts/migrate_company_flags.py pattern; each preceded by `python seed.py --backup-only`)
| Migration | Tables/columns | Needed by |
|---|---|---|
| migrate_password_reset.py | `password_reset_tokens` table | 1.3 |
| migrate_lockout.py | `profiles.failed_login_count`, `profiles.locked_until` | 3.1 |
| migrate_billing_flags.py | `companies.has_had_subscription`, `companies.tier_synced_at` | 3.4 |
# VERIFICATION GATE (end of each phase)
```bash
cd /home/vincent/projects/command-sovereignty
.venv/bin/python -m pytest tests/ -x -q
curl -s localhost:5003/health | jq .
systemctl --user status command-sovereignty --no-pager | head -5
# manual SPA smoke: login, dashboard, forecast, connectors page, billing page
```