Skip to content

Commit ffbc432

Browse files
Escape filenames and paths in HTML when generating index pages (#8317)
Co-authored-by: J. Nick Koston <[email protected]>
1 parent c29945a commit ffbc432

File tree

3 files changed

+118
-19
lines changed

3 files changed

+118
-19
lines changed

CHANGES/8317.bugfix.rst

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Escaped filenames in static view -- by :user:`bdraco`.

aiohttp/web_urldispatcher.py

+7-5
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import abc
22
import asyncio
33
import base64
4+
import functools
45
import hashlib
6+
import html
57
import keyword
68
import os
79
import re
@@ -87,6 +89,8 @@
8789
_ExpectHandler = Callable[[Request], Awaitable[Optional[StreamResponse]]]
8890
_Resolve = Tuple[Optional["UrlMappingMatchInfo"], Set[str]]
8991

92+
html_escape = functools.partial(html.escape, quote=True)
93+
9094

9195
class _InfoDict(TypedDict, total=False):
9296
path: str
@@ -686,15 +690,15 @@ def _directory_as_html(self, filepath: Path) -> str:
686690
assert filepath.is_dir()
687691

688692
relative_path_to_dir = filepath.relative_to(self._directory).as_posix()
689-
index_of = f"Index of /{relative_path_to_dir}"
693+
index_of = f"Index of /{html_escape(relative_path_to_dir)}"
690694
h1 = f"<h1>{index_of}</h1>"
691695

692696
index_list = []
693697
dir_index = filepath.iterdir()
694698
for _file in sorted(dir_index):
695699
# show file url as relative to static path
696700
rel_path = _file.relative_to(self._directory).as_posix()
697-
file_url = self._prefix + "/" + rel_path
701+
quoted_file_url = _quote_path(f"{self._prefix}/{rel_path}")
698702

699703
# if file is a directory, add '/' to the end of the name
700704
if _file.is_dir():
@@ -703,9 +707,7 @@ def _directory_as_html(self, filepath: Path) -> str:
703707
file_name = _file.name
704708

705709
index_list.append(
706-
'<li><a href="{url}">{name}</a></li>'.format(
707-
url=file_url, name=file_name
708-
)
710+
f'<li><a href="{quoted_file_url}">{html_escape(file_name)}</a></li>'
709711
)
710712
ul = "<ul>\n{}\n</ul>".format("\n".join(index_list))
711713
body = f"<body>\n{h1}\n{ul}\n</body>"

tests/test_web_urldispatcher.py

+110-14
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import asyncio
22
import functools
33
import pathlib
4+
import sys
45
from typing import Optional
56
from unittest import mock
67
from unittest.mock import MagicMock
@@ -14,31 +15,38 @@
1415

1516

1617
@pytest.mark.parametrize(
17-
"show_index,status,prefix,data",
18+
"show_index,status,prefix,request_path,data",
1819
[
19-
pytest.param(False, 403, "/", None, id="index_forbidden"),
20+
pytest.param(False, 403, "/", "/", None, id="index_forbidden"),
2021
pytest.param(
2122
True,
2223
200,
2324
"/",
24-
b"<html>\n<head>\n<title>Index of /.</title>\n"
25-
b"</head>\n<body>\n<h1>Index of /.</h1>\n<ul>\n"
26-
b'<li><a href="/my_dir">my_dir/</a></li>\n'
27-
b'<li><a href="/my_file">my_file</a></li>\n'
28-
b"</ul>\n</body>\n</html>",
29-
id="index_root",
25+
"/",
26+
b"<html>\n<head>\n<title>Index of /.</title>\n</head>\n<body>\n<h1>Index of"
27+
b' /.</h1>\n<ul>\n<li><a href="/my_dir">my_dir/</a></li>\n<li><a href="/my_file">'
28+
b"my_file</a></li>\n</ul>\n</body>\n</html>",
3029
),
3130
pytest.param(
3231
True,
3332
200,
3433
"/static",
35-
b"<html>\n<head>\n<title>Index of /.</title>\n"
36-
b"</head>\n<body>\n<h1>Index of /.</h1>\n<ul>\n"
37-
b'<li><a href="/static/my_dir">my_dir/</a></li>\n'
38-
b'<li><a href="/static/my_file">my_file</a></li>\n'
39-
b"</ul>\n</body>\n</html>",
34+
"/static",
35+
b"<html>\n<head>\n<title>Index of /.</title>\n</head>\n<body>\n<h1>Index of"
36+
b' /.</h1>\n<ul>\n<li><a href="/static/my_dir">my_dir/</a></li>\n<li><a href="'
37+
b'/static/my_file">my_file</a></li>\n</ul>\n</body>\n</html>',
4038
id="index_static",
4139
),
40+
pytest.param(
41+
True,
42+
200,
43+
"/static",
44+
"/static/my_dir",
45+
b"<html>\n<head>\n<title>Index of /my_dir</title>\n</head>\n<body>\n<h1>"
46+
b'Index of /my_dir</h1>\n<ul>\n<li><a href="/static/my_dir/my_file_in_dir">'
47+
b"my_file_in_dir</a></li>\n</ul>\n</body>\n</html>",
48+
id="index_subdir",
49+
),
4250
],
4351
)
4452
async def test_access_root_of_static_handler(
@@ -47,6 +55,7 @@ async def test_access_root_of_static_handler(
4755
show_index: bool,
4856
status: int,
4957
prefix: str,
58+
request_path: str,
5059
data: Optional[bytes],
5160
) -> None:
5261
# Tests the operation of static file server.
@@ -71,7 +80,94 @@ async def test_access_root_of_static_handler(
7180
client = await aiohttp_client(app)
7281

7382
# Request the root of the static directory.
74-
async with await client.get(prefix) as r:
83+
async with await client.get(request_path) as r:
84+
assert r.status == status
85+
86+
if data:
87+
assert r.headers["Content-Type"] == "text/html; charset=utf-8"
88+
read_ = await r.read()
89+
assert read_ == data
90+
91+
92+
@pytest.mark.internal # Dependent on filesystem
93+
@pytest.mark.skipif(
94+
not sys.platform.startswith("linux"),
95+
reason="Invalid filenames on some filesystems (like Windows)",
96+
)
97+
@pytest.mark.parametrize(
98+
"show_index,status,prefix,request_path,data",
99+
[
100+
pytest.param(False, 403, "/", "/", None, id="index_forbidden"),
101+
pytest.param(
102+
True,
103+
200,
104+
"/",
105+
"/",
106+
b"<html>\n<head>\n<title>Index of /.</title>\n</head>\n<body>\n<h1>Index of"
107+
b' /.</h1>\n<ul>\n<li><a href="/%3Cimg%20src=0%20onerror=alert(1)%3E.dir">&l'
108+
b't;img src=0 onerror=alert(1)&gt;.dir/</a></li>\n<li><a href="/%3Cimg%20sr'
109+
b'c=0%20onerror=alert(1)%3E.txt">&lt;img src=0 onerror=alert(1)&gt;.txt</a></l'
110+
b"i>\n</ul>\n</body>\n</html>",
111+
),
112+
pytest.param(
113+
True,
114+
200,
115+
"/static",
116+
"/static",
117+
b"<html>\n<head>\n<title>Index of /.</title>\n</head>\n<body>\n<h1>Index of"
118+
b' /.</h1>\n<ul>\n<li><a href="/static/%3Cimg%20src=0%20onerror=alert(1)%3E.'
119+
b'dir">&lt;img src=0 onerror=alert(1)&gt;.dir/</a></li>\n<li><a href="/stat'
120+
b'ic/%3Cimg%20src=0%20onerror=alert(1)%3E.txt">&lt;img src=0 onerror=alert(1)&'
121+
b"gt;.txt</a></li>\n</ul>\n</body>\n</html>",
122+
id="index_static",
123+
),
124+
pytest.param(
125+
True,
126+
200,
127+
"/static",
128+
"/static/<img src=0 onerror=alert(1)>.dir",
129+
b"<html>\n<head>\n<title>Index of /&lt;img src=0 onerror=alert(1)&gt;.dir</t"
130+
b"itle>\n</head>\n<body>\n<h1>Index of /&lt;img src=0 onerror=alert(1)&gt;.di"
131+
b'r</h1>\n<ul>\n<li><a href="/static/%3Cimg%20src=0%20onerror=alert(1)%3E.di'
132+
b'r/my_file_in_dir">my_file_in_dir</a></li>\n</ul>\n</body>\n</html>',
133+
id="index_subdir",
134+
),
135+
],
136+
)
137+
async def test_access_root_of_static_handler_xss(
138+
tmp_path: pathlib.Path,
139+
aiohttp_client: AiohttpClient,
140+
show_index: bool,
141+
status: int,
142+
prefix: str,
143+
request_path: str,
144+
data: Optional[bytes],
145+
) -> None:
146+
# Tests the operation of static file server.
147+
# Try to access the root of static file server, and make
148+
# sure that correct HTTP statuses are returned depending if we directory
149+
# index should be shown or not.
150+
# Ensure that html in file names is escaped.
151+
# Ensure that links are url quoted.
152+
my_file = tmp_path / "<img src=0 onerror=alert(1)>.txt"
153+
my_dir = tmp_path / "<img src=0 onerror=alert(1)>.dir"
154+
my_dir.mkdir()
155+
my_file_in_dir = my_dir / "my_file_in_dir"
156+
157+
with my_file.open("w") as fw:
158+
fw.write("hello")
159+
160+
with my_file_in_dir.open("w") as fw:
161+
fw.write("world")
162+
163+
app = web.Application()
164+
165+
# Register global static route:
166+
app.router.add_static(prefix, str(tmp_path), show_index=show_index)
167+
client = await aiohttp_client(app)
168+
169+
# Request the root of the static directory.
170+
async with await client.get(request_path) as r:
75171
assert r.status == status
76172

77173
if data:

0 commit comments

Comments
 (0)