|
2 | 2 |
|
3 | 3 | import uvicorn
|
4 | 4 | from dotenv import load_dotenv
|
5 |
| -from fastapi import FastAPI |
| 5 | +from pathlib import Path |
| 6 | +from fastapi import FastAPI, HTTPException, Request |
6 | 7 | from fastapi.middleware.cors import CORSMiddleware
|
7 | 8 | from fastapi.responses import FileResponse, HTMLResponse
|
8 | 9 | from fastapi.staticfiles import StaticFiles
|
|
23 | 24 | BUILD_DIR = os.path.join(os.path.dirname(__file__), "dist")
|
24 | 25 | INDEX_HTML = os.path.join(BUILD_DIR, "index.html")
|
25 | 26 |
|
| 27 | +# Resolved build directory path (used to prevent path traversal) |
| 28 | +BUILD_DIR_PATH = Path(BUILD_DIR).resolve() |
| 29 | + |
| 30 | +# Security: block serving of certain sensitive files by extension/name |
| 31 | +FORBIDDEN_EXTENSIONS = {'.env', '.py', '.pem', '.key', '.db', '.sqlite', '.toml', '.ini'} |
| 32 | +FORBIDDEN_FILENAMES = {'Dockerfile', '.env', '.secrets', '.gitignore'} |
| 33 | + |
26 | 34 | # Serve static files from build directory
|
27 | 35 | app.mount(
|
28 | 36 | "/assets", StaticFiles(directory=os.path.join(BUILD_DIR, "assets")), name="assets"
|
29 | 37 | )
|
30 | 38 |
|
31 | 39 |
|
| 40 | +@app.middleware("http") |
| 41 | +async def add_security_headers(request: Request, call_next): |
| 42 | + resp = await call_next(request) |
| 43 | + # Basic security headers; applications should extend CSP per app needs |
| 44 | + resp.headers.setdefault("X-Content-Type-Options", "nosniff") |
| 45 | + resp.headers.setdefault("X-Frame-Options", "DENY") |
| 46 | + resp.headers.setdefault("Referrer-Policy", "no-referrer") |
| 47 | + resp.headers.setdefault("Permissions-Policy", "geolocation=(), microphone=()") |
| 48 | + return resp |
| 49 | + |
| 50 | + |
32 | 51 | @app.get("/")
|
33 | 52 | async def serve_index():
|
34 | 53 | return FileResponse(INDEX_HTML)
|
@@ -57,12 +76,57 @@ async def get_config():
|
57 | 76 |
|
58 | 77 | @app.get("/{full_path:path}")
|
59 | 78 | async def serve_app(full_path: str):
|
60 |
| - # First check if file exists in build directory |
61 |
| - file_path = os.path.join(BUILD_DIR, full_path) |
62 |
| - if os.path.exists(file_path): |
63 |
| - return FileResponse(file_path) |
64 |
| - # Otherwise serve index.html for client-side routing |
65 |
| - return FileResponse(INDEX_HTML) |
| 79 | + """ |
| 80 | + Safely serve static files from the build directory or return the SPA index.html. |
| 81 | +
|
| 82 | + Protections: |
| 83 | + - Prevent directory traversal by resolving candidate paths and ensuring they are inside BUILD_DIR. |
| 84 | + - Block dotfiles and sensitive extensions/names. |
| 85 | + - Return 404 on suspicious access instead of leaking details. |
| 86 | + """ |
| 87 | + try: |
| 88 | + candidate = (BUILD_DIR_PATH / full_path).resolve() |
| 89 | + |
| 90 | + # Ensure resolved path is within BUILD_DIR |
| 91 | + if not str(candidate).startswith(str(BUILD_DIR_PATH)): |
| 92 | + raise HTTPException(status_code=404) |
| 93 | + |
| 94 | + # Compute relative parts and block dotfiles anywhere in path |
| 95 | + try: |
| 96 | + rel_parts = candidate.relative_to(BUILD_DIR_PATH).parts |
| 97 | + except Exception: |
| 98 | + raise HTTPException(status_code=404) |
| 99 | + |
| 100 | + if any(part.startswith('.') for part in rel_parts): |
| 101 | + raise HTTPException(status_code=404) |
| 102 | + |
| 103 | + if candidate.name in FORBIDDEN_FILENAMES: |
| 104 | + raise HTTPException(status_code=404) |
| 105 | + |
| 106 | + # If it's a regular file and allowed extension, serve it |
| 107 | + if candidate.is_file(): |
| 108 | + if candidate.suffix.lower() in FORBIDDEN_EXTENSIONS: |
| 109 | + raise HTTPException(status_code=404) |
| 110 | + |
| 111 | + headers = { |
| 112 | + "X-Content-Type-Options": "nosniff", |
| 113 | + "X-Frame-Options": "DENY", |
| 114 | + "Referrer-Policy": "no-referrer", |
| 115 | + } |
| 116 | + return FileResponse(str(candidate), headers=headers) |
| 117 | + |
| 118 | + # Not a file -> fall back to SPA entrypoint |
| 119 | + return FileResponse(INDEX_HTML, headers={ |
| 120 | + "X-Content-Type-Options": "nosniff", |
| 121 | + "X-Frame-Options": "DENY", |
| 122 | + "Referrer-Policy": "no-referrer", |
| 123 | + }) |
| 124 | + |
| 125 | + except HTTPException: |
| 126 | + raise |
| 127 | + except Exception: |
| 128 | + # Hide internal errors and respond with 404 to avoid information leakage |
| 129 | + raise HTTPException(status_code=404) |
66 | 130 |
|
67 | 131 |
|
68 | 132 | if __name__ == "__main__":
|
|
0 commit comments