Skip to content

Commit 24eac05

Browse files
authored
Merge pull request #804 from seapagan/security/critical-token-and-cors
fix: enforce token type separation and harden CORS defaults
2 parents cc1cd67 + 493b0bf commit 24eac05

File tree

12 files changed

+210
-11
lines changed

12 files changed

+210
-11
lines changed

.env.example

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,8 @@ ACCESS_TOKEN_EXPIRE_MINUTES=120
4545

4646
# List of origins that can access this API, separated by a comma, eg:
4747
# CORS_ORIGINS=http://localhost,https://www.gnramsay.com
48-
# If you want all origins to access (the default), use * or comment out:
48+
# For public APIs using Bearer tokens, * is acceptable but will log a warning.
49+
# Use explicit origins if serving browser clients.
4950
CORS_ORIGINS=*
5051

5152
# Email Settings - OPTIONAL for development, REQUIRED for production

SECURITY-REVIEW.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@
44

55
### 1. Refresh Token Authentication Bypass
66

7+
> [!NOTE]
8+
> **Done**: Access tokens now include `typ="access"` and `get_jwt_user`
9+
> enforces it; refresh or typ-less tokens are rejected.
10+
711
**Location**: `app/managers/auth.py:478-544` (`get_jwt_user`)
812

913
- **Issue**: Refresh tokens can be used as access tokens because access tokens
@@ -19,6 +23,10 @@
1923

2024
### 2. CORS Wildcard with Credentials - SEVERE
2125

26+
> [!NOTE]
27+
> **Done**: CORS credentials are disabled for the API and startup now warns
28+
> when `CORS_ORIGINS=*` is used.
29+
2230
**Location**: `app/main.py:169`, `app/config/settings.py:56`
2331

2432
- **Issue**: Default CORS configuration allows ALL origins (`cors_origins="*"`)

app/main.py

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,13 @@
3535

3636
BLIND_USER_ERROR = 66
3737

38+
# set up CORS
39+
cors_list = [
40+
origin.strip()
41+
for origin in get_settings().cors_origins.split(",")
42+
if origin.strip()
43+
]
44+
3845
# gatekeeper to ensure the user has read the docs and noted the major changes
3946
# since the last version.
4047
if not get_settings().i_read_the_damn_docs:
@@ -62,6 +69,15 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[Any, None]:
6269
# Initialize loguru logging within the server process.
6370
get_log_config()
6471

72+
if "*" in cors_list:
73+
warning_msg = (
74+
"CORS_ORIGINS is set to '*', allowing any origin to access the "
75+
"API. This is fine for public APIs with bearer tokens, but you "
76+
"should set explicit origins if serving browser clients."
77+
)
78+
logger.warning(warning_msg) # Console via uvicorn
79+
loguru_logger.warning(warning_msg) # File via loguru
80+
6581
redis_client = None
6682

6783
# Test database connection
@@ -160,13 +176,10 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[Any, None]:
160176
name="static",
161177
)
162178

163-
# set up CORS
164-
cors_list = (get_settings().cors_origins).split(",")
165-
166179
app.add_middleware(
167180
CORSMiddleware,
168181
allow_origins=cors_list,
169-
allow_credentials=True,
182+
allow_credentials=False,
170183
allow_methods=["*"],
171184
allow_headers=["*"],
172185
)

