Skip to content

Commit 96402b9

Browse files
committed
chore: tighten CORS configuration and enhance validation checks
- Updated CORS settings in the environment configuration to prevent wildcard origins in production and staging environments, allowing only explicit origins or an empty value for mobile-only APIs. - Added validation logic to ensure that CORS_ORIGINS is not set to '*' in production or staging, raising an error if this condition is met. - Introduced a new property to manage CORS credentials based on the origins list. - Enhanced deployment scripts to check for CORS configuration compliance, ensuring safer API deployment practices.
1 parent 1cdc699 commit 96402b9

9 files changed

Lines changed: 131 additions & 25 deletions

File tree

backend/.env.example

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,10 @@ AUTH_MOCK_OTP_CODE=123456
3737

3838
# ── CORS ──────────────────────────────────────────────────────
3939
CORS_ORIGINS=*
40-
# On Render set to comma-separated allowed origins, e.g.:
41-
# CORS_ORIGINS=https://yourapp.com,https://api.yourapp.com
40+
# Local dev: * is fine. On Render (production/staging) the API refuses to boot
41+
# with CORS_ORIGINS=* — use empty for mobile-only, or explicit origins for web:
42+
# CORS_ORIGINS=
43+
# CORS_ORIGINS=https://yourapp.com,https://admin.yourapp.com
4244

4345
# ── Arkesel SMS ───────────────────────────────────────────────
4446
ARKESEL_BASE_URL=https://sms.arkesel.com

backend/app/core/config.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,11 @@ def cors_origin_list(self) -> list[str]:
7575
return ["*"]
7676
return [o.strip() for o in raw.split(",") if o.strip()]
7777

78+
@property
79+
def cors_allow_credentials(self) -> bool:
80+
"""Browsers reject credentials with a wildcard origin."""
81+
return self.cors_origin_list != ["*"]
82+
7883

7984
@lru_cache
8085
def get_settings() -> Settings:
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
"""Attach standard HTTP security headers to every response."""
2+
3+
from __future__ import annotations
4+
5+
from collections.abc import Awaitable, Callable
6+
7+
from starlette.middleware.base import BaseHTTPMiddleware
8+
from starlette.requests import Request
9+
from starlette.responses import Response
10+
11+
_PRODUCTION_ENVS = frozenset({"production", "staging"})
12+
13+
14+
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
15+
def __init__(self, app, *, app_env: str) -> None:
16+
super().__init__(app)
17+
self._app_env = app_env.strip().lower()
18+
19+
async def dispatch(
20+
self, request: Request, call_next: Callable[[Request], Awaitable[Response]]
21+
) -> Response:
22+
response = await call_next(request)
23+
response.headers.setdefault("X-Content-Type-Options", "nosniff")
24+
response.headers.setdefault("X-Frame-Options", "DENY")
25+
response.headers.setdefault("Referrer-Policy", "strict-origin-when-cross-origin")
26+
response.headers.setdefault(
27+
"Permissions-Policy",
28+
"geolocation=(), microphone=(), camera=()",
29+
)
30+
if self._app_env in _PRODUCTION_ENVS or request.url.scheme == "https":
31+
response.headers.setdefault(
32+
"Strict-Transport-Security",
33+
"max-age=31536000; includeSubDomains",
34+
)
35+
return response

backend/app/core/startup_checks.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,5 +38,13 @@ def validate_settings_or_raise(settings: Settings) -> None:
3838
)
3939
raise ProductionConfigError(msg)
4040

41+
if settings.cors_origin_list == ["*"]:
42+
msg = (
43+
"CORS_ORIGINS must not be '*' in production or staging. "
44+
"Use an empty value for mobile-only APIs, or a comma-separated "
45+
"list of explicit browser origins."
46+
)
47+
raise ProductionConfigError(msg)
48+
4149

4250
__all__ = ["ProductionConfigError", "validate_settings_or_raise"]

backend/app/main.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from app.api.router import api_router
1010
from app.core.config import get_settings
1111
from app.core.logging import configure_logging
12+
from app.core.security_headers import SecurityHeadersMiddleware
1213
from app.core.startup_checks import validate_settings_or_raise
1314

1415

@@ -24,10 +25,11 @@ def create_app() -> FastAPI:
2425
settings = get_settings()
2526
app = FastAPI(title=settings.app_name, lifespan=lifespan)
2627

