From a6a2f02d546c5fa920025a766a6e5fe98b63ec16 Mon Sep 17 00:00:00 2001 From: Shuveb Hussain Date: Fri, 13 Mar 2026 12:31:03 +0530 Subject: [PATCH 1/4] =?UTF-8?q?security:=20fix=20VAPT=20vulnerabilities=20?= =?UTF-8?q?=E2=80=94=20WebSocket=20CSWSH,=20rate=20limiting,=20cookie=20Se?= =?UTF-8?q?cure=20flag,=20CSP=20headers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add Origin header validation on WebSocket endpoint to prevent cross-site hijacking (High) - Add slowapi rate limiting on auth endpoints: login, register, me/token, resend-verification (Medium) - Add Secure flag to frontend-set mfbt_session cookie on HTTPS (Low) - Add Content-Security-Policy, X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policy headers to frontend (Next.js) and backend (FastAPI) (Low) Co-Authored-By: Claude Opus 4.6 --- backend/app/main.py | 19 +++++- backend/app/rate_limit.py | 6 ++ backend/app/routers/auth.py | 13 +++- backend/app/routers/websocket.py | 22 +++++++ backend/pyproject.toml | 1 + backend/uv.lock | 104 +++++++++++++++++++++++++++++++ frontend/lib/api/client.ts | 6 +- frontend/next.config.ts | 42 +++++++++++++ 8 files changed, 208 insertions(+), 5 deletions(-) create mode 100644 backend/app/rate_limit.py diff --git a/backend/app/main.py b/backend/app/main.py index 5e78d50..eda3926 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -11,12 +11,16 @@ import logging from contextlib import asynccontextmanager -from fastapi import Depends, FastAPI +from fastapi import Depends, FastAPI, Request from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse +from slowapi import _rate_limit_exceeded_handler +from slowapi.errors import RateLimitExceeded from starlette.middleware.sessions import SessionMiddleware from app.auth.trial import require_active_trial, require_tokens_available from app.config import settings +from app.rate_limit import limiter from app.routers import ( activity, agent_api, @@ -144,6 +148,10 @@ async def lifespan(app: FastAPI): lifespan=lifespan, ) +# Register rate limiter +app.state.limiter = limiter +app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) + # Configure CORS # For development, allow frontend origin to support OAuth cookie flow # Also allow other origins for MCP clients (but those won't get credentials) @@ -253,6 +261,15 @@ async def lifespan(app: FastAPI): app.include_router(_rp.router, prefix=_prefix, dependencies=_deps) +@app.middleware("http") +async def add_security_headers(request: Request, call_next): + """Add security headers to all responses.""" + response = await call_next(request) + response.headers["X-Content-Type-Options"] = "nosniff" + response.headers["X-Frame-Options"] = "DENY" + return response + + @app.get("/health", tags=["health"]) async def health_check(): """ diff --git a/backend/app/rate_limit.py b/backend/app/rate_limit.py new file mode 100644 index 0000000..934a549 --- /dev/null +++ b/backend/app/rate_limit.py @@ -0,0 +1,6 @@ +"""Rate limiting configuration using slowapi.""" + +from slowapi import Limiter +from slowapi.util import get_remote_address + +limiter = Limiter(key_func=get_remote_address) diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index aa6a883..bf07e51 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -22,6 +22,7 @@ from app.auth.dependencies import get_current_user from app.auth.domain_validation import validate_signup_domain +from app.rate_limit import limiter from app.auth.providers import ( get_configured_providers, get_provider_client, @@ -143,7 +144,9 @@ def _get_known_provider_slugs() -> set[str]: @router.post("/register", response_model=RegistrationResponse, status_code=status.HTTP_201_CREATED) +@limiter.limit("5/minute") async def register( + request: Request, user_data: UserCreate, db: Annotated[Session, Depends(get_db)], async_db: Annotated[AsyncSession, Depends(get_async_db)], @@ -266,7 +269,9 @@ async def register( @router.post("/login", response_model=TokenResponse) +@limiter.limit("10/minute") def login( + request: Request, form_data: Annotated[OAuth2PasswordRequestForm, Depends()], db: Annotated[Session, Depends(get_db)], ) -> TokenResponse: @@ -373,7 +378,9 @@ def get_current_user_info( @router.get("/me/token", response_model=TokenResponse) +@limiter.limit("30/minute") def get_session_token( + request: Request, session_cookie: Annotated[str | None, Cookie(alias="mfbt_session")] = None, db: Session = Depends(get_db), ) -> TokenResponse: @@ -658,8 +665,10 @@ def verify_email( @router.post("/resend-verification", response_model=ResendVerificationResponse) +@limiter.limit("3/minute") async def resend_verification( - request: ResendVerificationRequest, + request: Request, + verification_request: ResendVerificationRequest, db: Annotated[Session, Depends(get_db)], async_db: Annotated[AsyncSession, Depends(get_async_db)], ) -> ResendVerificationResponse: @@ -678,7 +687,7 @@ async def resend_verification( Returns: ResendVerificationResponse with generic message """ - user = UserService.get_user_by_email(db, request.email) + user = UserService.get_user_by_email(db, verification_request.email) # Always return the same message for security (don't reveal if email exists) generic_message = "If an account with that email exists and is not yet verified, a verification email has been sent" diff --git a/backend/app/routers/websocket.py b/backend/app/routers/websocket.py index 9310f4b..ee2cb72 100644 --- a/backend/app/routers/websocket.py +++ b/backend/app/routers/websocket.py @@ -2,6 +2,7 @@ import logging from typing import Annotated +from urllib.parse import urlparse from uuid import UUID from fastapi import APIRouter, Query, WebSocket, WebSocketDisconnect, status @@ -9,6 +10,7 @@ from sqlalchemy.orm import Session from app.auth.utils import decode_access_token +from app.config import settings from app.models.user import User from app.permissions.context import OrgContext from app.services.user_service import UserService @@ -19,6 +21,17 @@ router = APIRouter(prefix="/ws", tags=["websocket"]) +def get_allowed_ws_origins() -> set[str]: + """Build the set of allowed WebSocket origins (same as CORS config).""" + origins = { + settings.frontend_url, + "http://localhost:8087", + "http://127.0.0.1:8087", + } + # Normalize: strip trailing slashes + return {o.rstrip("/") for o in origins} + + async def get_current_user_ws( token: str, db: Session, @@ -86,6 +99,15 @@ async def websocket_jobs_endpoint( } } """ + # Validate Origin header to prevent Cross-Site WebSocket Hijacking + origin = websocket.headers.get("origin") + if origin is not None: + normalized_origin = origin.rstrip("/") + if normalized_origin not in get_allowed_ws_origins(): + logger.warning(f"WebSocket rejected: disallowed origin={origin}") + await websocket.close(code=status.WS_1008_POLICY_VIOLATION, reason="Origin not allowed") + return + # Authenticate user with a short-lived DB session (not held during WebSocket lifetime) from app.database import SessionLocal diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 32de2df..3e18d81 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -43,6 +43,7 @@ dependencies = [ "mistune>=3.0", "tavily-python>=0.5.0", "redis>=7.1.0", + "slowapi>=0.1.9", ] [project.optional-dependencies] diff --git a/backend/uv.lock b/backend/uv.lock index 97c8d84..a5d1cf8 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -765,6 +765,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0a/bc/16e0276078c2de3ceef6b5a34b965f4436215efac45313df90d55f0ba2d2/cryptography-45.0.6-cp37-abi3-win_amd64.whl", hash = "sha256:20d15aed3ee522faac1a39fbfdfee25d17b1284bafd808e1640a74846d7c4d1b", size = 3390459, upload-time = "2025-08-05T23:59:03.358Z" }, ] +[[package]] +name = "deprecated" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/49/85/12f0a49a7c4ffb70572b6c2ef13c90c88fd190debda93b23f026b25f9634/deprecated-1.3.1.tar.gz", hash = "sha256:b1b50e0ff0c1fddaa5708a2c6b0a6588bb09b892825ab2b214ac9ea9d92a5223", size = 2932523, upload-time = "2025-10-30T08:19:02.757Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/d0/205d54408c08b13550c733c4b85429e7ead111c7f0014309637425520a9a/deprecated-1.3.1-py2.py3-none-any.whl", hash = "sha256:597bfef186b6f60181535a29fbe44865ce137a5079f295b479886c82729d5f3f", size = 11298, upload-time = "2025-10-30T08:19:00.758Z" }, +] + [[package]] name = "deprecation" version = "2.1.0" @@ -1698,6 +1710,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4a/db/694fd552295ed091e7418d02b6268ee36092d4c93211136c448fe061fe32/kafka_python-2.3.0-py2.py3-none-any.whl", hash = "sha256:831ba6dff28144d0f1145c874d391f3ebb3c2c3e940cc78d74e83f0183497c98", size = 326260, upload-time = "2025-11-21T00:47:32.561Z" }, ] +[[package]] +name = "limits" +version = "5.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "deprecated" }, + { name = "packaging" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/69/826a5d1f45426c68d8f6539f8d275c0e4fcaa57f0c017ec3100986558a41/limits-5.8.0.tar.gz", hash = "sha256:c9e0d74aed837e8f6f50d1fcebcf5fd8130957287206bc3799adaee5092655da", size = 226104, upload-time = "2026-02-05T07:17:35.859Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/98/cb5ca20618d205a09d5bec7591fbc4130369c7e6308d9a676a28ff3ab22c/limits-5.8.0-py3-none-any.whl", hash = "sha256:ae1b008a43eb43073c3c579398bd4eb4c795de60952532dc24720ab45e1ac6b8", size = 60954, upload-time = "2026-02-05T07:17:34.425Z" }, +] + [[package]] name = "litellm" version = "1.80.9" @@ -1870,6 +1896,7 @@ dependencies = [ { name = "python-jose", extra = ["cryptography"] }, { name = "python-multipart" }, { name = "redis" }, + { name = "slowapi" }, { name = "sqlalchemy" }, { name = "tavily-python" }, { name = "tenacity" }, @@ -1926,6 +1953,7 @@ requires-dist = [ { name = "scalekit-sdk-python", marker = "extra == 'enterprise'", specifier = "==2.4.15" }, { name = "slack-bolt", marker = "extra == 'enterprise'", specifier = ">=1.21.0" }, { name = "slack-sdk", marker = "extra == 'enterprise'", specifier = ">=3.33.0" }, + { name = "slowapi", specifier = ">=0.1.9" }, { name = "sqlalchemy", specifier = ">=2.0.44" }, { name = "tavily-python", specifier = ">=0.5.0" }, { name = "tenacity", specifier = ">=9.1.2" }, @@ -3114,6 +3142,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ef/1f/32bcf088e535c1870b1a1f2e3b916129c66fdfe565a793316317241d41e5/slack_sdk-3.39.0-py2.py3-none-any.whl", hash = "sha256:b1556b2f5b8b12b94e5ea3f56c4f2c7f04462e4e1013d325c5764ff118044fa8", size = 309850, upload-time = "2025-11-20T15:27:55.729Z" }, ] +[[package]] +name = "slowapi" +version = "0.1.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "limits" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a0/99/adfc7f94ca024736f061257d39118e1542bade7a52e86415a4c4ae92d8ff/slowapi-0.1.9.tar.gz", hash = "sha256:639192d0f1ca01b1c6d95bf6c71d794c3a9ee189855337b4821f7f457dddad77", size = 14028, upload-time = "2024-02-05T12:11:52.13Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2b/bb/f71c4b7d7e7eb3fc1e8c0458a8979b912f40b58002b9fbf37729b8cb464b/slowapi-0.1.9-py3-none-any.whl", hash = "sha256:cfad116cfb84ad9d763ee155c1e5c5cbf00b0d47399a769b227865f5df576e36", size = 14670, upload-time = "2024-02-05T12:11:50.898Z" }, +] + [[package]] name = "sniffio" version = "1.3.1" @@ -3529,6 +3569,70 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" }, ] +[[package]] +name = "wrapt" +version = "2.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2e/64/925f213fdcbb9baeb1530449ac71a4d57fc361c053d06bf78d0c5c7cd80c/wrapt-2.1.2.tar.gz", hash = "sha256:3996a67eecc2c68fd47b4e3c564405a5777367adfd9b8abb58387b63ee83b21e", size = 81678, upload-time = "2026-03-06T02:53:25.134Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4c/b6/1db817582c49c7fcbb7df6809d0f515af29d7c2fbf57eb44c36e98fb1492/wrapt-2.1.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ff2aad9c4cda28a8f0653fc2d487596458c2a3f475e56ba02909e950a9efa6a9", size = 61255, upload-time = "2026-03-06T02:52:45.663Z" }, + { url = "https://files.pythonhosted.org/packages/a2/16/9b02a6b99c09227c93cd4b73acc3678114154ec38da53043c0ddc1fba0dc/wrapt-2.1.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6433ea84e1cfacf32021d2a4ee909554ade7fd392caa6f7c13f1f4bf7b8e8748", size = 61848, upload-time = "2026-03-06T02:53:48.728Z" }, + { url = "https://files.pythonhosted.org/packages/af/aa/ead46a88f9ec3a432a4832dfedb84092fc35af2d0ba40cd04aea3889f247/wrapt-2.1.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c20b757c268d30d6215916a5fa8461048d023865d888e437fab451139cad6c8e", size = 121433, upload-time = "2026-03-06T02:54:40.328Z" }, + { url = "https://files.pythonhosted.org/packages/3a/9f/742c7c7cdf58b59085a1ee4b6c37b013f66ac33673a7ef4aaed5e992bc33/wrapt-2.1.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:79847b83eb38e70d93dc392c7c5b587efe65b3e7afcc167aa8abd5d60e8761c8", size = 123013, upload-time = "2026-03-06T02:53:26.58Z" }, + { url = "https://files.pythonhosted.org/packages/e8/44/2c3dd45d53236b7ed7c646fcf212251dc19e48e599debd3926b52310fafb/wrapt-2.1.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f8fba1bae256186a83d1875b2b1f4e2d1242e8fac0f58ec0d7e41b26967b965c", size = 117326, upload-time = "2026-03-06T02:53:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/74/e2/b17d66abc26bd96f89dec0ecd0ef03da4a1286e6ff793839ec431b9fae57/wrapt-2.1.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e3d3b35eedcf5f7d022291ecd7533321c4775f7b9cd0050a31a68499ba45757c", size = 121444, upload-time = "2026-03-06T02:54:09.5Z" }, + { url = "https://files.pythonhosted.org/packages/3c/62/e2977843fdf9f03daf1586a0ff49060b1b2fc7ff85a7ea82b6217c1ae36e/wrapt-2.1.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:6f2c5390460de57fa9582bc8a1b7a6c86e1a41dfad74c5225fc07044c15cc8d1", size = 116237, upload-time = "2026-03-06T02:54:03.884Z" }, + { url = "https://files.pythonhosted.org/packages/88/dd/27fc67914e68d740bce512f11734aec08696e6b17641fef8867c00c949fc/wrapt-2.1.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7dfa9f2cf65d027b951d05c662cc99ee3bd01f6e4691ed39848a7a5fffc902b2", size = 120563, upload-time = "2026-03-06T02:53:20.412Z" }, + { url = "https://files.pythonhosted.org/packages/ec/9f/b750b3692ed2ef4705cb305bd68858e73010492b80e43d2a4faa5573cbe7/wrapt-2.1.2-cp312-cp312-win32.whl", hash = "sha256:eba8155747eb2cae4a0b913d9ebd12a1db4d860fc4c829d7578c7b989bd3f2f0", size = 58198, upload-time = "2026-03-06T02:53:37.732Z" }, + { url = "https://files.pythonhosted.org/packages/8e/b2/feecfe29f28483d888d76a48f03c4c4d8afea944dbee2b0cd3380f9df032/wrapt-2.1.2-cp312-cp312-win_amd64.whl", hash = "sha256:1c51c738d7d9faa0b3601708e7e2eda9bf779e1b601dce6c77411f2a1b324a63", size = 60441, upload-time = "2026-03-06T02:52:47.138Z" }, + { url = "https://files.pythonhosted.org/packages/44/e1/e328f605d6e208547ea9fd120804fcdec68536ac748987a68c47c606eea8/wrapt-2.1.2-cp312-cp312-win_arm64.whl", hash = "sha256:c8e46ae8e4032792eb2f677dbd0d557170a8e5524d22acc55199f43efedd39bf", size = 58836, upload-time = "2026-03-06T02:53:22.053Z" }, + { url = "https://files.pythonhosted.org/packages/4c/7a/d936840735c828b38d26a854e85d5338894cda544cb7a85a9d5b8b9c4df7/wrapt-2.1.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:787fd6f4d67befa6fe2abdffcbd3de2d82dfc6fb8a6d850407c53332709d030b", size = 61259, upload-time = "2026-03-06T02:53:41.922Z" }, + { url = "https://files.pythonhosted.org/packages/5e/88/9a9b9a90ac8ca11c2fdb6a286cb3a1fc7dd774c00ed70929a6434f6bc634/wrapt-2.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4bdf26e03e6d0da3f0e9422fd36bcebf7bc0eeb55fdf9c727a09abc6b9fe472e", size = 61851, upload-time = "2026-03-06T02:52:48.672Z" }, + { url = "https://files.pythonhosted.org/packages/03/a9/5b7d6a16fd6533fed2756900fc8fc923f678179aea62ada6d65c92718c00/wrapt-2.1.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bbac24d879aa22998e87f6b3f481a5216311e7d53c7db87f189a7a0266dafffb", size = 121446, upload-time = "2026-03-06T02:54:14.013Z" }, + { url = "https://files.pythonhosted.org/packages/45/bb/34c443690c847835cfe9f892be78c533d4f32366ad2888972c094a897e39/wrapt-2.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:16997dfb9d67addc2e3f41b62a104341e80cac52f91110dece393923c0ebd5ca", size = 123056, upload-time = "2026-03-06T02:54:10.829Z" }, + { url = "https://files.pythonhosted.org/packages/93/b9/ff205f391cb708f67f41ea148545f2b53ff543a7ac293b30d178af4d2271/wrapt-2.1.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:162e4e2ba7542da9027821cb6e7c5e068d64f9a10b5f15512ea28e954893a267", size = 117359, upload-time = "2026-03-06T02:53:03.623Z" }, + { url = "https://files.pythonhosted.org/packages/1f/3d/1ea04d7747825119c3c9a5e0874a40b33594ada92e5649347c457d982805/wrapt-2.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f29c827a8d9936ac320746747a016c4bc66ef639f5cd0d32df24f5eacbf9c69f", size = 121479, upload-time = "2026-03-06T02:53:45.844Z" }, + { url = "https://files.pythonhosted.org/packages/78/cc/ee3a011920c7a023b25e8df26f306b2484a531ab84ca5c96260a73de76c0/wrapt-2.1.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:a9dd9813825f7ecb018c17fd147a01845eb330254dff86d3b5816f20f4d6aaf8", size = 116271, upload-time = "2026-03-06T02:54:46.356Z" }, + { url = "https://files.pythonhosted.org/packages/98/fd/e5ff7ded41b76d802cf1191288473e850d24ba2e39a6ec540f21ae3b57cb/wrapt-2.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6f8dbdd3719e534860d6a78526aafc220e0241f981367018c2875178cf83a413", size = 120573, upload-time = "2026-03-06T02:52:50.163Z" }, + { url = "https://files.pythonhosted.org/packages/47/c5/242cae3b5b080cd09bacef0591691ba1879739050cc7c801ff35c8886b66/wrapt-2.1.2-cp313-cp313-win32.whl", hash = "sha256:5c35b5d82b16a3bc6e0a04349b606a0582bc29f573786aebe98e0c159bc48db6", size = 58205, upload-time = "2026-03-06T02:53:47.494Z" }, + { url = "https://files.pythonhosted.org/packages/12/69/c358c61e7a50f290958809b3c61ebe8b3838ea3e070d7aac9814f95a0528/wrapt-2.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:f8bc1c264d8d1cf5b3560a87bbdd31131573eb25f9f9447bb6252b8d4c44a3a1", size = 60452, upload-time = "2026-03-06T02:53:30.038Z" }, + { url = "https://files.pythonhosted.org/packages/8e/66/c8a6fcfe321295fd8c0ab1bd685b5a01462a9b3aa2f597254462fc2bc975/wrapt-2.1.2-cp313-cp313-win_arm64.whl", hash = "sha256:3beb22f674550d5634642c645aba4c72a2c66fb185ae1aebe1e955fae5a13baf", size = 58842, upload-time = "2026-03-06T02:52:52.114Z" }, + { url = "https://files.pythonhosted.org/packages/da/55/9c7052c349106e0b3f17ae8db4b23a691a963c334de7f9dbd60f8f74a831/wrapt-2.1.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0fc04bc8664a8bc4c8e00b37b5355cffca2535209fba1abb09ae2b7c76ddf82b", size = 63075, upload-time = "2026-03-06T02:53:19.108Z" }, + { url = "https://files.pythonhosted.org/packages/09/a8/ce7b4006f7218248dd71b7b2b732d0710845a0e49213b18faef64811ffef/wrapt-2.1.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a9b9d50c9af998875a1482a038eb05755dfd6fe303a313f6a940bb53a83c3f18", size = 63719, upload-time = "2026-03-06T02:54:33.452Z" }, + { url = "https://files.pythonhosted.org/packages/e4/e5/2ca472e80b9e2b7a17f106bb8f9df1db11e62101652ce210f66935c6af67/wrapt-2.1.2-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2d3ff4f0024dd224290c0eabf0240f1bfc1f26363431505fb1b0283d3b08f11d", size = 152643, upload-time = "2026-03-06T02:52:42.721Z" }, + { url = "https://files.pythonhosted.org/packages/36/42/30f0f2cefca9d9cbf6835f544d825064570203c3e70aa873d8ae12e23791/wrapt-2.1.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3278c471f4468ad544a691b31bb856374fbdefb7fee1a152153e64019379f015", size = 158805, upload-time = "2026-03-06T02:54:25.441Z" }, + { url = "https://files.pythonhosted.org/packages/bb/67/d08672f801f604889dcf58f1a0b424fe3808860ede9e03affc1876b295af/wrapt-2.1.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a8914c754d3134a3032601c6984db1c576e6abaf3fc68094bb8ab1379d75ff92", size = 145990, upload-time = "2026-03-06T02:53:57.456Z" }, + { url = "https://files.pythonhosted.org/packages/68/a7/fd371b02e73babec1de6ade596e8cd9691051058cfdadbfd62a5898f3295/wrapt-2.1.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:ff95d4264e55839be37bafe1536db2ab2de19da6b65f9244f01f332b5286cfbf", size = 155670, upload-time = "2026-03-06T02:54:55.309Z" }, + { url = "https://files.pythonhosted.org/packages/86/2d/9fe0095dfdb621009f40117dcebf41d7396c2c22dca6eac779f4c007b86c/wrapt-2.1.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:76405518ca4e1b76fbb1b9f686cff93aebae03920cc55ceeec48ff9f719c5f67", size = 144357, upload-time = "2026-03-06T02:54:24.092Z" }, + { url = "https://files.pythonhosted.org/packages/0e/b6/ec7b4a254abbe4cde9fa15c5d2cca4518f6b07d0f1b77d4ee9655e30280e/wrapt-2.1.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c0be8b5a74c5824e9359b53e7e58bef71a729bacc82e16587db1c4ebc91f7c5a", size = 150269, upload-time = "2026-03-06T02:53:31.268Z" }, + { url = "https://files.pythonhosted.org/packages/6e/6b/2fabe8ebf148f4ee3c782aae86a795cc68ffe7d432ef550f234025ce0cfa/wrapt-2.1.2-cp313-cp313t-win32.whl", hash = "sha256:f01277d9a5fc1862f26f7626da9cf443bebc0abd2f303f41c5e995b15887dabd", size = 59894, upload-time = "2026-03-06T02:54:15.391Z" }, + { url = "https://files.pythonhosted.org/packages/ca/fb/9ba66fc2dedc936de5f8073c0217b5d4484e966d87723415cc8262c5d9c2/wrapt-2.1.2-cp313-cp313t-win_amd64.whl", hash = "sha256:84ce8f1c2104d2f6daa912b1b5b039f331febfeee74f8042ad4e04992bd95c8f", size = 63197, upload-time = "2026-03-06T02:54:41.943Z" }, + { url = "https://files.pythonhosted.org/packages/c0/1c/012d7423c95d0e337117723eb8ecf73c622ce15a97847e84cf3f8f26cd7e/wrapt-2.1.2-cp313-cp313t-win_arm64.whl", hash = "sha256:a93cd767e37faeddbe07d8fc4212d5cba660af59bdb0f6372c93faaa13e6e679", size = 60363, upload-time = "2026-03-06T02:54:48.093Z" }, + { url = "https://files.pythonhosted.org/packages/39/25/e7ea0b417db02bb796182a5316398a75792cd9a22528783d868755e1f669/wrapt-2.1.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1370e516598854e5b4366e09ce81e08bfe94d42b0fd569b88ec46cc56d9164a9", size = 61418, upload-time = "2026-03-06T02:53:55.706Z" }, + { url = "https://files.pythonhosted.org/packages/ec/0f/fa539e2f6a770249907757eaeb9a5ff4deb41c026f8466c1c6d799088a9b/wrapt-2.1.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6de1a3851c27e0bd6a04ca993ea6f80fc53e6c742ee1601f486c08e9f9b900a9", size = 61914, upload-time = "2026-03-06T02:52:53.37Z" }, + { url = "https://files.pythonhosted.org/packages/53/37/02af1867f5b1441aaeda9c82deed061b7cd1372572ddcd717f6df90b5e93/wrapt-2.1.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:de9f1a2bbc5ac7f6012ec24525bdd444765a2ff64b5985ac6e0692144838542e", size = 120417, upload-time = "2026-03-06T02:54:30.74Z" }, + { url = "https://files.pythonhosted.org/packages/c3/b7/0138a6238c8ba7476c77cf786a807f871672b37f37a422970342308276e7/wrapt-2.1.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:970d57ed83fa040d8b20c52fe74a6ae7e3775ae8cff5efd6a81e06b19078484c", size = 122797, upload-time = "2026-03-06T02:54:51.539Z" }, + { url = "https://files.pythonhosted.org/packages/e1/ad/819ae558036d6a15b7ed290d5b14e209ca795dd4da9c58e50c067d5927b0/wrapt-2.1.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3969c56e4563c375861c8df14fa55146e81ac11c8db49ea6fb7f2ba58bc1ff9a", size = 117350, upload-time = "2026-03-06T02:54:37.651Z" }, + { url = "https://files.pythonhosted.org/packages/8b/2d/afc18dc57a4600a6e594f77a9ae09db54f55ba455440a54886694a84c71b/wrapt-2.1.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:57d7c0c980abdc5f1d98b11a2aa3bb159790add80258c717fa49a99921456d90", size = 121223, upload-time = "2026-03-06T02:54:35.221Z" }, + { url = "https://files.pythonhosted.org/packages/b9/5b/5ec189b22205697bc56eb3b62aed87a1e0423e9c8285d0781c7a83170d15/wrapt-2.1.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:776867878e83130c7a04237010463372e877c1c994d449ca6aaafeab6aab2586", size = 116287, upload-time = "2026-03-06T02:54:19.654Z" }, + { url = "https://files.pythonhosted.org/packages/f7/2d/f84939a7c9b5e6cdd8a8d0f6a26cabf36a0f7e468b967720e8b0cd2bdf69/wrapt-2.1.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:fab036efe5464ec3291411fabb80a7a39e2dd80bae9bcbeeca5087fdfa891e19", size = 119593, upload-time = "2026-03-06T02:54:16.697Z" }, + { url = "https://files.pythonhosted.org/packages/0b/fe/ccd22a1263159c4ac811ab9374c061bcb4a702773f6e06e38de5f81a1bdc/wrapt-2.1.2-cp314-cp314-win32.whl", hash = "sha256:e6ed62c82ddf58d001096ae84ce7f833db97ae2263bff31c9b336ba8cfe3f508", size = 58631, upload-time = "2026-03-06T02:53:06.498Z" }, + { url = "https://files.pythonhosted.org/packages/65/0a/6bd83be7bff2e7efaac7b4ac9748da9d75a34634bbbbc8ad077d527146df/wrapt-2.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:467e7c76315390331c67073073d00662015bb730c566820c9ca9b54e4d67fd04", size = 60875, upload-time = "2026-03-06T02:53:50.252Z" }, + { url = "https://files.pythonhosted.org/packages/6c/c0/0b3056397fe02ff80e5a5d72d627c11eb885d1ca78e71b1a5c1e8c7d45de/wrapt-2.1.2-cp314-cp314-win_arm64.whl", hash = "sha256:da1f00a557c66225d53b095a97eace0fc5349e3bfda28fa34ffae238978ee575", size = 59164, upload-time = "2026-03-06T02:53:59.128Z" }, + { url = "https://files.pythonhosted.org/packages/71/ed/5d89c798741993b2371396eb9d4634f009ff1ad8a6c78d366fe2883ea7a6/wrapt-2.1.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:62503ffbc2d3a69891cf29beeaccdb4d5e0a126e2b6a851688d4777e01428dbb", size = 63163, upload-time = "2026-03-06T02:52:54.873Z" }, + { url = "https://files.pythonhosted.org/packages/c6/8c/05d277d182bf36b0a13d6bd393ed1dec3468a25b59d01fba2dd70fe4d6ae/wrapt-2.1.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c7e6cd120ef837d5b6f860a6ea3745f8763805c418bb2f12eeb1fa6e25f22d22", size = 63723, upload-time = "2026-03-06T02:52:56.374Z" }, + { url = "https://files.pythonhosted.org/packages/f4/27/6c51ec1eff4413c57e72d6106bb8dec6f0c7cdba6503d78f0fa98767bcc9/wrapt-2.1.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3769a77df8e756d65fbc050333f423c01ae012b4f6731aaf70cf2bef61b34596", size = 152652, upload-time = "2026-03-06T02:53:23.79Z" }, + { url = "https://files.pythonhosted.org/packages/db/4c/d7dd662d6963fc7335bfe29d512b02b71cdfa23eeca7ab3ac74a67505deb/wrapt-2.1.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a76d61a2e851996150ba0f80582dd92a870643fa481f3b3846f229de88caf044", size = 158807, upload-time = "2026-03-06T02:53:35.742Z" }, + { url = "https://files.pythonhosted.org/packages/b4/4d/1e5eea1a78d539d346765727422976676615814029522c76b87a95f6bcdd/wrapt-2.1.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6f97edc9842cf215312b75fe737ee7c8adda75a89979f8e11558dfff6343cc4b", size = 146061, upload-time = "2026-03-06T02:52:57.574Z" }, + { url = "https://files.pythonhosted.org/packages/89/bc/62cabea7695cd12a288023251eeefdcb8465056ddaab6227cb78a2de005b/wrapt-2.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4006c351de6d5007aa33a551f600404ba44228a89e833d2fadc5caa5de8edfbf", size = 155667, upload-time = "2026-03-06T02:53:39.422Z" }, + { url = "https://files.pythonhosted.org/packages/e9/99/6f2888cd68588f24df3a76572c69c2de28287acb9e1972bf0c83ce97dbc1/wrapt-2.1.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:a9372fc3639a878c8e7d87e1556fa209091b0a66e912c611e3f833e2c4202be2", size = 144392, upload-time = "2026-03-06T02:54:22.41Z" }, + { url = "https://files.pythonhosted.org/packages/40/51/1dfc783a6c57971614c48e361a82ca3b6da9055879952587bc99fe1a7171/wrapt-2.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3144b027ff30cbd2fca07c0a87e67011adb717eb5f5bd8496325c17e454257a3", size = 150296, upload-time = "2026-03-06T02:54:07.848Z" }, + { url = "https://files.pythonhosted.org/packages/6c/38/cbb8b933a0201076c1f64fc42883b0023002bdc14a4964219154e6ff3350/wrapt-2.1.2-cp314-cp314t-win32.whl", hash = "sha256:3b8d15e52e195813efe5db8cec156eebe339aaf84222f4f4f051a6c01f237ed7", size = 60539, upload-time = "2026-03-06T02:54:00.594Z" }, + { url = "https://files.pythonhosted.org/packages/82/dd/e5176e4b241c9f528402cebb238a36785a628179d7d8b71091154b3e4c9e/wrapt-2.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:08ffa54146a7559f5b8df4b289b46d963a8e74ed16ba3687f99896101a3990c5", size = 63969, upload-time = "2026-03-06T02:54:39Z" }, + { url = "https://files.pythonhosted.org/packages/5c/99/79f17046cf67e4a95b9987ea129632ba8bcec0bc81f3fb3d19bdb0bd60cd/wrapt-2.1.2-cp314-cp314t-win_arm64.whl", hash = "sha256:72aaa9d0d8e4ed0e2e98019cea47a21f823c9dd4b43c7b77bba6679ffcca6a00", size = 60554, upload-time = "2026-03-06T02:53:14.132Z" }, + { url = "https://files.pythonhosted.org/packages/1a/c7/8528ac2dfa2c1e6708f647df7ae144ead13f0a31146f43c7264b4942bf12/wrapt-2.1.2-py3-none-any.whl", hash = "sha256:b8fd6fa2b2c4e7621808f8c62e8317f4aae56e59721ad933bac5239d913cf0e8", size = 43993, upload-time = "2026-03-06T02:53:12.905Z" }, +] + [[package]] name = "yarl" version = "1.22.0" diff --git a/frontend/lib/api/client.ts b/frontend/lib/api/client.ts index 9a5cd6e..d54d3c6 100644 --- a/frontend/lib/api/client.ts +++ b/frontend/lib/api/client.ts @@ -185,7 +185,8 @@ export class ApiClient { if (typeof window !== "undefined") { localStorage.setItem("token", token); // Also set a cookie for server-side access (e.g., generateMetadata) - document.cookie = `mfbt_session=${token}; path=/; max-age=${90 * 24 * 60 * 60}; SameSite=Lax`; + const secure = window.location.protocol === "https:" ? "; Secure" : ""; + document.cookie = `mfbt_session=${token}; path=/; max-age=${90 * 24 * 60 * 60}; SameSite=Lax${secure}`; } } @@ -497,7 +498,8 @@ export class ApiClient { if (token) { this.token = token; // Sync token to cookie for server-side access (e.g., generateMetadata) - document.cookie = `mfbt_session=${token}; path=/; max-age=${90 * 24 * 60 * 60}; SameSite=Lax`; + const secure = window.location.protocol === "https:" ? "; Secure" : ""; + document.cookie = `mfbt_session=${token}; path=/; max-age=${90 * 24 * 60 * 60}; SameSite=Lax${secure}`; return true; } } diff --git a/frontend/next.config.ts b/frontend/next.config.ts index 68a6c64..21023f7 100644 --- a/frontend/next.config.ts +++ b/frontend/next.config.ts @@ -1,7 +1,49 @@ import type { NextConfig } from "next"; +// Backend API URL for CSP connect-src (cross-origin API calls) +const apiUrl = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8086"; + +const securityHeaders = [ + { + key: "Content-Security-Policy", + value: [ + "default-src 'self'", + "script-src 'self' 'unsafe-inline' 'unsafe-eval'", + "style-src 'self' 'unsafe-inline'", + "img-src 'self' data: blob: https:", + "font-src 'self'", + `connect-src 'self' ${apiUrl} ws: wss:`, + "frame-ancestors 'none'", + ].join("; "), + }, + { + key: "X-Frame-Options", + value: "DENY", + }, + { + key: "X-Content-Type-Options", + value: "nosniff", + }, + { + key: "Referrer-Policy", + value: "strict-origin-when-cross-origin", + }, + { + key: "Permissions-Policy", + value: "camera=(), microphone=(), geolocation=()", + }, +]; + const nextConfig: NextConfig = { output: "standalone", + async headers() { + return [ + { + source: "/(.*)", + headers: securityHeaders, + }, + ]; + }, }; export default nextConfig; From f172297528bcda785b5c4e3b6faf0541dca2b22b Mon Sep 17 00:00:00 2001 From: Shuveb Hussain Date: Fri, 13 Mar 2026 12:44:30 +0530 Subject: [PATCH 2/4] style: fix linter issues from pre-commit hooks - Remove unused imports (JSONResponse, urlparse) - Fix import ordering (rate_limit) - Fix indentation in client.ts Co-Authored-By: Claude Opus 4.6 --- backend/app/main.py | 1 - backend/app/routers/auth.py | 2 +- backend/app/routers/websocket.py | 1 - frontend/lib/api/client.ts | 2 +- 4 files changed, 2 insertions(+), 4 deletions(-) diff --git a/backend/app/main.py b/backend/app/main.py index eda3926..c93faf1 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -13,7 +13,6 @@ from fastapi import Depends, FastAPI, Request from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import JSONResponse from slowapi import _rate_limit_exceeded_handler from slowapi.errors import RateLimitExceeded from starlette.middleware.sessions import SessionMiddleware diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index bf07e51..a947205 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -22,7 +22,6 @@ from app.auth.dependencies import get_current_user from app.auth.domain_validation import validate_signup_domain -from app.rate_limit import limiter from app.auth.providers import ( get_configured_providers, get_provider_client, @@ -35,6 +34,7 @@ from app.database import get_async_db, get_db from app.models.user import User from app.plugin_registry import get_plugin_registry +from app.rate_limit import limiter from app.schemas.api_key import ApiKeyCreate from app.schemas.auth import ( OrgMembershipResponse, diff --git a/backend/app/routers/websocket.py b/backend/app/routers/websocket.py index ee2cb72..8ac4a6b 100644 --- a/backend/app/routers/websocket.py +++ b/backend/app/routers/websocket.py @@ -2,7 +2,6 @@ import logging from typing import Annotated -from urllib.parse import urlparse from uuid import UUID from fastapi import APIRouter, Query, WebSocket, WebSocketDisconnect, status diff --git a/frontend/lib/api/client.ts b/frontend/lib/api/client.ts index d54d3c6..dee76c9 100644 --- a/frontend/lib/api/client.ts +++ b/frontend/lib/api/client.ts @@ -499,7 +499,7 @@ export class ApiClient { this.token = token; // Sync token to cookie for server-side access (e.g., generateMetadata) const secure = window.location.protocol === "https:" ? "; Secure" : ""; - document.cookie = `mfbt_session=${token}; path=/; max-age=${90 * 24 * 60 * 60}; SameSite=Lax${secure}`; + document.cookie = `mfbt_session=${token}; path=/; max-age=${90 * 24 * 60 * 60}; SameSite=Lax${secure}`; return true; } } From d0cf4de36beae5fd55bf972ccdc0b203ff42133b Mon Sep 17 00:00:00 2001 From: Shuveb Hussain Date: Fri, 13 Mar 2026 14:27:07 +0530 Subject: [PATCH 3/4] security: address code review feedback on VAPT fixes - Add ProxyHeadersMiddleware so rate limiting uses real client IP behind reverse proxy - Restrict CSP connect-src WebSocket to specific backend host instead of wildcard ws:/wss: - Only include 'unsafe-eval' in CSP script-src during development - Document intentional absent-Origin allowance for MCP/CLI WebSocket clients Co-Authored-By: Claude Opus 4.6 --- backend/app/main.py | 5 +++++ backend/app/routers/websocket.py | 4 +++- frontend/next.config.ts | 7 ++++--- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/backend/app/main.py b/backend/app/main.py index c93faf1..436adb0 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -16,6 +16,7 @@ from slowapi import _rate_limit_exceeded_handler from slowapi.errors import RateLimitExceeded from starlette.middleware.sessions import SessionMiddleware +from uvicorn.middleware.proxy_headers import ProxyHeadersMiddleware from app.auth.trial import require_active_trial, require_tokens_available from app.config import settings @@ -147,6 +148,10 @@ async def lifespan(app: FastAPI): lifespan=lifespan, ) +# Trust X-Forwarded-For from reverse proxy so request.client.host is the real client IP +# (required for rate limiting to work correctly behind nginx/Traefik/load balancers) +app.add_middleware(ProxyHeadersMiddleware, trusted_hosts=["*"]) + # Register rate limiter app.state.limiter = limiter app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) diff --git a/backend/app/routers/websocket.py b/backend/app/routers/websocket.py index 8ac4a6b..52679bc 100644 --- a/backend/app/routers/websocket.py +++ b/backend/app/routers/websocket.py @@ -98,7 +98,9 @@ async def websocket_jobs_endpoint( } } """ - # Validate Origin header to prevent Cross-Site WebSocket Hijacking + # Validate Origin header to prevent Cross-Site WebSocket Hijacking (CSWSH). + # Absent Origin is allowed: non-browser clients (MCP tools, CLI) don't send it. + # These clients are still authenticated via JWT token in the query parameter. origin = websocket.headers.get("origin") if origin is not None: normalized_origin = origin.rstrip("/") diff --git a/frontend/next.config.ts b/frontend/next.config.ts index 21023f7..bd3c693 100644 --- a/frontend/next.config.ts +++ b/frontend/next.config.ts @@ -1,18 +1,19 @@ import type { NextConfig } from "next"; -// Backend API URL for CSP connect-src (cross-origin API calls) +// Backend API URL for CSP connect-src (cross-origin API calls + WebSocket) const apiUrl = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8086"; +const wsUrl = apiUrl.replace(/^http/, "ws"); const securityHeaders = [ { key: "Content-Security-Policy", value: [ "default-src 'self'", - "script-src 'self' 'unsafe-inline' 'unsafe-eval'", + `script-src 'self' 'unsafe-inline'${process.env.NODE_ENV === "development" ? " 'unsafe-eval'" : ""}`, "style-src 'self' 'unsafe-inline'", "img-src 'self' data: blob: https:", "font-src 'self'", - `connect-src 'self' ${apiUrl} ws: wss:`, + `connect-src 'self' ${apiUrl} ${wsUrl}`, "frame-ancestors 'none'", ].join("; "), }, From 3d4a5c3f4c692629c59096aad7344b662f76c279 Mon Sep 17 00:00:00 2001 From: Shuveb Hussain Date: Fri, 13 Mar 2026 14:38:59 +0530 Subject: [PATCH 4/4] security: restrict localhost origins to dev, make trusted proxy IPs configurable - Only allow localhost CORS/WebSocket origins in development environment - Add TRUSTED_PROXY_IPS setting to lock down ProxyHeadersMiddleware in production Co-Authored-By: Claude Opus 4.6 --- backend/app/config.py | 7 +++++++ backend/app/main.py | 13 +++++++------ backend/app/routers/websocket.py | 8 +++----- 3 files changed, 17 insertions(+), 11 deletions(-) diff --git a/backend/app/config.py b/backend/app/config.py index 171834f..8823873 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -39,6 +39,13 @@ class Settings(BaseSettings): "If not set, defaults to http://localhost:8087 when BASE_URL is http://localhost:8086, otherwise defaults to BASE_URL.", ) + # Proxy + trusted_proxy_ips: str | None = Field( + default=None, + description="Comma-separated trusted proxy IPs/CIDRs for X-Forwarded-For (e.g. '10.0.0.0/8'). " + "If unset, all proxies are trusted (only safe when the app is not directly exposed).", + ) + # Database database_url: PostgresDsn = Field( default="postgresql://mfbt:iammfbt@localhost:5432/mfbt_dev", diff --git a/backend/app/main.py b/backend/app/main.py index 436adb0..1c93a90 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -150,7 +150,9 @@ async def lifespan(app: FastAPI): # Trust X-Forwarded-For from reverse proxy so request.client.host is the real client IP # (required for rate limiting to work correctly behind nginx/Traefik/load balancers) -app.add_middleware(ProxyHeadersMiddleware, trusted_hosts=["*"]) +# Set TRUSTED_PROXY_IPS to comma-separated IPs/CIDRs in production (e.g. "10.0.0.0/8") +_trusted_hosts = settings.trusted_proxy_ips.split(",") if settings.trusted_proxy_ips else ["*"] +app.add_middleware(ProxyHeadersMiddleware, trusted_hosts=_trusted_hosts) # Register rate limiter app.state.limiter = limiter @@ -159,13 +161,12 @@ async def lifespan(app: FastAPI): # Configure CORS # For development, allow frontend origin to support OAuth cookie flow # Also allow other origins for MCP clients (but those won't get credentials) +_cors_origins = [settings.frontend_url] +if settings.is_development: + _cors_origins.extend(["http://localhost:8087", "http://127.0.0.1:8087"]) app.add_middleware( CORSMiddleware, - allow_origins=[ - settings.frontend_url, # Frontend origin for OAuth cookie flow - "http://localhost:8087", # Explicit localhost for development - "http://127.0.0.1:8087", # Alternative localhost - ], + allow_origins=_cors_origins, allow_credentials=True, # Required for cookies to work cross-origin allow_methods=["*"], # Allow all methods allow_headers=["*"], # Allow all headers diff --git a/backend/app/routers/websocket.py b/backend/app/routers/websocket.py index 52679bc..494bc48 100644 --- a/backend/app/routers/websocket.py +++ b/backend/app/routers/websocket.py @@ -22,11 +22,9 @@ def get_allowed_ws_origins() -> set[str]: """Build the set of allowed WebSocket origins (same as CORS config).""" - origins = { - settings.frontend_url, - "http://localhost:8087", - "http://127.0.0.1:8087", - } + origins = {settings.frontend_url} + if settings.is_development: + origins.update({"http://localhost:8087", "http://127.0.0.1:8087"}) # Normalize: strip trailing slashes return {o.rstrip("/") for o in origins}