-
Notifications
You must be signed in to change notification settings - Fork 107
Staging-->Main #438
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Staging-->Main #438
Changes from all commits
ced4d4c
d5c93ed
7afba2f
a42427c
3aae129
9a4098b
837f849
414b380
53c4d08
095184f
f859b63
2e1f439
2341d4e
141b5b5
63f1bad
75d5ce3
a7b39e6
fd56f78
f7ff24b
1e12755
83e240e
d56aa94
3f53691
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -1,7 +1,9 @@ | ||||||||||||||||||||||||||||||
| """Application factory for the text2sql FastAPI app.""" | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| import hmac | ||||||||||||||||||||||||||||||
| import logging | ||||||||||||||||||||||||||||||
| import os | ||||||||||||||||||||||||||||||
| import secrets | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| from dotenv import load_dotenv | ||||||||||||||||||||||||||||||
| from fastapi import FastAPI, Request, HTTPException | ||||||||||||||||||||||||||||||
|
|
@@ -54,6 +56,79 @@ async def dispatch(self, request: Request, call_next): | |||||||||||||||||||||||||||||
| return response | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| def _is_secure_request(request: Request) -> bool: | ||||||||||||||||||||||||||||||
| """Determine if the request is over HTTPS.""" | ||||||||||||||||||||||||||||||
| forwarded_proto = request.headers.get("x-forwarded-proto") | ||||||||||||||||||||||||||||||
| if forwarded_proto: | ||||||||||||||||||||||||||||||
| return forwarded_proto == "https" | ||||||||||||||||||||||||||||||
| return request.url.scheme == "https" | ||||||||||||||||||||||||||||||
|
Comment on lines
+61
to
+64
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Harden forwarded-proto parsing for secure-cookie decisions
💡 Proposed fix def _is_secure_request(request: Request) -> bool:
"""Determine if the request is over HTTPS."""
forwarded_proto = request.headers.get("x-forwarded-proto")
if forwarded_proto:
- return forwarded_proto == "https"
+ proto = forwarded_proto.split(",")[0].strip().lower()
+ return proto == "https"
return request.url.scheme == "https"🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| class CSRFMiddleware(BaseHTTPMiddleware): # pylint: disable=too-few-public-methods | ||||||||||||||||||||||||||||||
| """Double Submit Cookie CSRF protection. | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| Sets a csrf_token cookie (readable by JS) on every response. | ||||||||||||||||||||||||||||||
| State-changing requests must echo the cookie value back | ||||||||||||||||||||||||||||||
| via the X-CSRF-Token header. Bearer-token authenticated | ||||||||||||||||||||||||||||||
| requests and auth/login endpoints are exempt. | ||||||||||||||||||||||||||||||
| """ | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| SAFE_METHODS = frozenset({"GET", "HEAD", "OPTIONS", "TRACE"}) | ||||||||||||||||||||||||||||||
| CSRF_COOKIE = "csrf_token" | ||||||||||||||||||||||||||||||
| CSRF_HEADER = "x-csrf-token" | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| # Paths exempt from CSRF validation (auth flow endpoints). | ||||||||||||||||||||||||||||||
| # "/mcp" has no trailing slash so it also covers sub-paths like /mcp/sse. | ||||||||||||||||||||||||||||||
| EXEMPT_PREFIXES = ( | ||||||||||||||||||||||||||||||
| "/login/", | ||||||||||||||||||||||||||||||
| "/signup/", | ||||||||||||||||||||||||||||||
| "/mcp", | ||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| async def dispatch(self, request: Request, call_next): | ||||||||||||||||||||||||||||||
| # Validate CSRF for unsafe, non-exempt, non-Bearer requests | ||||||||||||||||||||||||||||||
| if ( | ||||||||||||||||||||||||||||||
| request.method not in self.SAFE_METHODS | ||||||||||||||||||||||||||||||
| and not request.url.path.startswith(self.EXEMPT_PREFIXES) | ||||||||||||||||||||||||||||||
| and not request.headers.get("authorization", "").lower().startswith("bearer ") | ||||||||||||||||||||||||||||||
| ): | ||||||||||||||||||||||||||||||
|
Comment on lines
+90
to
+94
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Avoid CSRF bypass based only on Skipping CSRF solely because a Bearer header exists allows unsafe requests to bypass CSRF checks even when session cookies are present. This weakens browser-session protection. 💡 Proposed fix- if (
+ auth_header = request.headers.get("authorization", "")
+ is_bearer_request = auth_header.lower().startswith("bearer ")
+ has_session_cookie = "session" in request.cookies
+
+ if (
request.method not in self.SAFE_METHODS
and not request.url.path.startswith(self.EXEMPT_PREFIXES)
- and not request.headers.get("authorization", "").lower().startswith("bearer ")
+ and not (is_bearer_request and not has_session_cookie)
):📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||
| cookie_token = request.cookies.get(self.CSRF_COOKIE) | ||||||||||||||||||||||||||||||
| header_token = request.headers.get(self.CSRF_HEADER) | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| if ( | ||||||||||||||||||||||||||||||
| not cookie_token | ||||||||||||||||||||||||||||||
| or not header_token | ||||||||||||||||||||||||||||||
| or not hmac.compare_digest(cookie_token, header_token) | ||||||||||||||||||||||||||||||
| ): | ||||||||||||||||||||||||||||||
| response = JSONResponse( | ||||||||||||||||||||||||||||||
| status_code=403, | ||||||||||||||||||||||||||||||
| content={"detail": "CSRF token missing or invalid"}, | ||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||
| self._ensure_csrf_cookie(request, response) | ||||||||||||||||||||||||||||||
| return response | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| response = await call_next(request) | ||||||||||||||||||||||||||||||
| self._ensure_csrf_cookie(request, response) | ||||||||||||||||||||||||||||||
| return response | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| # Match the session cookie lifetime (14 days in seconds) | ||||||||||||||||||||||||||||||
| CSRF_COOKIE_MAX_AGE = 60 * 60 * 24 * 14 | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| def _ensure_csrf_cookie(self, request: Request, response): | ||||||||||||||||||||||||||||||
| """Set the CSRF cookie if it is not already present.""" | ||||||||||||||||||||||||||||||
| if not request.cookies.get(self.CSRF_COOKIE): | ||||||||||||||||||||||||||||||
| token = secrets.token_urlsafe(32) | ||||||||||||||||||||||||||||||
| response.set_cookie( | ||||||||||||||||||||||||||||||
| key=self.CSRF_COOKIE, | ||||||||||||||||||||||||||||||
| value=token, | ||||||||||||||||||||||||||||||
| httponly=False, # JS must read this value | ||||||||||||||||||||||||||||||
| samesite="lax", | ||||||||||||||||||||||||||||||
| secure=_is_secure_request(request), | ||||||||||||||||||||||||||||||
| path="/", | ||||||||||||||||||||||||||||||
| max_age=self.CSRF_COOKIE_MAX_AGE, | ||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| def create_app(): | ||||||||||||||||||||||||||||||
| """Create and configure the FastAPI application.""" | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
|
|
@@ -192,6 +267,9 @@ def custom_openapi(): | |||||||||||||||||||||||||||||
| # Add security middleware | ||||||||||||||||||||||||||||||
| app.add_middleware(SecurityMiddleware) | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| # Add CSRF middleware (double-submit cookie pattern) | ||||||||||||||||||||||||||||||
| app.add_middleware(CSRFMiddleware) | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| # Mount static files from the React build (app/dist) | ||||||||||||||||||||||||||||||
| # This serves the bundled assets (JS, CSS, images, etc.) | ||||||||||||||||||||||||||||||
| dist_path = os.path.join(os.path.dirname(__file__), "../app/dist") | ||||||||||||||||||||||||||||||
|
|
@@ -261,7 +339,7 @@ async def handle_oauth_error( | |||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| # Serve React app for all non-API routes (SPA catch-all) | ||||||||||||||||||||||||||||||
| @app.get("/{full_path:path}", include_in_schema=False) | ||||||||||||||||||||||||||||||
| async def serve_react_app(_full_path: str): | ||||||||||||||||||||||||||||||
| async def serve_react_app(full_path: str): # pylint: disable=unused-argument | ||||||||||||||||||||||||||||||
| """Serve the React app for all routes not handled by API endpoints.""" | ||||||||||||||||||||||||||||||
| # Serve index.html for the React SPA | ||||||||||||||||||||||||||||||
| index_path = os.path.join(dist_path, "index.html") | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[major]: Pipfile still pins pytest-playwright~=0.7.1 but the regenerated lockfile resolves pytest-playwright 0.7.2. Pipenv will refuse to install because the lock hashes no longer match the Pipfile entry, so the Playwright 1.58 upgrade cannot be installed. Keep both files on the same version (ideally 0.7.2 which is the first release that supports Playwright 1.58) and regenerate the lockfile.