Skip to content

Commit e2a3703

Browse files
committed
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.
1 parent 23a415f commit e2a3703

3 files changed

Lines changed: 42 additions & 4 deletions

File tree

fasthtml/_modidx.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,7 @@
130130
'fasthtml.core._resp': ('api/core.html#_resp', 'fasthtml/core.py'),
131131
'fasthtml.core._route_pn': ('api/core.html#_route_pn', 'fasthtml/core.py'),
132132
'fasthtml.core._send_ws': ('api/core.html#_send_ws', 'fasthtml/core.py'),
133+
'fasthtml.core._static_fpath': ('api/core.html#_static_fpath', 'fasthtml/core.py'),
133134
'fasthtml.core._to_htmx_header': ('api/core.html#_to_htmx_header', 'fasthtml/core.py'),
134135
'fasthtml.core._to_xml': ('api/core.html#_to_xml', 'fasthtml/core.py'),
135136
'fasthtml.core._url_for': ('api/core.html#_url_for', 'fasthtml/core.py'),

fasthtml/core.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -975,18 +975,27 @@ def reg_re_param(m, s):
975975
_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()
976976
reg_re_param("static", '|'.join(_static_exts))
977977

978+
def _static_fpath(static_path, relpath):
979+
"Real path of `relpath` under `static_path`; 404 if it escapes `static_path` (e.g. `..` traversal)."
980+
base = os.path.realpath(static_path)
981+
fpath = os.path.realpath(os.path.join(base, relpath))
982+
if fpath != base and not fpath.startswith(base + os.sep): raise HTTPException(404)
983+
return fpath
984+
978985
@patch
979986
def static_route_exts(self:FastHTML, prefix='/', static_path='.', exts='static'):
980987
"Add a static route at URL path `prefix` with files from `static_path` and `exts` defined by `reg_re_param()`"
981988
@self.get(f"{prefix}{{fname:path}}.{{ext:{exts}}}")
982-
async def get(fname:str, ext:str): return FileResponse(f'{static_path}/{fname}.{ext}')
989+
async def get(fname:str, ext:str): return FileResponse(_static_fpath(static_path, f'{fname}.{ext}'))
990+
983991

984992
# %% ../nbs/api/00_core.ipynb #b31de65a
985993
@patch
986994
def static_route(self:FastHTML, ext='', prefix='/', static_path='.'):
987995
"Add a static route at URL path `prefix` with files from `static_path` and single `ext` (including the '.')"
988996
@self.get(f"{prefix}{{fname:path}}{ext}")
989-
async def get(fname:str): return FileResponse(f'{static_path}/{fname}{ext}')
997+
async def get(fname:str): return FileResponse(_static_fpath(static_path, f'{fname}{ext}'))
998+
990999

9911000
# %% ../nbs/api/00_core.ipynb #f63b7a03
9921001
class StaticNoCache(StaticFiles):

nbs/api/00_core.ipynb

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4349,11 +4349,18 @@
43494349
"_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",
43504350
"reg_re_param(\"static\", '|'.join(_static_exts))\n",
43514351
"\n",
4352+
"def _static_fpath(static_path, relpath):\n",
4353+
" \"Real path of `relpath` under `static_path`; 404 if it escapes `static_path` (e.g. `..` traversal).\"\n",
4354+
" base = os.path.realpath(static_path)\n",
4355+
" fpath = os.path.realpath(os.path.join(base, relpath))\n",
4356+
" if fpath != base and not fpath.startswith(base + os.sep): raise HTTPException(404)\n",
4357+
" return fpath\n",
4358+
"\n",
43524359
"@patch\n",
43534360
"def static_route_exts(self:FastHTML, prefix='/', static_path='.', exts='static'):\n",
43544361
" \"Add a static route at URL path `prefix` with files from `static_path` and `exts` defined by `reg_re_param()`\"\n",
43554362
" @self.get(f\"{prefix}{{fname:path}}.{{ext:{exts}}}\")\n",
4356-
" async def get(fname:str, ext:str): return FileResponse(f'{static_path}/{fname}.{ext}')"
4363+
" async def get(fname:str, ext:str): return FileResponse(_static_fpath(static_path, f'{fname}.{ext}'))\n"
43574364
]
43584365
},
43594366
{
@@ -4394,7 +4401,7 @@
43944401
"def static_route(self:FastHTML, ext='', prefix='/', static_path='.'):\n",
43954402
" \"Add a static route at URL path `prefix` with files from `static_path` and single `ext` (including the '.')\"\n",
43964403
" @self.get(f\"{prefix}{{fname:path}}{ext}\")\n",
4397-
" async def get(fname:str): return FileResponse(f'{static_path}/{fname}{ext}')"
4404+
" async def get(fname:str): return FileResponse(_static_fpath(static_path, f'{fname}{ext}'))\n"
43984405
]
43994406
},
44004407
{
@@ -4408,6 +4415,27 @@
44084415
"assert 'THIS FILE WAS AUTOGENERATED' in cli.get('/README.md').text"
44094416
]
44104417
},
4418+
{
4419+
"cell_type": "code",
4420+
"execution_count": null,
4421+
"id": "6a56e464",
4422+
"metadata": {},
4423+
"outputs": [],
4424+
"source": [
4425+
"# `..` in the URL path must not escape `static_path`, even to a file that exists (CVE-style path traversal)\n",
4426+
"import tempfile\n",
4427+
"with tempfile.TemporaryDirectory() as _root:\n",
4428+
" _pub = Path(_root)/'public'; _pub.mkdir()\n",
4429+
" (_pub/'ok.md').write_text('public')\n",
4430+
" (Path(_root)/'secret.md').write_text('SECRET') # sibling of static_path, outside it\n",
4431+
" _ta = FastHTML()\n",
4432+
" _ta.static_route('.md', static_path=str(_pub))\n",
4433+
" _tc = Client(_ta)\n",
4434+
" test_eq(_tc.get('/ok.md').text, 'public') # legit file still served\n",
4435+
" test_eq(_tc.get('/%2e%2e/secret.md').status_code, 404) # encoded `..` to a real file: blocked\n",
4436+
" test_eq(_tc.get('/%2e%2e/%2e%2e/etc/passwd.md').status_code, 404)\n"
4437+
]
4438+
},
44114439
{
44124440
"cell_type": "code",
44134441
"execution_count": null,

0 commit comments

Comments
 (0)