33
44import uvicorn
55from dotenv import load_dotenv
6- from fastapi import FastAPI
6+ from pathlib import Path
7+ from fastapi import FastAPI , HTTPException , Request
78from fastapi .middleware .cors import CORSMiddleware
89from fastapi .responses import FileResponse , HTMLResponse
910from fastapi .staticfiles import StaticFiles
2425BUILD_DIR = os .path .join (os .path .dirname (__file__ ), "build" )
2526INDEX_HTML = os .path .join (BUILD_DIR , "index.html" )
2627
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+
2735# Serve static files from build directory
2836app .mount (
2937 "/assets" , StaticFiles (directory = os .path .join (BUILD_DIR , "assets" )), name = "assets"
3038)
3139
3240
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+
3352@app .get ("/" )
3453async def serve_index ():
3554 return FileResponse (INDEX_HTML )
@@ -50,12 +69,57 @@ async def get_config():
5069
5170@app .get ("/{full_path:path}" )
5271async def serve_app (full_path : str ):
53- # First check if file exists in build directory
54- file_path = os .path .join (BUILD_DIR , full_path )
55- if os .path .exists (file_path ):
56- return FileResponse (file_path )
57- # Otherwise serve index.html for client-side routing
58- return FileResponse (INDEX_HTML )
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+ candidate = (BUILD_DIR_PATH / full_path ).resolve ()
82+
83+ # Ensure resolved path is within BUILD_DIR
84+ if not str (candidate ).startswith (str (BUILD_DIR_PATH )):
85+ raise HTTPException (status_code = 404 )
86+
87+ # Compute relative parts and block dotfiles anywhere in path
88+ try :
89+ rel_parts = candidate .relative_to (BUILD_DIR_PATH ).parts
90+ except Exception :
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 )
59123
60124
61125if __name__ == "__main__" :
0 commit comments