Skip to content

Commit 41a89c9

Browse files
committed
fix: enhance security setting
1 parent 6cf7435 commit 41a89c9

8 files changed

Lines changed: 192 additions & 44 deletions

File tree

.env.example

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,4 +15,7 @@ JWT_AUDIENCE=todo-modulith-client
1515
ACCESS_TOKEN_EXPIRE_MINUTES=30
1616
REFRESH_TOKEN_EXPIRE_MINUTES=10080
1717

18-
RATE_LIMIT="100/minute"
18+
RATE_LIMIT="100/minute"
19+
CORS_ALLOW_ORIGINS=http://localhost:3000
20+
CORS_ALLOW_METHODS=*
21+
CORS_ALLOW_HEADERS=*

README.md

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -489,38 +489,38 @@ Legend: `Implemented` means code exists in the repository. `Partial` means code
489489
| JWT Authentication | Required | Implemented | `AuthenticationMiddleware` validates bearer tokens for non-public routes. |
490490
| Refresh Token Rotation | Required | Implemented | Refresh flow revokes the old refresh token and persists a new token. |
491491
| RBAC + Permissions | Required | Implemented | Casbin-backed role and permission checks are wired through route dependencies. |
492-
| Rate Limiting (Redis-backed) | Required | Partial | Redis-backed limiter exists, but `apply_global_rate_limit` reads `GLOBAL_RATE_LIMIT` while settings expose `RATE_LIMIT`. |
492+
| Rate Limiting (Redis-backed) | Required | Implemented | Redis-backed limiter reads the configured `RATE_LIMIT` value. |
493493
| Security Headers Middleware | Required | Not Implemented | Add headers such as `X-Content-Type-Options`, `X-Frame-Options` or CSP `frame-ancestors`, `Referrer-Policy`, and production CSP. |
494-
| CORS Configuration | Required | Partial | CORS middleware exists, but production origins, methods, and headers should be environment-driven. |
494+
| CORS Configuration | Required | Implemented | CORS origins, methods, and headers are environment-driven through settings. |
495495
| Request ID Middleware | Required | Not Implemented | Add request/correlation ID generation and response header propagation. |
496496
| Audit Logging | Required | Not Implemented | Add audit events for sensitive auth, user, role, permission, and todo mutations. |
497497
| Structured Logging | Required | Not Implemented | Add structured application logs with request ID, method, path, status, latency, and user context when available. |
498-
| Global Exception Handling | Required | Partial | Exception handlers exist, but `Exception` is registered twice; verify domain and fallback handling behavior. |
498+
| Global Exception Handling | Required | Implemented | Domain exceptions are registered explicitly and `Exception` is used only as the fallback handler. |
499499
| Input Validation | Required | Implemented | Pydantic schemas and application validation functions are used across user and todo flows. |
500500
| Password Hashing (Argon2 or bcrypt) | Required | Implemented | User auth service uses bcrypt hashing. |
501501
| Account Lockout | Required | Not Implemented | Add failed-login tracking and temporary lockout or throttling by account. |
502502
| Token Revocation | Required | Implemented | Refresh tokens are revoked on rotation/logout, and access tokens are denylisted in Redis until expiry. |
503-
| OpenAPI Authentication | Required | Partial | Swagger OAuth2 auth is configured, but `/docs`, `/redoc`, and `/openapi.json` are public; disable them in production or protect them with authentication. |
503+
| OpenAPI Authentication | Required | Implemented | Swagger OAuth2 auth is configured, and docs/OpenAPI endpoints are disabled when `APP_ENV=production`. |
504504
| Health Check Endpoint | Required | Implemented | `/health` endpoint returns service health. |
505505
| Readiness/Liveness Endpoints | Required | Not Implemented | Add separate readiness and liveness endpoints for deployment orchestration. |
506506
| Request Size Limiting | Required | Implemented | `LimitRequestSizeMiddleware` rejects oversized write requests. |
507507
| Idempotency Support (for applicable POST endpoints) | Optional but valuable | Not Implemented | Consider idempotency keys for retry-safe create/payment-like workflows. |
508508
| Database Migrations | Required | Implemented | Alembic is configured with migration commands in the README and Makefile. |
509509
| Dependency Injection | Required | Implemented | FastAPI dependencies wire repositories, handlers, auth, authorization, and database sessions. |
510-
| Configuration via Environment Variables | Required | Partial | Pydantic settings read `.env`, but production validation should reject unsafe defaults. |
510+
| Configuration via Environment Variables | Required | Implemented | Pydantic settings read `.env` and reject the default secret key in production. |
511511

