July 4 - Update security

This commit is contained in:
2026-07-04 15:49:47 -04:00
parent 1cfb387b81
commit 03ce083691
6 changed files with 137 additions and 11 deletions
+2 -1
View File
@@ -1259,7 +1259,7 @@ set -a; . /etc/jqc/control.env; set +a
| 48 | **`upload_photo_ajax` endpoint on inspections blueprint** | `POST /<inspection_id>/upload-photo` with `@limiter.limit("30 per minute")`. Triggers on file selection (not form submit) so photos survive AJAX draft-save and page navigation. Stores in `inspection_photos/` subfolder; returns `{ok, path}`. |
| 49 | **Template schema snapshotted at submit time** | `execute()` POST stores `form_fields` list as `_template_schema` inside `inspection.notes` JSON. `view()` prefers this snapshot over the live template so historical inspection views remain correct if the template changes later. |
| 50 | **`mobile_local_id` UUID format validation on write endpoints** | `POST /api/v1/inspections` and `POST /api/v1/issues` validate `mobile_local_id` against `_UUID_RE` regex. Rejects non-UUID strings with HTTP 400. Prevents garbage values from being stored as idempotency keys. |
| 51 | **Security response headers via `@app.after_request`** | Added `X-Content-Type-Options: nosniff`, `X-Frame-Options: SAMEORIGIN`, `Referrer-Policy: strict-origin-when-cross-origin`, and a `Content-Security-Policy` (CDN allowlist + `unsafe-inline`). Uses `setdefault` so API responses can override if needed. |
| 51 | **Security response headers via `@app.after_request`** | Sets `X-Content-Type-Options: nosniff`, `X-Frame-Options: SAMEORIGIN`, `Referrer-Policy: strict-origin-when-cross-origin`, and a `Content-Security-Policy`. CSP includes `object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'` (all forms post same-origin, so `form-action 'self'` is safe). `script-src`/`style-src` still carry `'unsafe-inline'` — removing that needs a nonce migration across all inline scripts (larger follow-up, not done). **HSTS** (`Strict-Transport-Security: max-age=31536000`) is emitted only when the request is HTTPS (`request.is_secure` or `X-Forwarded-Proto: https`); `includeSubDomains` is intentionally omitted so a tenant custom domain never force-upgrades an unrelated customer subdomain. The panel (`control/panel/__init__.py`) mirrors these. Uses `setdefault` so API responses can override. |
| 52 | **Inspection `execute.html` offline-resilient photo flow** | Photos are uploaded immediately on file selection via `uploadPhotoField()` (XHR to `upload_photo_ajax`). Server path is stored in `<input type="hidden" id="field_<fid>_server_path">`. AJAX draft-save and flag-issue submission read these hidden fields so photos are never lost on navigation. |
| 53 | **Flag-issue panel is an offcanvas — not a page navigation** | Converted from a navigate-away flow to a Bootstrap offcanvas. Draft is saved first via `saveDraft()`, then the flag-issue form is submitted via `fetch()` FormData, then the page reloads. Eliminates the entire class of "photos lost on navigation" bugs. |
| 54 | **Bulk issue verification via `POST /issues/bulk-verify`** | `@supervisor_required`. Accepts `issue_ids` list from form. Skips issues not in `resolved` or `pending_verification` state. Calls `log_action()` after `db.session.commit()` per rule 10. |
@@ -1289,6 +1289,7 @@ set -a; . /etc/jqc/control.env; set +a
| 85 | **Apex host serves the public landing page; the landing route lives at `/welcome`, NOT `/`** | The dashboard owns `/` (login-gated) on tenant hosts, so the landing page cannot register a second `/` route (same collision class as rule 84). Instead the tenant middleware detects the apex host (`TENANT_BASE_DOMAIN` + `www.`) and calls `landing.index` directly for `/`, redirecting other non-exempt apex paths to `/`. `/welcome`, `/signup`, `/static/` are tenant-exempt. **The apex check runs BEFORE the `MULTI_TENANT_ENABLED` gate** — it must work in single-tenant mode too, otherwise the app serves its default database (tenant-zero) for the apex host and the landing page never shows. Requires the Nginx apex block to **proxy** (not 301-redirect) to port 8000 with `Host` passed through, and `TENANT_BASE_DOMAIN` set correctly in the app environment. |
| 86 | **Free plan is free-forever, not a trial** | `signup.index()` passes `trial_days=0` for `plan_code == 'free'`; `create_tenant()` then sets `subscription_status='active'` (no `trial_ends_at`) so `_billing_gate()` never blocks it. Paid plans keep the 14-day trial (`trial_days=14`). The welcome email adapts via `trial_note` and hides the trial row when `trial_ends_at` is blank. Do not reintroduce a hardcoded `trial_days=14` in the signup path. |
| 87 | **MFA is opt-in, TOTP-based, with hashed one-time recovery codes** | `app/utils/mfa.py` (data plane) and `control/mfa.py` (panel) are pure-logic mirrors — keep them in sync (same rule class as `time_utils`). The login challenge (`/auth/mfa`, panel `/mfa`) fires for ANY account with `mfa_enabled=1`; `login_user()`/`session['sa_id']` is deferred until the code passes. Recovery codes are stored ONLY as werkzeug hashes and are single-use (consumed on match). Disable requires a current TOTP code OR the password. **Lock-out escape hatch:** because MFA is per-account opt-in, the recovery path is the primary unlock; the operational last resort is a DB update `UPDATE users SET mfa_enabled=0, mfa_secret=NULL, mfa_recovery_codes=NULL WHERE username=...` (or the same on `superadmins`). Do not store `mfa_secret`/recovery codes in plaintext, and do not skip the deferred-login pattern. |
| 88 | **Password strength enforced by one shared `strong_password()` validator** | Lives in `app/utils/forms.py`: ≥8 chars, at least one letter AND one digit, and not in a small common-password blocklist. Applied to every password-setting form — `ProfileForm`, `UserForm`, `CustomerForm`, `ResetPasswordForm`, `SetPasswordForm`, and `signup.SignupForm` (imports it). Sits after `Optional()` on edit forms (skips blank = "leave unchanged"). Do not re-introduce ad-hoc `Length(min=6)` password rules — route new password fields through `strong_password()` so the policy stays consistent. |
---