Skip to content

Commit 5a922d5

Browse files
authored
fix: resolve security lint violations and critical audit findings (#19, #20, #21)
fix: resolve security lint violations and critical audit findings (#19, #20, #21)
2 parents 0e84292 + 780c050 commit 5a922d5

31 files changed

+419
-97
lines changed

.env.example

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,15 @@
1111
# Postgres password (used by both the postgres container and the server)
1212
POSTGRES_PASSWORD=
1313

14+
# Redis password (used by the redis container and embedded in REDIS_URL)
15+
# Generate: python -c "import secrets; print(secrets.token_urlsafe(32))"
16+
REDIS_PASSWORD=
17+
1418
# Database URL (docker-compose sets this automatically; set for standalone)
1519
# EDICTUM_DATABASE_URL=postgresql+asyncpg://postgres:password@localhost:5432/edictum
1620

1721
# Redis URL (docker-compose sets this automatically; set for standalone)
18-
# EDICTUM_REDIS_URL=redis://localhost:6379/0
22+
# EDICTUM_REDIS_URL=redis://:yourpassword@localhost:6379/0
1923

2024
# Secret key for session token signing
2125
# Generate: python -c "import secrets; print(secrets.token_hex(32))"

.github/workflows/ci.yml

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -60,17 +60,14 @@ jobs:
6060
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
6161
with:
6262
python-version: "3.12"
63-
- run: pip install bandit pip-audit
63+
- run: pip install -e ".[dev]" bandit pip-audit
6464
- run: bandit -r src/ -ll -ii --exclude tests/
6565
- name: Audit Python dependencies
6666
run: pip-audit
67-
continue-on-error: true
6867

6968
custom-security-lint:
7069
name: Custom Security Checks
7170
runs-on: ubuntu-latest
72-
# TODO: remove continue-on-error once issues #12-#28 are fixed
73-
continue-on-error: true
7471
steps:
7572
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
7673
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5

CLAUDE.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -308,12 +308,13 @@ All prefixed with `EDICTUM_` where possible.
308308
| Variable | Required | Default | Purpose |
309309
|----------|----------|---------|---------|
310310
| `DATABASE_URL` | Yes || Postgres connection string |
311-
| `REDIS_URL` | Yes || Redis connection string |
311+
| `REDIS_URL` | Yes || Redis connection string (include password: `redis://:pass@host:6379/0`) |
312+
| `REDIS_PASSWORD` | Yes || Redis `requirepass` (used by docker-compose; embedded in `REDIS_URL`) |
312313
| `EDICTUM_ADMIN_EMAIL` | First run || Bootstrap admin user |
313314
| `EDICTUM_ADMIN_PASSWORD` | First run || Bootstrap admin password |
314315
| `EDICTUM_AUTH_PROVIDER` | No | `local` | Auth provider (`local`, future: `clerk`, `oidc`) |
315316
| `EDICTUM_BASE_URL` | No | `http://localhost:8000` | Public URL for webhooks, CORS |
316-
| `EDICTUM_SECRET_KEY` | Yes || Session token signing. No default. |
317+
| `EDICTUM_SECRET_KEY` | Yes || Session HMAC signing. Min 32 chars. No default. |
317318

318319
## License
319320

docker-compose.yml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,9 @@ services:
1515

1616
redis:
1717
image: redis:7-alpine
18+
command: redis-server --requirepass ${REDIS_PASSWORD:?Set REDIS_PASSWORD in .env}
1819
healthcheck:
19-
test: ["CMD", "redis-cli", "ping"]
20+
test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"]
2021
interval: 10s
2122
timeout: 5s
2223
retries: 5
@@ -33,7 +34,7 @@ services:
3334
env_file: .env
3435
environment:
3536
EDICTUM_DATABASE_URL: postgresql+asyncpg://postgres:${POSTGRES_PASSWORD:?}@postgres:5432/edictum
36-
EDICTUM_REDIS_URL: redis://redis:6379/0
37+
EDICTUM_REDIS_URL: redis://:${REDIS_PASSWORD:?}@redis:6379/0
3738
EDICTUM_SECRET_KEY: ${EDICTUM_SECRET_KEY:?Set EDICTUM_SECRET_KEY in .env}
3839
EDICTUM_ADMIN_EMAIL: ${EDICTUM_ADMIN_EMAIL:-}
3940
EDICTUM_ADMIN_PASSWORD: ${EDICTUM_ADMIN_PASSWORD:-}

src/edictum_server/auth/dependencies.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ class AuthContext:
3030
agent_id: str | None = None
3131
email: str | None = None
3232
is_admin: bool = False
33+
api_key_prefix: str | None = None
3334

3435

3536
def _extract_bearer(authorization: str) -> str:
@@ -79,6 +80,7 @@ async def require_api_key(
7980
auth_type="api_key",
8081
env=api_key.env,
8182
agent_id=x_edictum_agent_id,
83+
api_key_prefix=api_key.key_prefix,
8284
)
8385

8486

src/edictum_server/auth/local.py

Lines changed: 63 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,10 @@
33
from __future__ import annotations
44

55
import hashlib
6+
import hmac
67
import json
78
import secrets
9+
import time
810
import uuid
911

1012
import bcrypt
@@ -15,21 +17,48 @@
1517

1618
_COOKIE_NAME = "edictum_session"
1719
_SESSION_PREFIX = "session:"
20+
_MAX_ABSOLUTE_LIFETIME = 7 * 24 * 3600 # 7 days — no session lives beyond this
1821

1922

2023
class LocalAuthProvider(AuthProvider):
21-
"""Bcrypt password auth with Redis-backed sessions."""
24+
"""Bcrypt password auth with HMAC-signed Redis-backed sessions."""
2225

2326
def __init__(
2427
self,
2528
redis: aioredis.Redis,
2629
session_ttl_hours: int = 24,
2730
*,
2831
secure_cookies: bool = False,
32+
secret_key: str,
2933
) -> None:
34+
if not secret_key:
35+
raise ValueError("secret_key must not be empty")
3036
self._redis = redis
3137
self._session_ttl = session_ttl_hours * 3600
3238
self._secure_cookies = secure_cookies
39+
self._secret_key = secret_key
40+
41+
def _sign(self, data: str) -> str:
42+
"""HMAC-SHA256 sign session data, return 'hmac_hex:json' string."""
43+
mac = hmac.new(
44+
self._secret_key.encode(), data.encode(), hashlib.sha256
45+
).hexdigest()
46+
return f"{mac}:{data}"
47+
48+
def _verify_and_parse(self, raw: str) -> dict[str, object] | None:
49+
"""Verify HMAC signature and return parsed session data, or None."""
50+
if ":" not in raw:
51+
return None
52+
stored_mac, _, payload = raw.partition(":")
53+
expected_mac = hmac.new(
54+
self._secret_key.encode(), payload.encode(), hashlib.sha256
55+
).hexdigest()
56+
if not hmac.compare_digest(stored_mac, expected_mac):
57+
return None
58+
try:
59+
return json.loads(payload) # type: ignore[no-any-return]
60+
except (json.JSONDecodeError, ValueError):
61+
return None
3362

3463
async def authenticate(self, request: Request) -> DashboardAuthContext:
3564
token = request.cookies.get(_COOKIE_NAME)
@@ -44,14 +73,38 @@ async def authenticate(self, request: Request) -> DashboardAuthContext:
4473
status_code=status.HTTP_401_UNAUTHORIZED,
4574
detail="Session expired or invalid.",
4675
)
47-
data = json.loads(raw)
76+
77+
# Verify HMAC signature — reject tampered sessions
78+
raw_str = raw if isinstance(raw, str) else raw.decode()
79+
data = self._verify_and_parse(raw_str)
80+
if data is None:
81+
# Tampered or legacy unsigned session — destroy it
82+
await self._redis.delete(f"{_SESSION_PREFIX}{token}")
83+
raise HTTPException(
84+
status_code=status.HTTP_401_UNAUTHORIZED,
85+
detail="Session expired or invalid.",
86+
)
87+
88+
# Enforce absolute session lifetime (7 days max)
89+
created_at = data.get("created_at")
90+
expired = (
91+
isinstance(created_at, (int, float))
92+
and time.time() - created_at > _MAX_ABSOLUTE_LIFETIME
93+
)
94+
if expired:
95+
await self._redis.delete(f"{_SESSION_PREFIX}{token}")
96+
raise HTTPException(
97+
status_code=status.HTTP_401_UNAUTHORIZED,
98+
detail="Session expired. Please log in again.",
99+
)
100+
48101
# Slide the expiration on each successful auth
49102
await self._redis.expire(f"{_SESSION_PREFIX}{token}", self._session_ttl)
50103
return DashboardAuthContext(
51-
user_id=uuid.UUID(data["user_id"]),
52-
tenant_id=uuid.UUID(data["tenant_id"]),
53-
email=data["email"],
54-
is_admin=data["is_admin"],
104+
user_id=uuid.UUID(str(data["user_id"])),
105+
tenant_id=uuid.UUID(str(data["tenant_id"])),
106+
email=str(data["email"]),
107+
is_admin=bool(data["is_admin"]),
55108
)
56109

57110
async def create_session(
@@ -68,11 +121,14 @@ async def create_session(
68121
"tenant_id": str(tenant_id),
69122
"email": email,
70123
"is_admin": is_admin,
124+
"created_at": time.time(),
71125
}
72126
)
127+
# HMAC-sign session data to prevent forgery via Redis access
128+
signed = self._sign(session_data)
73129
await self._redis.set(
74130
f"{_SESSION_PREFIX}{token}",
75-
session_data,
131+
signed,
76132
ex=self._session_ttl,
77133
)
78134
cookie_params: dict[str, str | bool | int] = {

src/edictum_server/config.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,11 @@ def validate_required(self) -> None:
9393
"EDICTUM_SECRET_KEY is required. "
9494
'Generate one with: python -c "import secrets; print(secrets.token_hex(32))"'
9595
)
96+
if len(self.secret_key) < 32:
97+
raise SystemExit(
98+
f"EDICTUM_SECRET_KEY must be at least 32 characters (got {len(self.secret_key)}). "
99+
'Generate one with: python -c "import secrets; print(secrets.token_hex(32))"'
100+
)
96101
if not self.database_url:
97102
raise SystemExit(
98103
"EDICTUM_DATABASE_URL is required. "

src/edictum_server/main.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -303,6 +303,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
303303
redis=app.state.redis,
304304
session_ttl_hours=settings.session_ttl_hours,
305305
secure_cookies=settings.base_url.startswith("https://"),
306+
secret_key=settings.secret_key,
306307
)
307308

308309
# Push manager (SSE)

src/edictum_server/routes/approvals.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,11 @@ async def create_approval(
9191
)
9292

9393
env = auth.env or "production"
94-
approval = await approval_service.create_approval(db, auth.tenant_id, body, env=env)
94+
# Identity from auth context, not request body (CLAUDE.md rule)
95+
agent_id = auth.agent_id or f"key:{auth.api_key_prefix or 'unknown'}"
96+
approval = await approval_service.create_approval(
97+
db, auth.tenant_id, body, env=env, agent_id=agent_id,
98+
)
9599
await db.commit()
96100

97101
event_data = {
@@ -166,12 +170,14 @@ async def submit_decision(
166170
push: PushManager = Depends(get_push_manager),
167171
) -> ApprovalResponse:
168172
"""Submit a human decision on a pending approval."""
173+
# Identity from auth context, not request body (CLAUDE.md rule)
174+
decided_by = auth.email or f"user:{auth.user_id}"
169175
approval = await approval_service.submit_decision(
170176
db,
171177
auth.tenant_id,
172178
approval_id,
173179
approved=body.approved,
174-
decided_by=body.decided_by,
180+
decided_by=decided_by,
175181
reason=body.reason,
176182
decided_via=body.decided_via or "console",
177183
)

src/edictum_server/routes/keys.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,9 @@ async def revoke_key(
121121
)
122122

123123
await db.execute(
124-
update(ApiKey).where(ApiKey.id == key_id).values(revoked_at=datetime.now(UTC))
124+
update(ApiKey)
125+
.where(ApiKey.id == key_id, ApiKey.tenant_id == auth.tenant_id)
126+
.values(revoked_at=datetime.now(UTC))
125127
)
126128
await db.commit()
127129

0 commit comments

Comments
 (0)