From c7655527b725d5b925fa741baa396091bfa8a9d9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 09:38:35 +0900 Subject: [PATCH 1/4] test(cors): require every supported API method in preflight --- backend/tests/test_cors_methods.py | 31 ++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 backend/tests/test_cors_methods.py diff --git a/backend/tests/test_cors_methods.py b/backend/tests/test_cors_methods.py new file mode 100644 index 00000000..864c8d58 --- /dev/null +++ b/backend/tests/test_cors_methods.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +from fastapi.testclient import TestClient + +from app.main import app + + +API_CORS_METHODS = ("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS") + + +def test_cors_preflight_allows_every_supported_api_method() -> None: + """Production CORS preflight must accept every HTTP method exposed by the API.""" + + client = TestClient(app) + + for method in API_CORS_METHODS: + response = client.options( + "/api/projects", + headers={ + "Origin": "http://localhost:5173", + "Access-Control-Request-Method": method, + "Access-Control-Request-Headers": "Content-Type", + }, + ) + + assert response.status_code in (200, 204) + allowed_methods = { + item.strip() + for item in response.headers["Access-Control-Allow-Methods"].split(",") + } + assert method in allowed_methods From c319bed12b081f99e03324cf95925b1e139b2f14 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 16:14:10 +0900 Subject: [PATCH 2/4] fix(cors): allow supported mutation methods --- backend/app/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/app/main.py b/backend/app/main.py index ae5788af..e500dda0 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -132,7 +132,7 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]: # actually need cookie-based auth. allow_credentials=False, # Explicit allowlist (avoid "*") so CORS behavior is reviewable. - allow_methods=["GET", "POST", "OPTIONS"], + allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"], allow_headers=CORS_ALLOW_HEADERS, ) From dd096deb87e6f07d8a2a15ca7f935c3012f3d166 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 22:27:06 +0900 Subject: [PATCH 3/4] test(cors): reject unexposed PATCH preflight --- backend/tests/test_cors_methods.py | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/backend/tests/test_cors_methods.py b/backend/tests/test_cors_methods.py index 864c8d58..645e4232 100644 --- a/backend/tests/test_cors_methods.py +++ b/backend/tests/test_cors_methods.py @@ -5,7 +5,8 @@ from app.main import app -API_CORS_METHODS = ("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS") +API_CORS_METHODS = ("GET", "POST", "PUT", "DELETE", "OPTIONS") +UNSUPPORTED_API_CORS_METHOD = "PATCH" def test_cors_preflight_allows_every_supported_api_method() -> None: @@ -29,3 +30,24 @@ def test_cors_preflight_allows_every_supported_api_method() -> None: for item in response.headers["Access-Control-Allow-Methods"].split(",") } assert method in allowed_methods + + +def test_cors_preflight_rejects_unexposed_api_method() -> None: + """Production CORS must reject methods that have no registered API route.""" + + client = TestClient(app) + response = client.options( + "/api/projects", + headers={ + "Origin": "http://localhost:5173", + "Access-Control-Request-Method": UNSUPPORTED_API_CORS_METHOD, + "Access-Control-Request-Headers": "Content-Type", + }, + ) + + assert response.status_code == 400 + allowed_methods = { + item.strip() + for item in response.headers["Access-Control-Allow-Methods"].split(",") + } + assert UNSUPPORTED_API_CORS_METHOD not in allowed_methods From 6ecb76fe10b76cc1c1359d5fa2a7ee497cdd5980 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 22:27:42 +0900 Subject: [PATCH 4/4] fix(cors): remove unexposed PATCH from allowlist --- backend/app/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/app/main.py b/backend/app/main.py index e500dda0..5dd3f06a 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -132,7 +132,7 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]: # actually need cookie-based auth. allow_credentials=False, # Explicit allowlist (avoid "*") so CORS behavior is reviewable. - allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"], + allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"], allow_headers=CORS_ALLOW_HEADERS, )