Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
ced4d4c
Initial plan
Copilot Feb 24, 2026
d5c93ed
Fix flaky Playwright e2e tests for database connection verification
Copilot Feb 24, 2026
7afba2f
Merge branch 'staging' into copilot/fix-github-actions-playwright-logs
gkorland Feb 24, 2026
a42427c
Bump playwright from 1.57.0 to 1.58.0
dependabot[bot] Feb 24, 2026
3aae129
Address review feedback: try/catch in retry, finalMessage guards, spe…
Copilot Feb 24, 2026
9a4098b
Rename waitForGraphPresent to waitForGraphs; make database tests serial
Copilot Feb 25, 2026
837f849
Tighten testdb_delete predicate: use === / endsWith instead of includes
Copilot Feb 25, 2026
414b380
Merge pull request #429 from FalkorDB/copilot/fix-github-actions-play…
gkorland Feb 25, 2026
53c4d08
Merge branch 'staging' into dependabot/pip/staging/playwright-1.58.0
gkorland Feb 25, 2026
095184f
Merge pull request #419 from FalkorDB/dependabot/pip/staging/playwrig…
gkorland Feb 25, 2026
f859b63
Merge branch 'main' into staging
gkorland Feb 25, 2026
2e1f439
Bump fastapi, uvicorn, litellm, playwright, and globals (#439)
gkorland Feb 25, 2026
2341d4e
Fix SPA catch-all route not serving index.html (#433)
gkorland Feb 26, 2026
141b5b5
Fix: Add CSRF protection via double-submit cookie pattern (#432)
gkorland Feb 26, 2026
63f1bad
Update @falkordb/canvas version to v0.0.40 (#440)
Anchel123 Feb 26, 2026
75d5ce3
fix(e2e): pass authenticated request context to API calls and browser…
gkorland Feb 26, 2026
a7b39e6
Bump fastapi from 0.133.0 to 0.135.0 (#446)
dependabot[bot] Mar 2, 2026
fd56f78
Bump actions/upload-artifact from 6 to 7 (#444)
dependabot[bot] Mar 2, 2026
f7ff24b
Bump the npm-minor-patch group in /app with 5 updates (#443)
dependabot[bot] Mar 2, 2026
1e12755
perf(ci): accelerate Playwright CI from ~16min to ~5min (#448)
gkorland Mar 2, 2026
83e240e
Bump litellm from 1.81.15 to 1.82.0 (#445)
dependabot[bot] Mar 2, 2026
d56aa94
Bump the npm_and_yarn group across 1 directory with 2 updates (#447)
dependabot[bot] Mar 2, 2026
3f53691
Bump version from 0.0.14 to 0.1.0 (#450)
Copilot Mar 2, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 24 additions & 10 deletions .github/workflows/playwright.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,17 @@ jobs:
uses: actions/setup-python@v6
with:
python-version: ${{ env.PYTHON_VERSION }}
cache: 'pip'
cache-dependency-path: Pipfile.lock

# Setup Node.js
- uses: actions/setup-node@v6
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
cache-dependency-path: |
package-lock.json
app/package-lock.json

# Install pipenv
- name: Install pipenv
Expand All @@ -48,6 +54,14 @@ jobs:
- name: Install root dependencies
run: npm ci

# Cache Playwright browsers
- name: Cache Playwright browsers
id: playwright-cache
uses: actions/cache@v4
with:
path: ~/.cache/ms-playwright
key: playwright-browsers-${{ runner.os }}-${{ hashFiles('package-lock.json') }}

# Install Node dependencies (frontend)
- name: Install frontend dependencies
run: npm ci
Expand All @@ -61,13 +75,8 @@ jobs:
# Start Docker Compose services (test databases)
- name: Start test databases
run: |
docker compose -f e2e/docker-compose.test.yml up -d
# Wait for databases to be healthy
echo "Waiting for databases to be ready..."
sleep 20
docker compose -f e2e/docker-compose.test.yml up -d --wait --wait-timeout 120
docker ps -a
# Verify all containers are running
docker compose -f e2e/docker-compose.test.yml ps

# Start FalkorDB (Redis graph database)
- name: Start FalkorDB
Expand Down Expand Up @@ -133,9 +142,14 @@ jobs:
AZURE_API_BASE: ${{ secrets.AZURE_API_BASE }}
AZURE_API_VERSION: ${{ secrets.AZURE_API_VERSION }}

# Install Playwright browsers
# Install Playwright browsers (Chromium only in CI)
- name: Install Playwright Browsers
run: npx playwright install --with-deps chromium firefox
if: steps.playwright-cache.outputs.cache-hit != 'true'
run: npx playwright install --with-deps chromium

- name: Install Playwright system deps
if: steps.playwright-cache.outputs.cache-hit == 'true'
run: npx playwright install-deps chromium

# Create auth directory for storage state
- name: Create auth directory
Expand All @@ -148,15 +162,15 @@ jobs:
CI: true

# Upload test results on failure
- uses: actions/upload-artifact@v6
- uses: actions/upload-artifact@v7
if: failure()
with:
name: playwright-report
path: playwright-report/
retention-days: 30

# Upload test screenshots on failure
- uses: actions/upload-artifact@v6
- uses: actions/upload-artifact@v7
if: failure()
with:
name: test-results
Expand Down
8 changes: 4 additions & 4 deletions Pipfile
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ verify_ssl = true
name = "pypi"

[packages]
fastapi = "~=0.131.0"
uvicorn = "~=0.40.0"
litellm = "~=1.80.9"
fastapi = "~=0.135.0"
uvicorn = "~=0.41.0"
litellm = "~=1.82.0"
falkordb = "~=1.6.0"
psycopg2-binary = "~=2.9.11"
pymysql = "~=1.1.0"
Expand All @@ -22,7 +22,7 @@ fastmcp = ">=2.13.1"
[dev-packages]
pytest = "~=8.4.2"
pylint = "~=4.0.3"
playwright = "~=1.57.0"
playwright = "~=1.58.0"
Copy link
Copy Markdown

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.

pytest-playwright = "~=0.7.1"
pytest-asyncio = "~=1.2.0"

Expand Down
702 changes: 318 additions & 384 deletions Pipfile.lock

Large diffs are not rendered by default.

80 changes: 79 additions & 1 deletion api/app_factory.py
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
Expand Down Expand Up @@ -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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Harden forwarded-proto parsing for secure-cookie decisions

x-forwarded-proto is often case-variant or comma-separated. Exact equality can mis-detect HTTPS and set CSRF cookies with the wrong secure attribute.

💡 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
Verify each finding against the current code and only fix it if needed.

In `@api/app_factory.py` around lines 61 - 64, The current check uses exact
equality on the raw header value from request.headers.get("x-forwarded-proto"),
which fails for case variants or comma-separated values; update the logic around
the forwarded_proto variable so you normalize and robustly parse the header
(e.g., if forwarded_proto exists, convert to lowercase, split on commas, strip
whitespace and use the first non-empty token) and then compare that token to
"https"; keep the existing fallback to request.url.scheme == "https" when the
header is missing.



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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Avoid CSRF bypass based only on Authorization: Bearer presence

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

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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 ")
):
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 (is_bearer_request and not has_session_cookie)
):
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@api/app_factory.py` around lines 90 - 94, The current CSRF bypass checks skip
protection solely when an Authorization: Bearer header exists; change the
condition so that Bearer-based exemption only applies when there is no browser
session cookie present. Update the if in the CSRF check (the block using
SAFE_METHODS, EXEMPT_PREFIXES and request.headers.get("authorization")) to also
require that request has no cookies (e.g., request.headers.get("cookie") is
empty) or specifically lacks your session cookie name (e.g., "session" /
"sessionid" / "auth"), so that requests with both a Bearer header and a session
cookie do not bypass CSRF; keep the rest of the logic (SAFE_METHODS,
EXEMPT_PREFIXES) unchanged.

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."""

Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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")
Expand Down
Loading
Loading