From e2a37033cf6528172ca9dac7c15ff76db9b4e7f5 Mon Sep 17 00:00:00 2001 From: Christian Lackas Date: Fri, 14 Aug 2026 17:07:59 +0200 Subject: [PATCH] Prevent path traversal in static file routes (#912) static_route and static_route_exts interpolate the {fname:path} URL segment straight into FileResponse(f'{static_path}/{fname}...'). The path converter matches '..', so a request like /%2e%2e/%2e%2e/secret.gz resolves outside static_path and serves any file the process can read. Add _static_fpath, which resolves the requested path against static_path and raises 404 when it escapes, and route both static handlers through it. Nested paths and an explicit static_path above the app dir still work; only '..' within the request path is rejected. --- fasthtml/_modidx.py | 1 + fasthtml/core.py | 13 +++++++++++-- nbs/api/00_core.ipynb | 32 ++++++++++++++++++++++++++++++-- 3 files changed, 42 insertions(+), 4 deletions(-) diff --git a/fasthtml/_modidx.py b/fasthtml/_modidx.py index 5b69b1de..91e4f45d 100644 --- a/fasthtml/_modidx.py +++ b/fasthtml/_modidx.py @@ -130,6 +130,7 @@ 'fasthtml.core._resp': ('api/core.html#_resp', 'fasthtml/core.py'), 'fasthtml.core._route_pn': ('api/core.html#_route_pn', 'fasthtml/core.py'), 'fasthtml.core._send_ws': ('api/core.html#_send_ws', 'fasthtml/core.py'), + 'fasthtml.core._static_fpath': ('api/core.html#_static_fpath', 'fasthtml/core.py'), 'fasthtml.core._to_htmx_header': ('api/core.html#_to_htmx_header', 'fasthtml/core.py'), 'fasthtml.core._to_xml': ('api/core.html#_to_xml', 'fasthtml/core.py'), 'fasthtml.core._url_for': ('api/core.html#_url_for', 'fasthtml/core.py'), diff --git a/fasthtml/core.py b/fasthtml/core.py index 46b4d1b9..1ba224ed 100644 --- a/fasthtml/core.py +++ b/fasthtml/core.py @@ -975,18 +975,27 @@ def reg_re_param(m, s): _static_exts = "ico gif jpg jpeg webm css js woff png svg mp4 webp ttf otf eot woff2 txt html map pdf zip tgz gz csv mp3 wav ogg flac aac doc docx xls xlsx ppt pptx epub mobi bmp tiff avi mov wmv mkv xml yaml yml rar 7z tar bz2 htm xhtml apk dmg exe msi swf iso".split() reg_re_param("static", '|'.join(_static_exts)) +def _static_fpath(static_path, relpath): + "Real path of `relpath` under `static_path`; 404 if it escapes `static_path` (e.g. `..` traversal)." + base = os.path.realpath(static_path) + fpath = os.path.realpath(os.path.join(base, relpath)) + if fpath != base and not fpath.startswith(base + os.sep): raise HTTPException(404) + return fpath + @patch def static_route_exts(self:FastHTML, prefix='/', static_path='.', exts='static'): "Add a static route at URL path `prefix` with files from `static_path` and `exts` defined by `reg_re_param()`" @self.get(f"{prefix}{{fname:path}}.{{ext:{exts}}}") - async def get(fname:str, ext:str): return FileResponse(f'{static_path}/{fname}.{ext}') + async def get(fname:str, ext:str): return FileResponse(_static_fpath(static_path, f'{fname}.{ext}')) + # %% ../nbs/api/00_core.ipynb #b31de65a @patch def static_route(self:FastHTML, ext='', prefix='/', static_path='.'): "Add a static route at URL path `prefix` with files from `static_path` and single `ext` (including the '.')" @self.get(f"{prefix}{{fname:path}}{ext}") - async def get(fname:str): return FileResponse(f'{static_path}/{fname}{ext}') + async def get(fname:str): return FileResponse(_static_fpath(static_path, f'{fname}{ext}')) + # %% ../nbs/api/00_core.ipynb #f63b7a03 class StaticNoCache(StaticFiles): diff --git a/nbs/api/00_core.ipynb b/nbs/api/00_core.ipynb index d8827e07..fae98a8f 100644 --- a/nbs/api/00_core.ipynb +++ b/nbs/api/00_core.ipynb @@ -4349,11 +4349,18 @@ "_static_exts = \"ico gif jpg jpeg webm css js woff png svg mp4 webp ttf otf eot woff2 txt html map pdf zip tgz gz csv mp3 wav ogg flac aac doc docx xls xlsx ppt pptx epub mobi bmp tiff avi mov wmv mkv xml yaml yml rar 7z tar bz2 htm xhtml apk dmg exe msi swf iso\".split()\n", "reg_re_param(\"static\", '|'.join(_static_exts))\n", "\n", + "def _static_fpath(static_path, relpath):\n", + " \"Real path of `relpath` under `static_path`; 404 if it escapes `static_path` (e.g. `..` traversal).\"\n", + " base = os.path.realpath(static_path)\n", + " fpath = os.path.realpath(os.path.join(base, relpath))\n", + " if fpath != base and not fpath.startswith(base + os.sep): raise HTTPException(404)\n", + " return fpath\n", + "\n", "@patch\n", "def static_route_exts(self:FastHTML, prefix='/', static_path='.', exts='static'):\n", " \"Add a static route at URL path `prefix` with files from `static_path` and `exts` defined by `reg_re_param()`\"\n", " @self.get(f\"{prefix}{{fname:path}}.{{ext:{exts}}}\")\n", - " async def get(fname:str, ext:str): return FileResponse(f'{static_path}/{fname}.{ext}')" + " async def get(fname:str, ext:str): return FileResponse(_static_fpath(static_path, f'{fname}.{ext}'))\n" ] }, { @@ -4394,7 +4401,7 @@ "def static_route(self:FastHTML, ext='', prefix='/', static_path='.'):\n", " \"Add a static route at URL path `prefix` with files from `static_path` and single `ext` (including the '.')\"\n", " @self.get(f\"{prefix}{{fname:path}}{ext}\")\n", - " async def get(fname:str): return FileResponse(f'{static_path}/{fname}{ext}')" + " async def get(fname:str): return FileResponse(_static_fpath(static_path, f'{fname}{ext}'))\n" ] }, { @@ -4408,6 +4415,27 @@ "assert 'THIS FILE WAS AUTOGENERATED' in cli.get('/README.md').text" ] }, + { + "cell_type": "code", + "execution_count": null, + "id": "6a56e464", + "metadata": {}, + "outputs": [], + "source": [ + "# `..` in the URL path must not escape `static_path`, even to a file that exists (CVE-style path traversal)\n", + "import tempfile\n", + "with tempfile.TemporaryDirectory() as _root:\n", + " _pub = Path(_root)/'public'; _pub.mkdir()\n", + " (_pub/'ok.md').write_text('public')\n", + " (Path(_root)/'secret.md').write_text('SECRET') # sibling of static_path, outside it\n", + " _ta = FastHTML()\n", + " _ta.static_route('.md', static_path=str(_pub))\n", + " _tc = Client(_ta)\n", + " test_eq(_tc.get('/ok.md').text, 'public') # legit file still served\n", + " test_eq(_tc.get('/%2e%2e/secret.md').status_code, 404) # encoded `..` to a real file: blocked\n", + " test_eq(_tc.get('/%2e%2e/%2e%2e/etc/passwd.md').status_code, 404)\n" + ] + }, { "cell_type": "code", "execution_count": null,