512512
### Next Implementation Checklist
513513

514-
- [ ] Fix and verify rate limit configuration wiring.
514+
- [x] Fix and verify rate limit configuration wiring.
515515
- [ ] Add security headers middleware.
516516
- [ ] Add request ID middleware.
517517
- [ ] Add structured request logging.
518518
- [ ] Add audit logging for sensitive actions.
519519
- [ ] Add account lockout or equivalent failed-login protection.
520-
- [ ] Disable or authenticate `/docs`, `/redoc`, and `/openapi.json` in production.
520+
- [x] Disable or authenticate `/docs`, `/redoc`, and `/openapi.json` in production.
521521
- [ ] Add readiness and liveness endpoints.
522-
- [ ] Add production config validation for secrets and unsafe defaults.
523-
- [ ] Harden CORS through environment-driven allowed origins, methods, and headers.
522+
- [x] Add production config validation for secrets and unsafe defaults.
523+
- [x] Harden CORS through environment-driven allowed origins, methods, and headers.
524524
- [ ] Review exception responses to avoid leaking token parsing details or internal exception messages.
525525
- [ ] Add automated tests for request size limits, rate limiting, auth failures, authorization failures, CORS, security headers, and request IDs.
526526
- [ ] Add dependency vulnerability scanning to local or CI checks, for example `pip-audit` or an equivalent Poetry-compatible scanner.

src/core/bootstrap/exception.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from starlette.exceptions import HTTPException as StarletteHTTPException
44

55
from src.core.exceptions.handler import (
6+
DOMAIN_EXCEPTION_MAP,
67
domain_exception_handler,
78
global_exception_handler,
89
http_exception_handler,
@@ -14,8 +15,7 @@ def register_exception(app: FastAPI):
1415
app.add_exception_handler(RequestValidationError, validation_exception_handler)
1516
app.add_exception_handler(StarletteHTTPException, http_exception_handler)
1617

17-
# Catches custom domain exceptions
18-
app.add_exception_handler(Exception, domain_exception_handler)
18+
for exception_type in DOMAIN_EXCEPTION_MAP:
19+
app.add_exception_handler(exception_type, domain_exception_handler)
1920

20-
# Fallback (Note: order matters, put specific ones first)
2121
app.add_exception_handler(Exception, global_exception_handler)

src/core/bootstrap/middleware.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,9 @@ def register_middleware(app: FastAPI):
1515
)
1616
app.add_middleware(
1717
CORSMiddleware,
18-
allow_origins=["http://localhost:3000"],
18+
allow_origins=settings.cors_allow_origins,
1919
allow_credentials=True,
20-
allow_methods=["*"],
21-
allow_headers=["*"],
20+
allow_methods=settings.cors_allow_methods,
21+
allow_headers=settings.cors_allow_headers,
2222
)
2323
app.add_middleware(AuthenticationMiddleware)

src/core/config/setting.py

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
from functools import lru_cache
2+
from typing import ClassVar
23

3-
from pydantic import Field
4+
from pydantic import Field, model_validator
45
from pydantic_settings import BaseSettings, SettingsConfigDict
56

67

