[Security] JWT signing secret leaked via GET /api/settings to any authenticated user #70

Closed
opened 2026-07-01 09:11:04 +02:00 by bullitt · 1 comment
Owner

Severity: Critical | Category: Security

Found while verifying architecture-rework against a live dev server — confirmed present on main too, unrelated to any architecture-review change.

auth.py writes the JWT signing secret directly to the settings table as _jwt_secret via raw SQL (auth.py:66), bypassing settings.py's set_setting(). get_public_user_settings()/get_public_settings() (settings.py) only redacted a fixed _SECRET_KEYS blocklist (scraper_dhl_api_key, ntfy_password, imap_password, mqtt_password) — _jwt_secret was never added, so it passed through in plaintext to GET /api/settings for any authenticated user, including one authenticated with just an API key (not just admins). Anyone who read it could forge a valid JWT for any user, including admin — full authentication bypass.

Fixed on architecture-rework, commit 133a832: excluded any underscore-prefixed key (the existing "internal, not user-facing" convention) from both public settings functions entirely, rather than only redacting a known-secrets blocklist. Protects any future internal key added the same way.

Added test_get_settings_never_exposes_internal_keys; confirmed it fails on the pre-fix code and passes after (verified by temporarily reverting settings.py). Also confirmed live: the secret appeared in the raw HTTP response from a running dev server before the fix, and is gone after.

Action needed after this branch merges to main and deploys: rotate the JWT secret in production (delete the _jwt_secret row from the production settings table, or set the JWT_SECRET env var, so a new one is generated) — any secret that was live before this fix should be treated as potentially compromised.

**Severity:** Critical | **Category:** Security Found while verifying `architecture-rework` against a live dev server — confirmed present on `main` too, unrelated to any architecture-review change. `auth.py` writes the JWT signing secret directly to the `settings` table as `_jwt_secret` via raw SQL (`auth.py:66`), bypassing `settings.py`'s `set_setting()`. `get_public_user_settings()`/`get_public_settings()` (settings.py) only redacted a fixed `_SECRET_KEYS` blocklist (`scraper_dhl_api_key`, `ntfy_password`, `imap_password`, `mqtt_password`) — `_jwt_secret` was never added, so it passed through in plaintext to `GET /api/settings` for **any authenticated user**, including one authenticated with just an API key (not just admins). Anyone who read it could forge a valid JWT for any user, including admin — full authentication bypass. **Fixed** on `architecture-rework`, commit 133a832: excluded any underscore-prefixed key (the existing "internal, not user-facing" convention) from both public settings functions entirely, rather than only redacting a known-secrets blocklist. Protects any future internal key added the same way. Added `test_get_settings_never_exposes_internal_keys`; confirmed it fails on the pre-fix code and passes after (verified by temporarily reverting `settings.py`). Also confirmed live: the secret appeared in the raw HTTP response from a running dev server before the fix, and is gone after. **Action needed after this branch merges to main and deploys:** rotate the JWT secret in production (delete the `_jwt_secret` row from the production `settings` table, or set the `JWT_SECRET` env var, so a new one is generated) — any secret that was live before this fix should be treated as potentially compromised.
Author
Owner

Rotated in production 2026-07-05: deleted the _jwt_secret row from the settings table on the live DB and restarted the container. Confirmed the old row is gone; a new secret will be generated lazily on the next login/token operation (auth.py:_get_secret). All existing sessions are invalidated. Health check confirms the app came back up clean (v0.13.6). Closing.

Rotated in production 2026-07-05: deleted the `_jwt_secret` row from the `settings` table on the live DB and restarted the container. Confirmed the old row is gone; a new secret will be generated lazily on the next login/token operation (`auth.py:_get_secret`). All existing sessions are invalidated. Health check confirms the app came back up clean (v0.13.6). Closing.
Sign in to join this conversation.
No labels
arch
harness
security
ux
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
bullitt/trackbox#70
No description provided.