|
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