fix: P0 hardening — fail-closed SECRET_KEY, locked CORS, role-safe registration (HARDENING.md P0.1/P0.2/P0.3)

P0.1 — fail-closed secrets:
- config.py: no default SECRET_KEY; refuses to boot when unset, a known
  placeholder, or <32 chars. Generate with: openssl rand -hex 32.
- docker-compose.yml: literal secrets removed; runtime env now comes from
  a git-ignored .env via env_file. .env.example added as template.
- .gitignore already covers .env (verified).

P0.2 — locked CORS:
- main.py: CORS_ORIGINS must be an explicit comma-separated allow-list.
  '*' or an empty value refuses to boot (was: silently ['*'] with
  allow_credentials=True).

P0.3 — role-safe registration:
- services/auth.py: client-supplied 'role' is IGNORED on POST
  /api/auth/register; self-registered users always get the
  least-privilege 'CS Rep' role. Unauthenticated callers can no longer
  mint Admin/Jerome, Admin/Wahab, or Director accounts.

Tests:
- conftest.py sets test SECRET_KEY/CORS_ORIGINS before app import.
- New tests/test_p0_hardening.py (8 tests): role-escalation blocked for
  Admin/Jerome and Admin/Wahab, duplicate-email 409, and subprocess
  boot-validation for placeholder/short/missing secret + wildcard CORS.
- Full suite: 44 passed.

Redeploy note (per research): seed_units/seed_categories are insert-only,
so the Aug-26 redeploy does NOT orphan historical tickets referencing
units 103E/103W/105E/105W or the legacy 34-category tree. Pending
Wahab: are 103E/103W/105E/105W real apartments dropped from the Excel
regeneration? Optional follow-up: floor-number backfill for already-
seeded units (mapping corrected floors; existing rows keep old values).

Checks per HARDENING.md acceptance:
- [x] starting without a real key fails loudly (subprocess-verified)
- [x] compose carries no literal secret; secrets come from .env
- [x] CORS_ORIGINS explicit allow-list, '*' rejected
- [x] unauthenticated register cannot mint Admin/* or Director
This commit is contained in:
Mumuni (Syslog Code Agent)
2026-09-02 23:59:21 +00:00
committed by Mumuni (Syslog Falcon)
parent 76d9d12b78
commit 3ec09470ff
8 changed files with 196 additions and 9 deletions
+4
View File
@@ -12,6 +12,10 @@ import tempfile
_TMP_DIR = tempfile.mkdtemp(prefix="denya-test-")
os.environ["DATABASE_URL"] = f"sqlite+aiosqlite:///{_TMP_DIR}/test.db"
# HARDENING.md P0.1/P0.2: the app now fails closed without a real SECRET_KEY
# and an explicit CORS allow-list — tests must satisfy both.
os.environ.setdefault("SECRET_KEY", "test-secret-key-not-for-production-0123456789abcdef")
os.environ.setdefault("CORS_ORIGINS", "http://test")
import pytest_asyncio # noqa: E402 (DATABASE_URL must be set before app imports)
from httpx import ASGITransport, AsyncClient # noqa: E402
+132
View File
@@ -0,0 +1,132 @@
"""P0 hardening regression tests (HARDENING.md P0.1 / P0.2 / P0.3).
Covers:
- P0.3: self-registration CANNOT mint a privileged role (role field ignored)
- P0.3: duplicate email still 409s
- P0.1: app fails to import/boot with placeholder or missing SECRET_KEY
- P0.2: app fails to boot with CORS_ORIGINS="*"
"""
from __future__ import annotations
import os
import subprocess
import sys
from pathlib import Path
import pytest
from httpx import ASGITransport, AsyncClient
REPO_ROOT = Path(__file__).resolve().parent.parent
pytestmark = pytest.mark.asyncio
# ── P0.3: registration role-escalation ────────────────────────────────
async def test_register_cannot_mint_admin_role(client: AsyncClient):
"""A raw unauthenticated register call must NOT be able to mint Admin/*."""
resp = await client.post(
"/api/auth/register",
json={
"email": "attacker@example.com",
"password": "Sup3rSecret!",
"full_name": "Attacker",
"role": "Admin/Jerome",
},
)
assert resp.status_code == 201, resp.text
created = resp.json()
assert created["role"] == "CS Rep", (
f"self-registration minted privileged role: {created['role']}"
)
async def test_register_role_wahab_also_blocked(client: AsyncClient):
resp = await client.post(
"/api/auth/register",
json={
"email": "attacker2@example.com",
"password": "Sup3rSecret!",
"full_name": "Attacker Two",
"role": "Admin/Wahab",
},
)
assert resp.status_code == 201
assert resp.json()["role"] == "CS Rep"
async def test_register_duplicate_email_conflict(client: AsyncClient):
payload = {
"email": "dupe@example.com",
"password": "Sup3rSecret!",
"full_name": "Dupe",
}
r1 = await client.post("/api/auth/register", json=payload)
assert r1.status_code == 201
r2 = await client.post("/api/auth/register", json=payload)
assert r2.status_code == 409
# ── P0.1 / P0.2: fail-closed boot validation ──────────────────────────
def _boot_with_env(env_overrides: dict[str, str]) -> subprocess.CompletedProcess:
"""Try importing app.main in a subprocess with the given env; the import
must fail (non-zero) when fail-closed validation trips."""
env = os.environ.copy()
env["DATABASE_URL"] = "sqlite+aiosqlite:///:memory:"
env.pop("SECRET_KEY", None)
env.pop("CORS_ORIGINS", None)
env.update(env_overrides)
script = (
"import sys; sys.path.insert(0, ''); "
"import app.main" # noqa
)
return subprocess.run(
[sys.executable, "-c", script],
cwd=str(REPO_ROOT),
env=env,
capture_output=True,
text=True,
timeout=60,
)
def test_boot_fails_with_placeholder_secret():
result = _boot_with_env({"SECRET_KEY": "change-me-in-production"})
assert result.returncode != 0, "app booted with placeholder SECRET_KEY!"
assert "SECRET_KEY" in result.stderr
def test_boot_fails_with_short_secret():
result = _boot_with_env({"SECRET_KEY": "tooshort"})
assert result.returncode != 0, "app booted with a <32-char SECRET_KEY!"
assert "SECRET_KEY" in result.stderr
def test_boot_fails_without_secret():
result = _boot_with_env({"SECRET_KEY": ""})
assert result.returncode != 0, "app booted without a SECRET_KEY!"
assert "SECRET_KEY" in result.stderr
def test_boot_fails_with_wildcard_cors():
result = _boot_with_env(
{
"SECRET_KEY": "test-secret-key-not-for-production-0123456789abcdef",
"CORS_ORIGINS": "*",
}
)
assert result.returncode != 0, "app booted with CORS_ORIGINS=* !"
assert "CORS_ORIGINS" in result.stderr
def test_boot_succeeds_with_valid_env():
result = _boot_with_env(
{
"SECRET_KEY": "test-secret-key-not-for-production-0123456789abcdef",
"CORS_ORIGINS": "http://test",
}
)
assert result.returncode == 0, result.stderr