app/managers/auth.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ def encode_token(user: User) -> str:
5252
try:
5353
payload = {
5454
"sub": user.id,
55+
"typ": "access",
5556
"exp": datetime.datetime.now(tz=datetime.timezone.utc)
5657
+ datetime.timedelta(
5758
minutes=get_settings().access_token_expire_minutes
@@ -492,6 +493,16 @@ async def get_jwt_user(
492493
algorithms=["HS256"],
493494
options={"verify_sub": False},
494495
)
496+
if payload.get("typ") != "access":
497+
increment_auth_failure("invalid_token", "jwt")
498+
category_logger.warning(
499+
"Authentication attempted with non-access token",
500+
LogCategory.AUTH,
501+
)
502+
raise HTTPException(
503+
status_code=status.HTTP_401_UNAUTHORIZED,
504+
detail=ResponseMessages.INVALID_TOKEN,
505+
)
495506
user_data = await get_user_by_id_(payload["sub"], db)
496507

497508
# Check user validity - user must exist, be verified, and not banned

docs/deployment/deployment.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,8 @@ Before deploying to production, ensure you've configured all critical settings p
1616
- Used for JWT token signing and session security
1717

1818
- **CORS_ORIGINS**
19-
- **Don't** leave as `*` in production
20-
- Set to your actual frontend domain(s)
19+
- For browser clients, set to your actual frontend domain(s)
20+
- For public APIs using Bearer tokens, `*` is acceptable but will log a warning
2121
- Example: `CORS_ORIGINS=https://app.example.com,https://www.example.com`
2222
- Multiple origins separated by commas
2323

docs/important.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,12 @@ API.
66

77
## Breaking Changes in `HEAD`
88

9-
None.
9+
### CORS credentials disabled by default
10+
11+
Credentialed CORS requests are now disabled for the API. If you relied on
12+
cookie-based auth from browser clients, you must switch to Bearer tokens or
13+
explicitly re-enable credentials and restrict `CORS_ORIGINS` to your frontend
14+
domains.
1015

1116
## Breaking Changes in `0.8.0`
1217

docs/usage/configuration/environment.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -178,9 +178,11 @@ is an HTTP-header based mechanism that allows a server to indicate any origins
178178
(domain, scheme, or port) other than its own from which a browser should permit
179179
loading resources.
180180

181-
For a **PUBLIC API** (unless its going through an API gateway!), set
182-
`CORS_ORIGINS=*`, otherwise list the domains (**and ports**) required. If you
183-
use an API gateway of some nature, that will probably need to be listed.
181+
For a **PUBLIC API** using Bearer tokens, `CORS_ORIGINS=*` is acceptable.
182+
If you serve browser clients, list the required domains (**and ports**) instead
183+
to restrict access. The app will log a warning when `CORS_ORIGINS=*` is set so
184+
you can confirm the intent. If you use an API gateway, that will probably need
185+
to be listed.
184186

185187
```ini
186188
CORS_ORIGINS=*

tests/integration/test_protected_user_routes.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,15 @@
11
"""Integration tests for user routes."""
22

3+
import datetime
4+
5+
import jwt
36
import pytest
47
from fastapi import status
58

9+
from app.config.settings import get_settings
610
from app.database.helpers import hash_password
11+
from app.managers.auth import AuthManager
12+
from app.models.user import User
713

814

915
@pytest.mark.integration
@@ -64,3 +70,57 @@ async def test_routes_bad_auth(self, client, route) -> None:
6470

6571
assert response.status_code == status.HTTP_401_UNAUTHORIZED
6672
assert response.json() == {"detail": "That token is Invalid"}
73+
74+
@pytest.mark.asyncio
75+
@pytest.mark.parametrize(
76+
"route",
77+
test_routes,
78+
)
79+
async def test_routes_refresh_token_rejected(
80+
self, client, test_db, route
81+
) -> None:
82+
"""Test that refresh tokens are rejected on protected routes."""
83+
test_user = User(**self.test_user)
84+
test_db.add(test_user)
85+
await test_db.commit()
86+
refresh_token = AuthManager.encode_refresh_token(test_user)
87+
88+
route_name, method = route
89+
fn = getattr(client, method)
90+
response = await fn(
91+
route_name, headers={"Authorization": f"Bearer {refresh_token}"}
92+
)
93+
94+
assert response.status_code == status.HTTP_401_UNAUTHORIZED
95+
assert response.json() == {"detail": "That token is Invalid"}
96+
97+
@pytest.mark.asyncio
98+
@pytest.mark.parametrize(
99+
"route",
100+
test_routes,
101+
)
102+
async def test_routes_missing_typ_rejected(
103+
self, client, test_db, route
104+
) -> None:
105+
"""Test that tokens without typ are rejected on protected routes."""
106+
test_user = User(**self.test_user)
107+
test_db.add(test_user)
108+
await test_db.commit()
109+
token = jwt.encode(
110+
{
111+
"sub": test_user.id,
112+
"exp": datetime.datetime.now(tz=datetime.timezone.utc)
113+
+ datetime.timedelta(minutes=10),
114+
},
115+
get_settings().secret_key,
116+
algorithm="HS256",
117+
)
118+
119+
route_name, method = route
120+
fn = getattr(client, method)
121+
response = await fn(
122+
route_name, headers={"Authorization": f"Bearer {token}"}
123+
)
124+
125+
assert response.status_code == status.HTTP_401_UNAUTHORIZED
126+
assert response.json() == {"detail": "That token is Invalid"}

tests/unit/test_auth_manager.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ def test_encode_token(self) -> None:
4242
options={"verify_sub": False},
4343
)
4444
assert payload["sub"] == 1
45+
assert payload["typ"] == "access"
4546
assert isinstance(payload["exp"], int)
4647
# TODO(seapagan): better comparison to ensure the exp is in the future
4748
# but close to the expected expiry time taking into account the setting
@@ -68,6 +69,7 @@ def test_encode_refresh_token(self) -> None:
6869
)
6970

7071
assert payload["sub"] == 1
72+
assert payload["typ"] == "refresh"
7173
assert isinstance(payload["exp"], int)
7274
# TODO(seapagan): better comparison to ensure the exp is in the future
7375
# but close to the expected expiry time taking into account the expiry

tests/unit/test_cors_config.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
"""Tests for CORS middleware configuration."""
2+
3+
from typing import Any, cast
4+
5+
import pytest
6+
from fastapi.middleware.cors import CORSMiddleware
7+
8+
from app.main import app
9+
10+
11+
@pytest.mark.unit
12+
def test_cors_middleware_disables_credentials() -> None:
13+
"""Ensure the API does not allow credentialed CORS by default."""
14+
cors_middleware = next(
15+
middleware
16+
for middleware in app.user_middleware
17+
if cast("Any", middleware.cls) is CORSMiddleware
18+
)
19+
20+
kwargs = cast("dict[str, Any]", cors_middleware.kwargs)
21+
allow_origins = cast("list[str]", kwargs["allow_origins"])
22+
23+
assert kwargs["allow_credentials"] is False
24+
assert "*" in allow_origins

0 commit comments

Comments
 (0)