33
44import uvicorn
55from dotenv import load_dotenv
6- from pathlib import Path
7- from fastapi import FastAPI , HTTPException , Request
6+ from fastapi import FastAPI
87from fastapi .middleware .cors import CORSMiddleware
98from fastapi .responses import FileResponse , HTMLResponse
109from fastapi .staticfiles import StaticFiles
2524BUILD_DIR = os .path .join (os .path .dirname (__file__ ), "build" )
2625INDEX_HTML = os .path .join (BUILD_DIR , "index.html" )
2726
28- # Resolved build directory path (used to prevent path traversal)
29- BUILD_DIR_PATH = Path (BUILD_DIR ).resolve ()
30-
31- # Security: block serving of certain sensitive files by extension/name
32- FORBIDDEN_EXTENSIONS = {'.env' , '.py' , '.pem' , '.key' , '.db' , '.sqlite' , '.toml' , '.ini' }
33- FORBIDDEN_FILENAMES = {'Dockerfile' , '.env' , '.secrets' , '.gitignore' }
34-
3527# Serve static files from build directory
3628app .mount (
3729 "/assets" , StaticFiles (directory = os .path .join (BUILD_DIR , "assets" )), name = "assets"
3830)
3931
4032
41- @app .middleware ("http" )
42- async def add_security_headers (request : Request , call_next ):
43- resp = await call_next (request )
44- # Basic security headers; applications should extend CSP per app needs
45- resp .headers .setdefault ("X-Content-Type-Options" , "nosniff" )
46- resp .headers .setdefault ("X-Frame-Options" , "DENY" )
47- resp .headers .setdefault ("Referrer-Policy" , "no-referrer" )
48- resp .headers .setdefault ("Permissions-Policy" , "geolocation=(), microphone=()" )
49- return resp
50-
51-
5233@app .get ("/" )
5334async def serve_index ():
5435 return FileResponse (INDEX_HTML )
@@ -69,58 +50,14 @@ async def get_config():
6950
7051@app .get ("/{full_path:path}" )
7152async def serve_app (full_path : str ):
72- """
73- Safely serve static files from the build directory or return the SPA index.html.
74-
75- Protections:
76- - Prevent directory traversal by resolving candidate paths and ensuring they are inside BUILD_DIR.
77- - Block dotfiles and sensitive extensions/names.
78- - Return 404 on suspicious access instead of leaking details.
79- """
80- try :
81- # Normalize and join to avoid odd path segments, then resolve.
82- # This mirrors the suggested remediation (normpath + join) but
83- # uses Path.relative_to() as the final containment check.
84- normalized = os .path .normpath (os .path .join (BUILD_DIR , full_path ))
85- candidate = Path (normalized ).resolve ()
86-
87- try :
88- rel_parts = candidate .relative_to (BUILD_DIR_PATH ).parts
89- except Exception :
90- # Not contained -> possible traversal attempt
91- raise HTTPException (status_code = 404 )
92-
93- if any (part .startswith ('.' ) for part in rel_parts ):
94- raise HTTPException (status_code = 404 )
95-
96- if candidate .name in FORBIDDEN_FILENAMES :
97- raise HTTPException (status_code = 404 )
98-
99- # If it's a regular file and allowed extension, serve it
100- if candidate .is_file ():
101- if candidate .suffix .lower () in FORBIDDEN_EXTENSIONS :
102- raise HTTPException (status_code = 404 )
103-
104- headers = {
105- "X-Content-Type-Options" : "nosniff" ,
106- "X-Frame-Options" : "DENY" ,
107- "Referrer-Policy" : "no-referrer" ,
108- }
109- return FileResponse (str (candidate ), headers = headers )
110-
111- # Not a file -> fall back to SPA entrypoint
112- return FileResponse (INDEX_HTML , headers = {
113- "X-Content-Type-Options" : "nosniff" ,
114- "X-Frame-Options" : "DENY" ,
115- "Referrer-Policy" : "no-referrer" ,
116- })
117-
118- except HTTPException :
119- raise
120- except Exception :
121- # Hide internal errors and respond with 404 to avoid information leakage
122- raise HTTPException (status_code = 404 )
123-
53+ # Remediation: normalize and check containment before serving
54+ file_path = os .path .normpath (os .path .join (BUILD_DIR , full_path ))
55+ # Block traversal and dotfiles
56+ if not file_path .startswith (BUILD_DIR ) or ".." in full_path or "/." in full_path or "\\ ." in full_path :
57+ return FileResponse (INDEX_HTML )
58+ if os .path .isfile (file_path ):
59+ return FileResponse (file_path )
60+ return FileResponse (INDEX_HTML )
12461
12562if __name__ == "__main__" :
12663 uvicorn .run (app , host = "127.0.0.1" , port = 3000 )
0 commit comments