78
class Settings(BaseSettings):
9+
DEFAULT_SECRET_KEY: ClassVar[str] = "super-secret-key-change-in-production"
10+
811
APP_NAME: str = Field(alias="APP_NAME", default="Todo Modulith API")
912
APP_ENV: str = Field(alias="APP_ENV", default="development")
1013
DATABASE_URL: str = Field(
@@ -15,9 +18,7 @@ class Settings(BaseSettings):
1518
alias="REDIS_URL",
1619
default="redis://:eYVX7EwVmmxKPCDmwMtyKVge8oLd2t81@127.0.0.1:6379/0",
1720
)
18-
SECRET_KEY: str = Field(
19-
alias="SECRET_KEY", default="super-secret-key-change-in-production"
20-
)
21+
SECRET_KEY: str = Field(alias="SECRET_KEY", default=DEFAULT_SECRET_KEY)
2122
ALGORITHM: str = Field(alias="ALGORITHM", default="HS256")
2223
JWT_ISSUER: str = Field(alias="JWT_ISSUER", default="todo-modulith-api")
2324
JWT_AUDIENCE: str = Field(alias="JWT_AUDIENCE", default="todo-modulith-client")
@@ -28,10 +29,42 @@ class Settings(BaseSettings):
2829
alias="REFRESH_TOKEN_EXPIRE_MINUTES", default=10080
2930
)
3031
RATE_LIMIT: str = Field(alias="RATE_LIMIT", default="100/minute")
32+
CORS_ALLOW_ORIGINS: str = Field(
33+
alias="CORS_ALLOW_ORIGINS",
34+
default="http://localhost:3000",
35+
)
36+
CORS_ALLOW_METHODS: str = Field(alias="CORS_ALLOW_METHODS", default="*")
37+
CORS_ALLOW_HEADERS: str = Field(alias="CORS_ALLOW_HEADERS", default="*")
3138
MAX_REQUEST_SIZE_MB: int = Field(
3239
alias="MAX_REQUEST_SIZE_MB", default=5 * 1024 * 1024
3340
)
3441

