Skip to content

Commit ea2e794

Browse files
aminalaeeKludex
andauthored
Allow StaticFiles follow symlinks (#1683)
Co-authored-by: Marcelo Trylesinski <marcelotryle@gmail.com>
1 parent ea70fd5 commit ea2e794

3 files changed

Lines changed: 77 additions & 2 deletions

File tree

docs/staticfiles.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,13 @@ Starlette also includes a `StaticFiles` class for serving files in a given direc
33

44
### StaticFiles
55

6-
Signature: `StaticFiles(directory=None, packages=None, check_dir=True)`
6+
Signature: `StaticFiles(directory=None, packages=None, check_dir=True, follow_symlink=False)`
77

88
* `directory` - A string or [os.Pathlike][pathlike] denoting a directory path.
99
* `packages` - A list of strings or list of tuples of strings of python packages.
1010
* `html` - Run in HTML mode. Automatically loads `index.html` for directories if such file exist.
1111
* `check_dir` - Ensure that the directory exists upon instantiation. Defaults to `True`.
12+
* `follow_symlink` - A boolean indicating if symbolic links for files and directories should be followed. Defaults to `False`.
1213

1314
You can combine this ASGI application with Starlette's routing to provide
1415
comprehensive static file serving.

starlette/staticfiles.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,12 +45,14 @@ def __init__(
4545
] = None,
4646
html: bool = False,
4747
check_dir: bool = True,
48+
follow_symlink: bool = False,
4849
) -> None:
4950
self.directory = directory
5051
self.packages = packages
5152
self.all_directories = self.get_directories(directory, packages)
5253
self.html = html
5354
self.config_checked = False
55+
self.follow_symlink = follow_symlink
5456
if check_dir and directory is not None and not os.path.isdir(directory):
5557
raise RuntimeError(f"Directory '{directory}' does not exist")
5658

@@ -161,7 +163,11 @@ def lookup_path(
161163
self, path: str
162164
) -> typing.Tuple[str, typing.Optional[os.stat_result]]:
163165
for directory in self.all_directories:
164-
full_path = os.path.realpath(os.path.join(directory, path))
166+
joined_path = os.path.join(directory, path)
167+
if self.follow_symlink:
168+
full_path = os.path.abspath(joined_path)
169+
else:
170+
full_path = os.path.realpath(joined_path)
165171
directory = os.path.realpath(directory)
166172
if os.path.commonprefix([full_path, directory]) != directory:
167173
# Don't allow misbehaving clients to break out of the static files

tests/test_staticfiles.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import os
22
import pathlib
33
import stat
4+
import tempfile
45
import time
56

67
import anyio
@@ -448,3 +449,70 @@ def mock_timeout(*args, **kwargs):
448449
response = client.get("/example.txt")
449450
assert response.status_code == 500
450451
assert response.text == "Internal Server Error"
452+
453+
454+
def test_staticfiles_follows_symlinks(tmpdir, test_client_factory):
455+
statics_path = os.path.join(tmpdir, "statics")
456+
os.mkdir(statics_path)
457+
458+
source_path = tempfile.mkdtemp()
459+
source_file_path = os.path.join(source_path, "page.html")
460+
with open(source_file_path, "w") as file:
461+
file.write("<h1>Hello</h1>")
462+
463+
statics_file_path = os.path.join(statics_path, "index.html")
464+
os.symlink(source_file_path, statics_file_path)
465+
466+
app = StaticFiles(directory=statics_path, follow_symlink=True)
467+
client = test_client_factory(app)
468+
469+
response = client.get("/index.html")
470+
assert response.url == "http://testserver/index.html"
471+
assert response.status_code == 200
472+
assert response.text == "<h1>Hello</h1>"
473+
474+
475+
def test_staticfiles_follows_symlink_directories(tmpdir, test_client_factory):
476+
statics_path = os.path.join(tmpdir, "statics")
477+
statics_html_path = os.path.join(statics_path, "html")
478+
os.mkdir(statics_path)
479+
480+
source_path = tempfile.mkdtemp()
481+
source_file_path = os.path.join(source_path, "page.html")
482+
with open(source_file_path, "w") as file:
483+
file.write("<h1>Hello</h1>")
484+
485+
os.symlink(source_path, statics_html_path)
486+
487+
app = StaticFiles(directory=statics_path, follow_symlink=True)
488+
client = test_client_factory(app)
489+
490+
response = client.get("/html/page.html")
491+
assert response.url == "http://testserver/html/page.html"
492+
assert response.status_code == 200
493+
assert response.text == "<h1>Hello</h1>"
494+
495+
496+
def test_staticfiles_disallows_path_traversal_with_symlinks(tmpdir):
497+
statics_path = os.path.join(tmpdir, "statics")
498+
499+
root_source_path = tempfile.mkdtemp()
500+
source_path = os.path.join(root_source_path, "statics")
501+
os.mkdir(source_path)
502+
503+
source_file_path = os.path.join(root_source_path, "index.html")
504+
with open(source_file_path, "w") as file:
505+
file.write("<h1>Hello</h1>")
506+
507+
os.symlink(source_path, statics_path)
508+
509+
app = StaticFiles(directory=statics_path, follow_symlink=True)
510+
# We can't test this with 'httpx', so we test the app directly here.
511+
path = app.get_path({"path": "/../index.html"})
512+
scope = {"method": "GET"}
513+
514+
with pytest.raises(HTTPException) as exc_info:
515+
anyio.run(app.get_response, path, scope)
516+
517+
assert exc_info.value.status_code == 404
518+
assert exc_info.value.detail == "Not Found"

0 commit comments

Comments
 (0)