Skip to content
This repository was archived by the owner on Apr 26, 2024. It is now read-only.

Commit 858d80b

Browse files
authored
Fix media repository failing when media store path contains symlinks (#11446)
1 parent 435f044 commit 858d80b

File tree

3 files changed

+180
-45
lines changed

3 files changed

+180
-45
lines changed

changelog.d/11446.bugfix

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Fix a bug introduced in 1.47.1 where the media repository would fail to work if the media store path contained any symbolic links.

synapse/rest/media/v1/filepath.py

Lines changed: 71 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -43,47 +43,75 @@ def _wrapped(self: "MediaFilePaths", *args: Any, **kwargs: Any) -> str:
4343
)
4444

4545

46-
def _wrap_with_jail_check(func: GetPathMethod) -> GetPathMethod:
46+
def _wrap_with_jail_check(relative: bool) -> Callable[[GetPathMethod], GetPathMethod]:
4747
"""Wraps a path-returning method to check that the returned path(s) do not escape
4848
the media store directory.
4949
50+
The path-returning method may return either a single path, or a list of paths.
51+
5052
The check is not expected to ever fail, unless `func` is missing a call to
5153
`_validate_path_component`, or `_validate_path_component` is buggy.
5254
5355
Args:
54-
func: The `MediaFilePaths` method to wrap. The method may return either a single
55-
path, or a list of paths. Returned paths may be either absolute or relative.
56+
relative: A boolean indicating whether the wrapped method returns paths relative
57+
to the media store directory.
5658
5759
Returns:
58-
The method, wrapped with a check to ensure that the returned path(s) lie within
59-
the media store directory. Raises a `ValueError` if the check fails.
60+
A method which will wrap a path-returning method, adding a check to ensure that
61+
the returned path(s) lie within the media store directory. The check will raise
62+
a `ValueError` if it fails.
6063
"""
6164

62-
@functools.wraps(func)
63-
def _wrapped(
64-
self: "MediaFilePaths", *args: Any, **kwargs: Any
65-
) -> Union[str, List[str]]:
66-
path_or_paths = func(self, *args, **kwargs)
67-
68-
if isinstance(path_or_paths, list):
69-
paths_to_check = path_or_paths
70-
else:
71-
paths_to_check = [path_or_paths]
72-
73-
for path in paths_to_check:
74-
# path may be an absolute or relative path, depending on the method being
75-
# wrapped. When "appending" an absolute path, `os.path.join` discards the
76-
# previous path, which is desired here.
77-
normalized_path = os.path.normpath(os.path.join(self.real_base_path, path))
78-
if (
79-
os.path.commonpath([normalized_path, self.real_base_path])
80-
!= self.real_base_path
81-
):
82-
raise ValueError(f"Invalid media store path: {path!r}")
83-
84-
return path_or_paths
85-
86-
return cast(GetPathMethod, _wrapped)
65+
def _wrap_with_jail_check_inner(func: GetPathMethod) -> GetPathMethod:
66+
@functools.wraps(func)
67+
def _wrapped(
68+
self: "MediaFilePaths", *args: Any, **kwargs: Any
69+
) -> Union[str, List[str]]:
70+
path_or_paths = func(self, *args, **kwargs)
71+
72+
if isinstance(path_or_paths, list):
73+
paths_to_check = path_or_paths
74+
else:
75+
paths_to_check = [path_or_paths]
76+
77+
for path in paths_to_check:
78+
# Construct the path that will ultimately be used.
79+
# We cannot guess whether `path` is relative to the media store
80+
# directory, since the media store directory may itself be a relative
81+
# path.
82+
if relative:
83+
path = os.path.join(self.base_path, path)
84+
normalized_path = os.path.normpath(path)
85+
86+
# Now that `normpath` has eliminated `../`s and `./`s from the path,
87+
# `os.path.commonpath` can be used to check whether it lies within the
88+
# media store directory.
89+
if (
90+
os.path.commonpath([normalized_path, self.normalized_base_path])
91+
!= self.normalized_base_path
92+
):
93+
# The path resolves to outside the media store directory,
94+
# or `self.base_path` is `.`, which is an unlikely configuration.
95+
raise ValueError(f"Invalid media store path: {path!r}")
96+
97+
# Note that `os.path.normpath`/`abspath` has a subtle caveat:
98+
# `a/b/c/../c` will normalize to `a/b/c`, but the former refers to a
99+
# different path if `a/b/c` is a symlink. That is, the check above is
100+
# not perfect and may allow a certain restricted subset of untrustworthy
101+
# paths through. Since the check above is secondary to the main
102+
# `_validate_path_component` checks, it's less important for it to be
103+
# perfect.
104+
#
105+
# As an alternative, `os.path.realpath` will resolve symlinks, but
106+
# proves problematic if there are symlinks inside the media store.
107+
# eg. if `url_store/` is symlinked to elsewhere, its canonical path
108+
# won't match that of the main media store directory.
109+
110+
return path_or_paths
111+
112+
return cast(GetPathMethod, _wrapped)
113+
114+
return _wrap_with_jail_check_inner
87115

