Skip to content
Merged
Changes from 2 commits
Commits
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
77 changes: 70 additions & 7 deletions src/frontend/frontend_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,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
Expand All @@ -24,12 +25,30 @@
BUILD_DIR = os.path.join(os.path.dirname(__file__), "build")
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)
Expand All @@ -50,12 +69,56 @@

@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()

# Compute relative parts and block dotfiles anywhere in path.
# Use Path.relative_to() as the canonical containment check; it
# raises an exception if `candidate` is outside `BUILD_DIR_PATH`.
try:
rel_parts = candidate.relative_to(BUILD_DIR_PATH).parts
except Exception:
# Not contained -> possible traversal attempt
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__":
Expand Down
Loading