fix: wildcard CORS for public banner API endpoints
Some checks failed
CI / Detect changes (push) Has been cancelled
CI / API Lint (push) Has been cancelled
CI / API Tests (push) Has been cancelled
CI / Scanner Lint (push) Has been cancelled
CI / Scanner Tests (push) Has been cancelled
CI / Banner Lint & Typecheck (push) Has been cancelled
CI / Banner Tests (push) Has been cancelled
CI / Banner Build (push) Has been cancelled
CI / Admin UI Typecheck (push) Has been cancelled
CI / Admin UI Tests (push) Has been cancelled
CI / Admin UI Build (push) Has been cancelled

Replace the fragile per-site dynamic CORS middleware with a public banner
CORS middleware that allows non-credentialed wildcard CORS only for banner
endpoints:

- /api/v1/config/sites/*
- /api/v1/translations/*
- /api/v1/consent/

Admin/auth endpoints remain governed by the normal ALLOWED_ORIGINS based
CORSMiddleware. Add regression tests for public GET/preflight behavior and
for avoiding wildcard CORS on non-public endpoints.
This commit is contained in:
Kunthawat Greethong
2026-06-15 21:12:59 +07:00
parent 0bba7ef21a
commit 27a3e777ae
4 changed files with 164 additions and 139 deletions

View File

@@ -0,0 +1,84 @@
"""CORS behavior for public banner endpoints.
The banner API is embedded on merchant websites, so these public endpoints
must be readable cross-origin without relying on the admin ALLOWED_ORIGINS
setting.
"""
from __future__ import annotations
import uuid
from unittest.mock import AsyncMock, MagicMock
import pytest
from httpx import ASGITransport, AsyncClient
from src.db import get_db
from src.main import create_app
def _db_returning_not_found() -> AsyncMock:
session = AsyncMock()
result = MagicMock()
result.scalar_one_or_none.return_value = None
session.execute = AsyncMock(return_value=result)
return session
@pytest.mark.asyncio
async def test_public_config_get_allows_any_merchant_origin():
app = create_app()
db = _db_returning_not_found()
async def _override_get_db():
yield db
app.dependency_overrides[get_db] = _override_get_db
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
resp = await client.get(
f"/api/v1/config/sites/{uuid.uuid4()}",
headers={"Origin": "https://www.dealplustech.co.th"},
)
assert resp.status_code == 404
assert resp.headers["access-control-allow-origin"] == "*"
assert "access-control-allow-credentials" not in resp.headers
@pytest.mark.asyncio
async def test_public_consent_preflight_allows_json_post_from_any_merchant_origin():
app = create_app()
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
resp = await client.options(
"/api/v1/consent/",
headers={
"Origin": "https://www.dealplustech.co.th",
"Access-Control-Request-Method": "POST",
"Access-Control-Request-Headers": "content-type",
},
)
assert resp.status_code == 204
assert resp.headers["access-control-allow-origin"] == "*"
assert "POST" in resp.headers["access-control-allow-methods"]
assert "content-type" in resp.headers["access-control-allow-headers"].lower()
assert "access-control-allow-credentials" not in resp.headers
@pytest.mark.asyncio
async def test_non_public_endpoint_does_not_get_public_wildcard_cors():
app = create_app()
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
resp = await client.get(
"/health",
headers={"Origin": "https://www.dealplustech.co.th"},
)
assert resp.status_code == 200
assert resp.headers.get("access-control-allow-origin") != "*"