From 1c117ef958e3da4519f409ea28ba0815af396c98 Mon Sep 17 00:00:00 2001 From: Ravi Date: Thu, 21 Aug 2025 13:15:01 +0530 Subject: [PATCH 1/2] sfi issue fixes --- src/frontend/frontend_server.py | 78 ++++++++++++++++++++++++++++++--- 1 file changed, 71 insertions(+), 7 deletions(-) diff --git a/src/frontend/frontend_server.py b/src/frontend/frontend_server.py index c54d0305..82c609ad 100644 --- a/src/frontend/frontend_server.py +++ b/src/frontend/frontend_server.py @@ -2,7 +2,8 @@ import uvicorn from dotenv import load_dotenv -from fastapi import FastAPI +from pathlib import Path +from fastapi import FastAPI, HTTPException, Request from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse, HTMLResponse from fastapi.staticfiles import StaticFiles @@ -23,12 +24,30 @@ BUILD_DIR = os.path.join(os.path.dirname(__file__), "dist") INDEX_HTML = os.path.join(BUILD_DIR, "index.html") +# Resolved build directory path (used to prevent path traversal) +BUILD_DIR_PATH = Path(BUILD_DIR).resolve() + +# Security: block serving of certain sensitive files by extension/name +FORBIDDEN_EXTENSIONS = {'.env', '.py', '.pem', '.key', '.db', '.sqlite', '.toml', '.ini'} +FORBIDDEN_FILENAMES = {'Dockerfile', '.env', '.secrets', '.gitignore'} + # Serve static files from build directory app.mount( "/assets", StaticFiles(directory=os.path.join(BUILD_DIR, "assets")), name="assets" ) +@app.middleware("http") +async def add_security_headers(request: Request, call_next): + resp = await call_next(request) + # Basic security headers; applications should extend CSP per app needs + resp.headers.setdefault("X-Content-Type-Options", "nosniff") + resp.headers.setdefault("X-Frame-Options", "DENY") + resp.headers.setdefault("Referrer-Policy", "no-referrer") + resp.headers.setdefault("Permissions-Policy", "geolocation=(), microphone=()") + return resp + + @app.get("/") async def serve_index(): return FileResponse(INDEX_HTML) @@ -57,12 +76,57 @@ async def get_config(): @app.get("/{full_path:path}") async def serve_app(full_path: str): - # First check if file exists in build directory - file_path = os.path.join(BUILD_DIR, full_path) - if os.path.exists(file_path): - return FileResponse(file_path) - # Otherwise serve index.html for client-side routing - return FileResponse(INDEX_HTML) + """ + Safely serve static files from the build directory or return the SPA index.html. + + Protections: + - Prevent directory traversal by resolving candidate paths and ensuring they are inside BUILD_DIR. + - Block dotfiles and sensitive extensions/names. + - Return 404 on suspicious access instead of leaking details. + """ + try: + candidate = (BUILD_DIR_PATH / full_path).resolve() + + # Ensure resolved path is within BUILD_DIR + if not str(candidate).startswith(str(BUILD_DIR_PATH)): + raise HTTPException(status_code=404) + + # Compute relative parts and block dotfiles anywhere in path + try: + rel_parts = candidate.relative_to(BUILD_DIR_PATH).parts + except Exception: + raise HTTPException(status_code=404) + + if any(part.startswith('.') for part in rel_parts): + raise HTTPException(status_code=404) + + if candidate.name in FORBIDDEN_FILENAMES: + raise HTTPException(status_code=404) + + # If it's a regular file and allowed extension, serve it + if candidate.is_file(): + if candidate.suffix.lower() in FORBIDDEN_EXTENSIONS: + raise HTTPException(status_code=404) + + headers = { + "X-Content-Type-Options": "nosniff", + "X-Frame-Options": "DENY", + "Referrer-Policy": "no-referrer", + } + return FileResponse(str(candidate), headers=headers) + + # Not a file -> fall back to SPA entrypoint + return FileResponse(INDEX_HTML, headers={ + "X-Content-Type-Options": "nosniff", + "X-Frame-Options": "DENY", + "Referrer-Policy": "no-referrer", + }) + + except HTTPException: + raise + except Exception: + # Hide internal errors and respond with 404 to avoid information leakage + raise HTTPException(status_code=404) if __name__ == "__main__": From f18e98ef08702a7fcbdd71f109072cad03c377dd Mon Sep 17 00:00:00 2001 From: Ravi Date: Thu, 21 Aug 2025 17:04:12 +0530 Subject: [PATCH 2/2] SFI issue fix --- src/frontend/frontend_server.py | 79 ++++----------------------------- 1 file changed, 9 insertions(+), 70 deletions(-) diff --git a/src/frontend/frontend_server.py b/src/frontend/frontend_server.py index 82c609ad..199d1a66 100644 --- a/src/frontend/frontend_server.py +++ b/src/frontend/frontend_server.py @@ -2,8 +2,7 @@ import uvicorn from dotenv import load_dotenv -from pathlib import Path -from fastapi import FastAPI, HTTPException, Request +from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse, HTMLResponse from fastapi.staticfiles import StaticFiles @@ -24,12 +23,6 @@ BUILD_DIR = os.path.join(os.path.dirname(__file__), "dist") INDEX_HTML = os.path.join(BUILD_DIR, "index.html") -# Resolved build directory path (used to prevent path traversal) -BUILD_DIR_PATH = Path(BUILD_DIR).resolve() - -# Security: block serving of certain sensitive files by extension/name -FORBIDDEN_EXTENSIONS = {'.env', '.py', '.pem', '.key', '.db', '.sqlite', '.toml', '.ini'} -FORBIDDEN_FILENAMES = {'Dockerfile', '.env', '.secrets', '.gitignore'} # Serve static files from build directory app.mount( @@ -37,17 +30,6 @@ ) -@app.middleware("http") -async def add_security_headers(request: Request, call_next): - resp = await call_next(request) - # Basic security headers; applications should extend CSP per app needs - resp.headers.setdefault("X-Content-Type-Options", "nosniff") - resp.headers.setdefault("X-Frame-Options", "DENY") - resp.headers.setdefault("Referrer-Policy", "no-referrer") - resp.headers.setdefault("Permissions-Policy", "geolocation=(), microphone=()") - return resp - - @app.get("/") async def serve_index(): return FileResponse(INDEX_HTML) @@ -76,57 +58,14 @@ async def get_config(): @app.get("/{full_path:path}") async def serve_app(full_path: str): - """ - Safely serve static files from the build directory or return the SPA index.html. - - Protections: - - Prevent directory traversal by resolving candidate paths and ensuring they are inside BUILD_DIR. - - Block dotfiles and sensitive extensions/names. - - Return 404 on suspicious access instead of leaking details. - """ - try: - candidate = (BUILD_DIR_PATH / full_path).resolve() - - # Ensure resolved path is within BUILD_DIR - if not str(candidate).startswith(str(BUILD_DIR_PATH)): - raise HTTPException(status_code=404) - - # Compute relative parts and block dotfiles anywhere in path - try: - rel_parts = candidate.relative_to(BUILD_DIR_PATH).parts - except Exception: - raise HTTPException(status_code=404) - - if any(part.startswith('.') for part in rel_parts): - raise HTTPException(status_code=404) - - if candidate.name in FORBIDDEN_FILENAMES: - raise HTTPException(status_code=404) - - # If it's a regular file and allowed extension, serve it - if candidate.is_file(): - if candidate.suffix.lower() in FORBIDDEN_EXTENSIONS: - raise HTTPException(status_code=404) - - headers = { - "X-Content-Type-Options": "nosniff", - "X-Frame-Options": "DENY", - "Referrer-Policy": "no-referrer", - } - return FileResponse(str(candidate), headers=headers) - - # Not a file -> fall back to SPA entrypoint - return FileResponse(INDEX_HTML, headers={ - "X-Content-Type-Options": "nosniff", - "X-Frame-Options": "DENY", - "Referrer-Policy": "no-referrer", - }) - - except HTTPException: - raise - except Exception: - # Hide internal errors and respond with 404 to avoid information leakage - raise HTTPException(status_code=404) + # Remediation: normalize and check containment before serving + file_path = os.path.normpath(os.path.join(BUILD_DIR, full_path)) + # Block traversal and dotfiles + if not file_path.startswith(BUILD_DIR) or ".." in full_path or "/." in full_path or "\\." in full_path: + return FileResponse(INDEX_HTML) + if os.path.isfile(file_path): + return FileResponse(file_path) + return FileResponse(INDEX_HTML) if __name__ == "__main__":