@@ -108,6 +108,14 @@ def __init__(
108108 "Using path='/' or path='' is not supported."
109109 )
110110 self .directory = Path (directory )
111+ # Precompute the resolved directory so we don't hit the filesystem
112+ # on every request. Fall back to an absolute path if resolve()
113+ # fails (e.g., broken symlink), so the middleware still starts
114+ # and per-request checks can handle resolution errors.
115+ try :
116+ self ._resolved_directory = self .directory .resolve ()
117+ except (RuntimeError , OSError ):
118+ self ._resolved_directory = self .directory .absolute ()
111119 self .app_builder = app_builder
112120 self ._app_cache : dict [str , ASGIApp ] = {}
113121 self .validate_callback = validate_callback
@@ -135,16 +143,35 @@ def _redirect_response(self, scope: Scope) -> Response:
135143 LOGGER .debug (f"Redirecting to: { redirect_url } " )
136144 return RedirectResponse (url = redirect_url , status_code = 307 )
137145
146+ def _is_within_directory (self , path : Path ) -> bool :
147+ """Check that path resolves to a location within self.directory."""
148+ try :
149+ path .resolve ().relative_to (self ._resolved_directory )
150+ return True
151+ except (ValueError , RuntimeError , OSError ):
152+ return False
153+
138154 def _find_matching_file (
139155 self , relative_path : str
140156 ) -> tuple [Path , str ] | None :
141157 """Find a matching Python file in the directory structure.
142158 Returns tuple of (matching file, remaining path) if found, None otherwise.
143159 """
160+ # Reject path traversal segments. Normalize "\" to "/" so the check
161+ # also catches backslash segments, which Windows treats as path
162+ # separators (e.g. "..\\secret" via %5C in the URL).
163+ segments = relative_path .replace ("\\ " , "/" ).split ("/" )
164+ if ".." in segments :
165+ return None
166+
144167 # Try direct match first, skip if relative path has an extension
145168 if not Path (relative_path ).suffix :
146169 direct_match = self .directory / f"{ relative_path } .py"
147- if not direct_match .name .startswith ("_" ) and direct_match .exists ():
170+ if (
171+ not direct_match .name .startswith ("_" )
172+ and self ._is_within_directory (direct_match )
173+ and direct_match .exists ()
174+ ):
148175 return (direct_match , "" )
149176
150177 # Try nested path by progressively checking each part
@@ -159,6 +186,7 @@ def _find_matching_file(
159186 if (
160187 cache_key in self ._app_cache
161188 and not potential_path .name .startswith ("_" )
189+ and self ._is_within_directory (potential_path )
162190 ):
163191 return (potential_path .with_suffix (".py" ), "/" .join (remaining ))
164192
0 commit comments