|
2 | 2 |
|
3 | 3 | import uvicorn
|
4 | 4 | from dotenv import load_dotenv
|
5 |
| -from pathlib import Path |
6 |
| -from fastapi import FastAPI, HTTPException, Request |
| 5 | +from fastapi import FastAPI |
7 | 6 | from fastapi.middleware.cors import CORSMiddleware
|
8 | 7 | from fastapi.responses import FileResponse, HTMLResponse
|
9 | 8 | from fastapi.staticfiles import StaticFiles
|
|
24 | 23 | BUILD_DIR = os.path.join(os.path.dirname(__file__), "dist")
|
25 | 24 | INDEX_HTML = os.path.join(BUILD_DIR, "index.html")
|
26 | 25 |
|
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 | 27 | # Serve static files from build directory
|
35 | 28 | app.mount(
|
36 | 29 | "/assets", StaticFiles(directory=os.path.join(BUILD_DIR, "assets")), name="assets"
|
37 | 30 | )
|
38 | 31 |
|
39 | 32 |
|
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 |
| - |
51 | 33 | @app.get("/")
|
52 | 34 | async def serve_index():
|
53 | 35 | return FileResponse(INDEX_HTML)
|
@@ -76,57 +58,14 @@ async def get_config():
|
76 | 58 |
|
77 | 59 | @app.get("/{full_path:path}")
|
78 | 60 | async def serve_app(full_path: str):
|
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) |
| 61 | + # Remediation: normalize and check containment before serving |
| 62 | + file_path = os.path.normpath(os.path.join(BUILD_DIR, full_path)) |
| 63 | + # Block traversal and dotfiles |
| 64 | + if not file_path.startswith(BUILD_DIR) or ".." in full_path or "/." in full_path or "\\." in full_path: |
| 65 | + return FileResponse(INDEX_HTML) |
| 66 | + if os.path.isfile(file_path): |
| 67 | + return FileResponse(file_path) |
| 68 | + return FileResponse(INDEX_HTML) |
130 | 69 |
|
131 | 70 |
|
132 | 71 | if __name__ == "__main__":
|
|
0 commit comments