88116

89117
ALLOWED_CHARACTERS = set(
@@ -127,9 +155,7 @@ class MediaFilePaths:
127155

128156
def __init__(self, primary_base_path: str):
129157
self.base_path = primary_base_path
130-
131-
# The media store directory, with all symlinks resolved.
132-
self.real_base_path = os.path.realpath(primary_base_path)
158+
self.normalized_base_path = os.path.normpath(self.base_path)
133159

134160
# Refuse to initialize if paths cannot be validated correctly for the current
135161
# platform.
@@ -140,7 +166,7 @@ def __init__(self, primary_base_path: str):
140166
# for certain homeservers there, since ":"s aren't allowed in paths.
141167
assert os.name == "posix"
142168

143-
@_wrap_with_jail_check
169+
@_wrap_with_jail_check(relative=True)
144170
def local_media_filepath_rel(self, media_id: str) -> str:
145171
return os.path.join(
146172
"local_content",
@@ -151,7 +177,7 @@ def local_media_filepath_rel(self, media_id: str) -> str:
151177

152178
local_media_filepath = _wrap_in_base_path(local_media_filepath_rel)
153179

154-
@_wrap_with_jail_check
180+
@_wrap_with_jail_check(relative=True)
155181
def local_media_thumbnail_rel(
156182
self, media_id: str, width: int, height: int, content_type: str, method: str
157183
) -> str:
@@ -167,7 +193,7 @@ def local_media_thumbnail_rel(
167193

168194
local_media_thumbnail = _wrap_in_base_path(local_media_thumbnail_rel)
169195

170-
@_wrap_with_jail_check
196+
@_wrap_with_jail_check(relative=False)
171197
def local_media_thumbnail_dir(self, media_id: str) -> str:
172198
"""
173199
Retrieve the local store path of thumbnails of a given media_id
@@ -185,7 +211,7 @@ def local_media_thumbnail_dir(self, media_id: str) -> str:
185211
_validate_path_component(media_id[4:]),
186212
)
187213

188-
@_wrap_with_jail_check
214+
@_wrap_with_jail_check(relative=True)
189215
def remote_media_filepath_rel(self, server_name: str, file_id: str) -> str:
190216
return os.path.join(
191217
"remote_content",
@@ -197,7 +223,7 @@ def remote_media_filepath_rel(self, server_name: str, file_id: str) -> str:
197223

198224
remote_media_filepath = _wrap_in_base_path(remote_media_filepath_rel)
199225

200-
@_wrap_with_jail_check
226+
@_wrap_with_jail_check(relative=True)
201227
def remote_media_thumbnail_rel(
202228
self,
203229
server_name: str,
@@ -223,7 +249,7 @@ def remote_media_thumbnail_rel(
223249
# Legacy path that was used to store thumbnails previously.
224250
# Should be removed after some time, when most of the thumbnails are stored
225251
# using the new path.
226-
@_wrap_with_jail_check
252+
@_wrap_with_jail_check(relative=True)
227253
def remote_media_thumbnail_rel_legacy(
228254
self, server_name: str, file_id: str, width: int, height: int, content_type: str
229255
) -> str:
@@ -238,6 +264,7 @@ def remote_media_thumbnail_rel_legacy(
238264
_validate_path_component(file_name),
239265
)
240266

267+
@_wrap_with_jail_check(relative=False)
241268
def remote_media_thumbnail_dir(self, server_name: str, file_id: str) -> str:
242269
return os.path.join(
243270
self.base_path,
@@ -248,7 +275,7 @@ def remote_media_thumbnail_dir(self, server_name: str, file_id: str) -> str:
248275
_validate_path_component(file_id[4:]),
249276
)
250277

251-
@_wrap_with_jail_check
278+
@_wrap_with_jail_check(relative=True)
252279
def url_cache_filepath_rel(self, media_id: str) -> str:
253280
if NEW_FORMAT_ID_RE.match(media_id):
254281
# Media id is of the form <DATE><RANDOM_STRING>
@@ -268,7 +295,7 @@ def url_cache_filepath_rel(self, media_id: str) -> str:
268295

269296
url_cache_filepath = _wrap_in_base_path(url_cache_filepath_rel)
270297

271-
@_wrap_with_jail_check
298+
@_wrap_with_jail_check(relative=False)
272299
def url_cache_filepath_dirs_to_delete(self, media_id: str) -> List[str]:
273300
"The dirs to try and remove if we delete the media_id file"
274301
if NEW_FORMAT_ID_RE.match(media_id):
@@ -290,7 +317,7 @@ def url_cache_filepath_dirs_to_delete(self, media_id: str) -> List[str]:
290317
),
291318
]
292319

293-
@_wrap_with_jail_check
320+
@_wrap_with_jail_check(relative=True)
294321
def url_cache_thumbnail_rel(
295322
self, media_id: str, width: int, height: int, content_type: str, method: str
296323
) -> str:
@@ -318,7 +345,7 @@ def url_cache_thumbnail_rel(
318345

319346
url_cache_thumbnail = _wrap_in_base_path(url_cache_thumbnail_rel)
320347

321-
@_wrap_with_jail_check
348+
@_wrap_with_jail_check(relative=True)
322349
def url_cache_thumbnail_directory_rel(self, media_id: str) -> str:
323350
# Media id is of the form <DATE><RANDOM_STRING>
324351
# E.g.: 2017-09-28-fsdRDt24DS234dsf
@@ -341,7 +368,7 @@ def url_cache_thumbnail_directory_rel(self, media_id: str) -> str:
341368
url_cache_thumbnail_directory_rel
342369
)
343370

344-
@_wrap_with_jail_check
371+
@_wrap_with_jail_check(relative=False)
345372
def url_cache_thumbnail_dirs_to_delete(self, media_id: str) -> List[str]:
346373
"The dirs to try and remove if we delete the media_id thumbnails"
347374
# Media id is of the form <DATE><RANDOM_STRING>

tests/rest/media/v1/test_filepath.py

Lines changed: 108 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,10 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414
import inspect
15+
import os
1516
from typing import Iterable
1617

17-
from synapse.rest.media.v1.filepath import MediaFilePaths
18+
from synapse.rest.media.v1.filepath import MediaFilePaths, _wrap_with_jail_check
1819

1920
from tests import unittest
2021

@@ -486,3 +487,109 @@ def _test_path_validation(
486487
f"{value!r} unexpectedly passed validation: "
487488
f"{method} returned {path_or_list!r}"
488489
)
490+
491+
492+
class MediaFilePathsJailTestCase(unittest.TestCase):
493+
def _check_relative_path(self, filepaths: MediaFilePaths, path: str) -> None:
494+
"""Passes a relative path through the jail check.
495+
496+
Args:
497+
filepaths: The `MediaFilePaths` instance.
498+
path: A path relative to the media store directory.
499+
500+
Raises:
501+
ValueError: If the jail check fails.
502+
"""
503+
504+
@_wrap_with_jail_check(relative=True)
505+
def _make_relative_path(self: MediaFilePaths, path: str) -> str:
506+
return path
507+
508+
_make_relative_path(filepaths, path)
509+
510+
def _check_absolute_path(self, filepaths: MediaFilePaths, path: str) -> None:
511+
"""Passes an absolute path through the jail check.
512+
513+
Args:
514+
filepaths: The `MediaFilePaths` instance.
515+
path: A path relative to the media store directory.
516+
517+
Raises:
518+
ValueError: If the jail check fails.
519+
"""
520+
521+
@_wrap_with_jail_check(relative=False)
522+
def _make_absolute_path(self: MediaFilePaths, path: str) -> str:
523+
return os.path.join(self.base_path, path)
524+
525+
_make_absolute_path(filepaths, path)
526+
527+
def test_traversal_inside(self) -> None:
528+
"""Test the jail check for paths that stay within the media directory."""
529+
# Despite the `../`s, these paths still lie within the media directory and it's
530+
# expected for the jail check to allow them through.
531+
# These paths ought to trip the other checks in place and should never be
532+
# returned.
533+
filepaths = MediaFilePaths("/media_store")
534+
path = "url_cache/2020-01-02/../../GerZNDnDZVjsOtar"
535+
self._check_relative_path(filepaths, path)
536+
self._check_absolute_path(filepaths, path)
537+
538+
def test_traversal_outside(self) -> None:
539+
"""Test that the jail check fails for paths that escape the media directory."""
540+
filepaths = MediaFilePaths("/media_store")
541+
path = "url_cache/2020-01-02/../../../GerZNDnDZVjsOtar"
542+
with self.assertRaises(ValueError):
543+
self._check_relative_path(filepaths, path)
544+
with self.assertRaises(ValueError):
545+
self._check_absolute_path(filepaths, path)
546+
547+
def test_traversal_reentry(self) -> None:
548+
"""Test the jail check for paths that exit and re-enter the media directory."""
549+
# These paths lie outside the media directory if it is a symlink, and inside
550+
# otherwise. Ideally the check should fail, but this proves difficult.
551+
# This test documents the behaviour for this edge case.
552+
# These paths ought to trip the other checks in place and should never be
553+
# returned.
554+
filepaths = MediaFilePaths("/media_store")
555+
path = "url_cache/2020-01-02/../../../media_store/GerZNDnDZVjsOtar"
556+
self._check_relative_path(filepaths, path)
557+
self._check_absolute_path(filepaths, path)
558+
559+
def test_symlink(self) -> None:
560+
"""Test that a symlink does not cause the jail check to fail."""
561+
media_store_path = self.mktemp()
562+
563+
# symlink the media store directory
564+
os.symlink("/mnt/synapse/media_store", media_store_path)
565+
566+
# Test that relative and absolute paths don't trip the check
567+
# NB: `media_store_path` is a relative path
568+
filepaths = MediaFilePaths(media_store_path)
569+
self._check_relative_path(filepaths, "url_cache/2020-01-02/GerZNDnDZVjsOtar")
570+
self._check_absolute_path(filepaths, "url_cache/2020-01-02/GerZNDnDZVjsOtar")
571+
572+
filepaths = MediaFilePaths(os.path.abspath(media_store_path))
573+
self._check_relative_path(filepaths, "url_cache/2020-01-02/GerZNDnDZVjsOtar")
574+
self._check_absolute_path(filepaths, "url_cache/2020-01-02/GerZNDnDZVjsOtar")
575+
576+
def test_symlink_subdirectory(self) -> None:
577+
"""Test that a symlinked subdirectory does not cause the jail check to fail."""
578+
media_store_path = self.mktemp()
579+
os.mkdir(media_store_path)
580+
581+
# symlink `url_cache/`
582+
os.symlink(
583+
"/mnt/synapse/media_store_url_cache",
584+
os.path.join(media_store_path, "url_cache"),
585+
)
586+
587+
# Test that relative and absolute paths don't trip the check
588+
# NB: `media_store_path` is a relative path
589+
filepaths = MediaFilePaths(media_store_path)
590+
self._check_relative_path(filepaths, "url_cache/2020-01-02/GerZNDnDZVjsOtar")
591+
self._check_absolute_path(filepaths, "url_cache/2020-01-02/GerZNDnDZVjsOtar")
592+
593+
filepaths = MediaFilePaths(os.path.abspath(media_store_path))
594+
self._check_relative_path(filepaths, "url_cache/2020-01-02/GerZNDnDZVjsOtar")
595+
self._check_absolute_path(filepaths, "url_cache/2020-01-02/GerZNDnDZVjsOtar")

0 commit comments

Comments
 (0)