28+
app.add_middleware(SecurityHeadersMiddleware, app_env=settings.app_env)
2729
app.add_middleware(
2830
CORSMiddleware,
2931
allow_origins=settings.cors_origin_list,
30-
allow_credentials=True,
32+
allow_credentials=settings.cors_allow_credentials,
3133
allow_methods=["*"],
3234
allow_headers=["*"],
3335
)
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
"""Security headers middleware smoke tests."""
2+
3+
from fastapi.testclient import TestClient
4+
5+
from app.main import app
6+
7+
client = TestClient(app)
8+
9+
10+
def test_health_includes_security_headers() -> None:
11+
r = client.get("/health")
12+
assert r.status_code == 200
13+
assert r.headers.get("X-Content-Type-Options") == "nosniff"
14+
assert r.headers.get("X-Frame-Options") == "DENY"
15+
assert r.headers.get("Referrer-Policy") == "strict-origin-when-cross-origin"
16+
assert "geolocation=()" in (r.headers.get("Permissions-Policy") or "")

backend/app/tests/test_startup_checks.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,3 +69,39 @@ def test_staging_rejects_default_secret_but_allows_mock_otp() -> None:
6969
auth_mock_otp_code="123456",
7070
)
7171
)
72+
73+
74+
def test_production_rejects_wildcard_cors() -> None:
75+
with pytest.raises(ProductionConfigError, match="CORS_ORIGINS"):
76+
validate_settings_or_raise(
77+
Settings(
78+
app_env="production",
79+
secret_key="production-secret-key-value",
80+
auth_mock_otp_code=None,
81+
payment_config_encryption_key="payment-encryption-key",
82+
cors_origins="*",
83+
)
84+
)
85+
86+
87+
def test_staging_rejects_wildcard_cors() -> None:
88+
with pytest.raises(ProductionConfigError, match="CORS_ORIGINS"):
89+
validate_settings_or_raise(
90+
Settings(
91+
app_env="staging",
92+
secret_key="staging-secret-key-value",
93+
cors_origins="*",
94+
)
95+
)
96+
97+
98+
def test_production_allows_empty_cors_for_mobile_only() -> None:
99+
validate_settings_or_raise(
100+
Settings(
101+
app_env="production",
102+
secret_key="production-secret-key-value",
103+
auth_mock_otp_code=None,
104+
payment_config_encryption_key="payment-encryption-key",
105+
cors_origins="",
106+
)
107+
)

backend/scripts/check_deploy_env.py

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -62,13 +62,14 @@ def main() -> int:
6262
else:
6363
_ok("PAYSTACK_SECRET_KEY_LIVE is set")
6464

65-
cors = settings.cors_origins
66-
if "*" in cors:
67-
_warn("CORS_ORIGINS includes '*' — tighten before any browser client.")
68-
elif cors:
69-
_ok(f"CORS_ORIGINS configured ({len(cors)} origin(s))")
65+
cors = settings.cors_origins.strip()
66+
if cors == "*":
67+
print(" FAIL CORS_ORIGINS is '*' — boot check rejects this in production.")
68+
return 1
69+
if cors:
70+
_ok(f"CORS_ORIGINS configured ({len(settings.cors_origin_list)} origin(s))")
7071
else:
71-
_warn("CORS_ORIGINS empty — OK for mobile-only; set if web clients exist.")
72+
_ok("CORS_ORIGINS empty — mobile-only API (no browser CORS)")
7273

7374
print("\nManual steps (cannot verify from env alone):")
7475
print(" • Run: alembic upgrade head (migrations 020–023)")

docs/audits/CODEBASE_AUDIT.md

Lines changed: 17 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
**Product:** SikaBoafo (BizTrackGh) — offline-first merchant OS for Ghana
44
**Stack:** Flutter · FastAPI · PostgreSQL · Paystack
55
**Last reviewed:** May 2026
6-
**Test snapshot:** Backend 157/157 pytest pass · Mobile tests pass
6+
**Test snapshot:** Backend 161/161 pytest pass · Mobile tests pass
77

88
This is the **single audit document** for the repo. Use it to see what is wrong, what was already fixed, and what to tackle next. Older notes (e.g. root `debt_payment_flow_audit.md`) are folded in here; prefer **this file** for planning work.
99

@@ -107,12 +107,11 @@ When you fix an item, change its status here and tick the checklist box.
107107

108108
| Field | Value |
109109
|-------|--------|
110-
| **Status** | `open` |
111-
| **Severity** | Medium (when web clients exist) |
112-
| **What's happening** | `cors_origins` defaults to `*` with `allow_credentials=True`. |
113-
| **Why it matters** | Fine for mobile-only MVP; risky if you add a browser admin or web app. |
114-
| **Where** | `backend/app/main.py`, `backend/app/core/config.py` |
115-
| **Fix** | Set explicit origins per environment in Render/env. |
110+
| **Status** | `done` |
111+
| **Severity** ||
112+
| **What's happening** | Boot check rejects `CORS_ORIGINS=*` in **production** and **staging**; empty list allowed for mobile-only. Local dev keeps `*`. |
113+
| **Fix applied** | `startup_checks.py`; `cors_allow_credentials=False` when wildcard; `.env.example` + deploy script updated. |
114+
| **Where** | `backend/app/main.py`, `backend/app/core/config.py`, `backend/app/core/startup_checks.py` |
116115