42+
@property
43+
def is_production(self) -> bool:
44+
return self.APP_ENV.lower() == "production"
45+
46+
@property
47+
def cors_allow_origins(self) -> list[str]:
48+
return self._split_csv(self.CORS_ALLOW_ORIGINS)
49+
50+
@property
51+
def cors_allow_methods(self) -> list[str]:
52+
return self._split_csv(self.CORS_ALLOW_METHODS)
53+
54+
@property
55+
def cors_allow_headers(self) -> list[str]:
56+
return self._split_csv(self.CORS_ALLOW_HEADERS)
57+
58+
@staticmethod
59+
def _split_csv(value: str) -> list[str]:
60+
return [item.strip() for item in value.split(",") if item.strip()]
61+
62+
@model_validator(mode="after")
63+
def validate_production_security(self):
64+
if self.is_production and self.SECRET_KEY == self.DEFAULT_SECRET_KEY:
65+
raise ValueError("SECRET_KEY must be changed in production")
66+
return self
67+
3568
model_config = SettingsConfigDict(
3669
env_file=".env",
3770
env_file_encoding="utf-8",

src/core/dependency/rate_limit.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ async def apply_global_rate_limit(request: Request):
5050
if request.url.path in EXEMPT_PATHS:
5151
return
5252

53-
limit_str = settings.GLOBAL_RATE_LIMIT
53+
limit_str = settings.RATE_LIMIT
5454
times_str, period = limit_str.split("/")
5555
times = int(times_str)
5656
seconds = 60 if "minute" in period else 1

src/main.py

Lines changed: 32 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -10,26 +10,35 @@
1010

1111
settings = get_settings()
1212

13-
app = FastAPI(
14-
title=settings.APP_NAME,
15-
version="1.0.0",
16-
lifespan=lifespan.lifespan,
17-
swagger_ui_parameters={
18-
"persistAuthorization": True,
19-
"displayRequestDuration": True,
20-
"filter": True,
21-
"deepLinking": True,
22-
"tryItOutEnabled": True,
23-
},
24-
dependencies=[Depends(apply_global_rate_limit)],
25-
)
26-
27-
register_exception(app=app)
28-
register_middleware(app=app)
29-
v1_router.register_router(app=app)
30-
admin_router.register_router(app=app)
31-
32-
33-
@app.get("/health", tags=["Health Check"])
34-
def health_check():
35-
return {"status": "healthy"}
13+
14+
def create_app(app_settings=settings) -> FastAPI:
15+
app = FastAPI(
16+
title=app_settings.APP_NAME,
17+
version="1.0.0",
18+
lifespan=lifespan.lifespan,
19+
docs_url=None if app_settings.is_production else "/docs",
20+
redoc_url=None if app_settings.is_production else "/redoc",
21+
openapi_url=None if app_settings.is_production else "/openapi.json",
22+
swagger_ui_parameters={
23+
"persistAuthorization": True,
24+
"displayRequestDuration": True,
25+
"filter": True,
26+
"deepLinking": True,
27+
"tryItOutEnabled": True,
28+
},
29+
dependencies=[Depends(apply_global_rate_limit)],
30+
)
31+
32+
register_exception(app=app)
33+
register_middleware(app=app)
34+
v1_router.register_router(app=app)
35+
admin_router.register_router(app=app)
36+
37+
@app.get("/health", tags=["Health Check"])
38+
def health_check():
39+
return {"status": "healthy"}
40+
41+
return app
42+
43+
44+
app = create_app(settings)

tests/core/test_security_todo.py

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import pytest
2+
from fastapi import FastAPI, Request
3+
4+
from src.core.bootstrap.exception import register_exception
5+
from src.core.bootstrap.middleware import register_middleware
6+
from src.core.config.setting import Settings
7+
from src.core.dependency import rate_limit as rate_limit_module
8+
from src.core.dependency.rate_limit import apply_global_rate_limit
9+
from src.core.exceptions.handler import (
10+
DOMAIN_EXCEPTION_MAP,
11+
domain_exception_handler,
12+
global_exception_handler,
13+
)
14+
from src.main import create_app
15+
16+
17+
def test_rate_limit_uses_configured_rate_limit_setting(monkeypatch):
18+
created_limiters = []
19+
20+
class FakeLimiter:
21+
def __init__(self, times: int, seconds: int):
22+
self.times = times
23+
self.seconds = seconds
24+
created_limiters.append(self)
25+
26+
async def __call__(self, request):
27+
return None
28+
29+
request = Request(
30+
{
31+
"type": "http",
32+
"method": "GET",
33+
"path": "/api/v1/todos/",
34+
"headers": [],
35+
"query_string": b"",
36+
"server": ("testserver", 80),
37+
"scheme": "http",
38+
"client": ("testclient", 50000),
39+
}
40+
)
41+
42+
monkeypatch.setattr(rate_limit_module.settings, "RATE_LIMIT", "42/minute")
43+
monkeypatch.setattr(rate_limit_module, "RateLimiter", FakeLimiter)
44+
45+
import asyncio
46+
47+
asyncio.run(apply_global_rate_limit(request))
48+
49+
assert created_limiters[0].times == 42
50+
assert created_limiters[0].seconds == 60
51+
52+
53+
def test_cors_middleware_uses_environment_driven_settings(monkeypatch):
54+
monkeypatch.setattr(
55+
"src.core.bootstrap.middleware.settings",
56+
Settings(
57+
CORS_ALLOW_ORIGINS="https://app.example.com,https://admin.example.com",
58+
CORS_ALLOW_METHODS="GET,POST",
59+
CORS_ALLOW_HEADERS="Authorization,Content-Type",
60+
),
61+
)
62+
app = FastAPI()
63+
64+
register_middleware(app)
65+
66+
cors = next(
67+
middleware
68+
for middleware in app.user_middleware
69+
if middleware.cls.__name__ == "CORSMiddleware"
70+
)
71+
assert cors.kwargs["allow_origins"] == [
72+
"https://app.example.com",
73+
"https://admin.example.com",
74+
]
75+
assert cors.kwargs["allow_methods"] == ["GET", "POST"]
76+
assert cors.kwargs["allow_headers"] == ["Authorization", "Content-Type"]
77+
78+
79+
def test_register_exception_uses_specific_domain_handlers_and_single_fallback():
80+
app = FastAPI()
81+
82+
register_exception(app)
83+
84+
for exception_type in DOMAIN_EXCEPTION_MAP:
85+
assert app.exception_handlers[exception_type] is domain_exception_handler
86+
assert app.exception_handlers[Exception] is global_exception_handler
87+
88+
89+
def test_create_app_disables_openapi_entrypoints_in_production():
90+
app = create_app(Settings(APP_ENV="production", SECRET_KEY="production-secret"))
91+
92+
assert app.docs_url is None
93+
assert app.redoc_url is None
94+
assert app.openapi_url is None
95+
96+
97+
def test_production_settings_reject_default_secret_key():
98+
with pytest.raises(ValueError, match="SECRET_KEY must be changed"):
99+
Settings(
100+
APP_ENV="production",
101+
SECRET_KEY=Settings.DEFAULT_SECRET_KEY,
102+
_env_file=None,
103+
)

0 commit comments

Comments
 (0)