v2.4.15 (Patch — Sprint 1.6 audit polish)
Audit polish sprint: the four zero/low-risk items from the 2026-05-12 API auth audit that didn't need structural design, shipped together because each is small, additive, and shares no code paths. Four atomic commits, 13 new unit tests on top of v2.4.14's 112 (125 total, 0.87s runtime, no Docker). PR #151.
F-4 — Confirm dialog when creating an API key with no domain scope
The Allowed Domains field on the API Keys settings tab has always defaulted to "empty = unrestricted" — sensible for backward compatibility, easy to trip over: an admin who hits Create with the field blank just minted a key with role-scoped access to every certificate on the install.
createKey in static/js/settings-apikeys.js now gates on a confirm dialog when parseAllowedDomains(...) returns undefined:
"This key will have no domain restrictions and will be authorized to operate on every certificate on this CertMate instance, scoped only by the role you selected. To restrict the key to specific domains, cancel and fill in the Allowed Domains field (comma-separated, supports wildcards like *.example.com). Create this unrestricted key?"
On cancel, focus jumps back to the Allowed Domains input so recovery is one keystroke. When the field has at least one pattern (including a single wildcard) the dialog is skipped — the admin already declared intent. Zero backend change.
F-6 — Self-host ReDoc bundle + drop external CDN from CSP
/redoc/ previously loaded the ReDoc bundle from cdn.redoc.ly and Montserrat / Roboto from fonts.googleapis.com + fonts.gstatic.com, breaking the project's air-gapped-ready promise and forcing three external origins into the global CSP.
Three changes:
static/js/redoc.standalone.jschecked in as a vendored asset (918 KB; pulled fromcdn.redoc.ly/redoc/latest/bundles/at the time of the commit; carries its own MIT license header).templates/redoc.htmlswitched from<redoc spec-url=>+ CDN<script>toRedoc.init()against the locally-hosted bundle. ReDoc'stheme.typography.fontFamilyis pinned to the system-font stack so no Google Fonts request fires.modules/core/factory.pyCSP — droppedcdn.redoc.ly,fonts.googleapis.com,fonts.gstatic.com./redoc/now satisfies the sameself-only CSP as every other page on the install.
tests/test_static_csp.py — test_redoc_csp_allows_external rewritten to test_redoc_csp_self_only (asserts the three CDN origins are absent and self is present); new test_redoc_html_self_hosts_bundle confirms the rendered page references /static/js/redoc.standalone.js.
GET role normalisation — /api/web/settings GET is now viewer
The Flask-RESTX surface at /api/settings GET already required only viewer; the web blueprint at the same path (plus its /api/web/settings alias) was admin for both GET and POST. Two endpoints, same data, different roles — confusing for integrators and inconsistent with the masking guarantee already in place on the web blueprint side.
The blueprint splits into two view functions: GET is viewer, POST stays admin. Why this is safe to open: api_settings_get already masks every key matching /(token|secret|password|key|credential)/i to '********' via the _mask_dict regex walker. The RESTX Settings.get resource achieves the same via MaskedString field types. Both endpoints have been masking on the read path since well before this change; the only thing that shifts is which roles can hit them.
F-7 — Per-username login rate limit on top of per-IP
/api/auth/login had a single per-IP bucket (5 attempts / 60s). It fails open to a distributed brute-force where an attacker spreads attempts across N source IPs — N × 5 attempts/minute against a single account before any rate limit fires. The 12-character password policy mitigates the threat; SOC2 / ISO27001 audits still expect a per-account lockout for due diligence.
New per-username bucket on top:
| Bucket | Limit | Window |
|---|---|---|
| per-IP | 5 attempts | 60 s |
| per-username (new) | 10 attempts | 300 s |
Wider window for the username bucket because the threat model is slower than a per-IP burst and legitimate users mistyping a password from different devices should still recover quickly.
Defensive details:
- Username is lower-cased + trimmed before bucketing so an attacker cannot side-step the cap by varying case (
ADMINvsadmin) or padding whitespace. - Empty / whitespace-only usernames skip the per-user bucket — recording them would let an attacker pre-fill a wildcard slot to starve legitimate users.
- Attempts older than their respective window are pruned lazily on every check; in-memory dicts don't grow unbounded.
- When both buckets trip,
retry_afteris the longer of the two outstanding windows so the client backs off enough to clear both. _check_login_rate_limitand_record_login_attemptgrew an optionalusernameparameter; existing callers without a username keep working.
auth_routes.py POST /api/auth/login now reads the username from the request body before the rate-limit check but credential validation still happens after — so a hammered username cannot be probed for existence by comparing error codes; both rate-limit and bad-credentials paths return the same generic error envelope.
Tests
tests/test_sprint1_6_audit_polish.py — 13 new tests, 0.07s runtime:
TestPerIpBucket(3) — original behaviour preserved + backward-compatible no-username signatureTestPerUsernameBucket(5) — distributed-attack blocked, per-user isolation, case/whitespace normalisation, empty username does not poison the bucketTestRetryAfterIsWorstCase(2) —retry_afterpicks the binding windowTestBucketWindowExpiry(2) — stale entries pruned on next checkTestSettingsGetRoleNormalization(1) — module exposes the split GET handler with viewer role
Total unit-test surface after this release: 125 passes (112 pre-existing + 13 new) in 0.87s without Docker. CI also runs the Docker-fixture integration suite and the updated tests/test_static_csp.py assertions for the ReDoc self-host.
Backward compatibility
- Every existing API call continues to work for admin callers. The only observable changes for non-admin callers:
- Viewers can now read
/api/web/settingsGET (where they previously got 403). The response is masked; no secret value is exposed. - The viewer cannot trip the per-username login bucket without doing 10 failed POSTs against the same username in 5 minutes.
- Viewers can now read
parseAllowedDomainsis unchanged; the F-4 confirm only fires when the field is empty, so admins who routinely scope their keys see no new dialog.- The
_check_login_rate_limit(ip)signature is preserved (username defaults to None); legacy callers don't need to change. - ReDoc renders identically — only the asset source changes.
Non-goals (deferred to Sprint 2, with reason)
- F-3 (legacy bearer deprecation flag + dedicated rotation endpoint + UI migration warning). Structural feature, needs UX design for the migration path. Deferred to a dedicated PR.
- #138 (help.html rewrite). Pure UI but a large rewrite that deserves its own design + sprint.
- #150 (diagnostic snapshot + one-click bug report from error toasts). New feature opened during this sprint after the audit walk-through; in the backlog now, separate PR.