117116
### AUTH-08 · No API-wide rate limiting (sync done — AUTH-08b)
118117

@@ -446,10 +445,10 @@ When you fix an item, change its status here and tick the checklist box.
446445

447446
| Field | Value |
448447
|-------|--------|
449-
| **Status** | `open` |
450-
| **Severity** | Low |
451-
| **What's happening** | FastAPI app does not set security headers middleware. |
452-
| **Fix** | Add middleware or terminate at CDN/load balancer. |
448+
| **Status** | `done` |
449+
| **Severity** | |
450+
| **What's happening** | `SecurityHeadersMiddleware` sets `X-Content-Type-Options`, `X-Frame-Options`, `Referrer-Policy`, `Permissions-Policy`; HSTS on production/staging and HTTPS requests. |
451+
| **Fix applied** | `backend/app/core/security_headers.py`, wired in `main.py`. |
453452

454453
### OPS-03 · Webhook endpoint public by design
455454

@@ -527,7 +526,7 @@ Items marked *(boot)* are enforced at API startup in production/staging — stil
527526
# Master Checklist C — Do better (quality & growth)
528527

529528
- [ ] **AUTH-04** — Migrate JWT to maintained library (`PyJWT`)
530-
- [ ] **AUTH-07** — Tighten CORS per environment
529+
- [x] **AUTH-07** — Tighten CORS per environment
531530
- [ ] **SYNC-01** — Incremental server pull / sync cursors
532531
- [x] **SYNC-03** — UI to review/retry **dead** sync queue rows
533532
- [ ] **MOB-02** — Refactor large debt/payment widgets for testability
@@ -536,7 +535,7 @@ Items marked *(boot)* are enforced at API startup in production/staging — stil
536535
- [ ] **ARCH-02** — Wire Redis for sessions/rate limits or remove from architecture docs
537536
- [ ] **ARCH-03** — Admin console or remove from public architecture
538537
- [ ] **REPO-01** — Clean scratch artifacts from main branch
539-
- [ ] **OPS-02** — Security headers at edge or in FastAPI
538+
- [x] **OPS-02** — Security headers at edge or in FastAPI
540539

541540
---
542541

@@ -550,7 +549,7 @@ Items marked *(boot)* are enforced at API startup in production/staging — stil
550549
| AUTH-04 | Custom JWT | open |
551550
| AUTH-05 | Mock OTP in prod | done |
552551
| AUTH-06 | Default secret key | done |
553-
| AUTH-07 | CORS `*` | open |
552+
| AUTH-07 | CORS `*` | done |
554553
| AUTH-08 | OTP + sync rate limits | done |
555554
| AUTH-08b | Sync rate limit | done |
556555
| PAY-01 | PaymentService size | done |
@@ -581,7 +580,7 @@ Items marked *(boot)* are enforced at API startup in production/staging — stil
581580
| ARCH-03 | Admin stub | open |
582581
| ARCH-04 | Thin routers | done |
583582
| OPS-01 | Prod checklist | partial |
584-
| OPS-02 | Security headers | open |
583+
| OPS-02 | Security headers | done |
585584
| OPS-03 | Webhook HMAC | done |
586585
| REPO-01 | Scratch files | open |
587586
| REPO-02 | Audit consolidation | done |
@@ -601,8 +600,9 @@ Items marked *(boot)* are enforced at API startup in production/staging — stil
601600
9. ~~**DEBT-03** — Cancel invalidates pending Paystack payment (regression test)~~ ✓ done
602601
10. ~~**DEBT-09** — Block online pay until debt synced + clear messaging~~ ✓ done
603602
11. ~~**SYNC-02** — Merchant-visible conflict resolution~~ ✓ done
603+
12. ~~**OPS-02 + AUTH-07** — Security headers + production CORS hardening~~ ✓ done
604604

605-
**Next suggested:** MOB-02 (widget refactors), OPS-02 (security headers), or SYNC-01 (incremental pull).
605+
**Next suggested:** REPO-01 (scratch cleanup), MOB-02 (widget refactors), or SYNC-01 (incremental pull).
606606

607607
---
608608

@@ -611,6 +611,7 @@ Items marked *(boot)* are enforced at API startup in production/staging — stil
611611
| Path | Notes |
612612
|------|--------|
613613
| `docs/operations/PRODUCTION_DEPLOY_CHECKLIST.md` | OPS-01 deploy runbook |
614+
| `backend/app/core/security_headers.py` | OPS-02 response headers |
614615
| `backend/scripts/check_deploy_env.py` | OPS-01 pre-flight env validation |
615616
| `debt_payment_flow_audit.md` (repo root) | Historical debt audit — see header for pointer here |
616617
| `docs/auth/pin-and-otp-flow.md` | Auth flow design |

0 commit comments

Comments
 (0)