Skip to content

Commit f0e57c0

Browse files
committed
Manually resolve paths relatively to root_dir to prevent escape
This patch is a followup of #311 (2a89f76). It appeared that we were not resolving paths when reading from files. This means that symbolic links present under `root_dir` could be blindly followed, eventually leading _outside_ of `root_dir` (i.e host own files). Note : this patch **only** changes LinuxDistribution's `root_dir` parameter behavior.
1 parent 7b4a85c commit f0e57c0

File tree

14 files changed

+192
-12
lines changed

14 files changed

+192
-12
lines changed

src/distro/distro.py

Lines changed: 129 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
import subprocess
3838
import sys
3939
import warnings
40+
from pathlib import Path
4041
from typing import (
4142
Any,
4243
Callable,
@@ -771,9 +772,15 @@ def __init__(
771772
self.usr_lib_dir, _OS_RELEASE_BASENAME
772773
)
773774

775+
def __isfile(path: str) -> bool:
776+
try:
777+
return os.path.isfile(self._resolve_path_relatively_to_chroot(path))
778+
except FileNotFoundError:
779+
return False
780+
774781
# NOTE: The idea is to respect order **and** have it set
775782
# at all times for API backwards compatibility.
776-
if os.path.isfile(etc_dir_os_release_file) or not os.path.isfile(
783+
if __isfile(etc_dir_os_release_file) or not __isfile(
777784
usr_lib_os_release_file
778785
):
779786
self.os_release_file = etc_dir_os_release_file
@@ -1091,6 +1098,101 @@ def uname_attr(self, attribute: str) -> str:
10911098
"""
10921099
return self._uname_info.get(attribute, "")
10931100

1101+
@staticmethod
1102+
def __abs_path_join(root_path: Path, abs_path: Path) -> Path:
1103+
rel_path = os.path.splitdrive(abs_path)[1].lstrip(os.sep)
1104+
if os.altsep is not None:
1105+
rel_path = rel_path.lstrip(os.altsep)
1106+
1107+
return root_path / Path(rel_path)
1108+
1109+
def _resolve_path_relatively_to_chroot(self, path: str) -> Path:
1110+
"""
1111+
Resolves any encountered symbolic links in ``path`` relatively to
1112+
``self.root_dir``, if defined. Otherwise it would simply return
1113+
original ``path``.
1114+
This function could be considered as a "soft-chroot" implementation.
1115+
We're doing this check at a central place, to make calling code more readable
1116+
and to de-duplicate.
1117+
1118+
Raises:
1119+
1120+
* :py:exc:`FileNotFoundError`: ``path`` doesn't resolve in chroot, or resolving
1121+
it lead to symbolic links loop
1122+
1123+
Examples :
1124+
1125+
* if root_dir="/path/to/chroot" and path="folder/../../../../etc/os-release"
1126+
with "etc" resolving to "/mnt/disk/etc" and "os-release" to
1127+
"../../usr/lib/os-release", this function returns
1128+
"/path/to/chroot/mnt/usr/lib/os-release"
1129+
1130+
* if root_dir=None and path="/path/to/os-release", this function returns
1131+
"/path/to/os-release"
1132+
"""
1133+
path_to_resolve = Path(path)
1134+
1135+
if self.root_dir is None:
1136+
return path_to_resolve
1137+
1138+
# resolve `self.root_dir` once and for all
1139+
chroot_path = Path(self.root_dir).resolve()
1140+
1141+
# consider non-absolute `path_to_resolve` relative to chroot
1142+
if not path_to_resolve.is_absolute():
1143+
path_to_resolve = chroot_path / path_to_resolve
1144+
1145+
seen_paths = set()
1146+
while True:
1147+
# although `path_to_resolve` _should_ be relative to chroot (either
1148+
# passed from trusted code or already resolved by previous loop
1149+
# iteration), we enforce this check as some inputs are available through API
1150+
try:
1151+
relative_parts = path_to_resolve.relative_to(chroot_path).parts
1152+
except ValueError:
1153+
raise FileNotFoundError
1154+
1155+
# iterate over (relative) path segments and try to resolve each one of them
1156+
for i, part in enumerate(relative_parts, start=1):
1157+
if part == os.pardir:
1158+
# normalize path parts up to this segment (relatively to chroot)
1159+
path_to_resolve = self.__abs_path_join(
1160+
chroot_path,
1161+
Path(os.path.normpath("/" / Path(*relative_parts[:i]))),
1162+
) / Path(*relative_parts[i:])
1163+
break # restart path resolution as path has just been normalized
1164+
1165+
# attempt symbolic link resolution on current path segment
1166+
symlink_candidate = chroot_path / Path(*relative_parts[:i])
1167+
try:
1168+
symlink_resolved = Path(os.readlink(symlink_candidate))
1169+
except (
1170+
AttributeError, # `readlink` isn't supported by system
1171+
OSError, # not a symlink, go to next path segment
1172+
):
1173+
continue
1174+
1175+
# "bend" **absolute** resolved path inside the chroot
1176+
# consider **non-absolute** resolved path relatively to chroot
1177+
if symlink_resolved.is_absolute():
1178+
path_to_resolve = self.__abs_path_join(
1179+
chroot_path, symlink_resolved
1180+
)
1181+
else:
1182+
path_to_resolve = symlink_candidate.parent / symlink_resolved
1183+
1184+
# append remaining path segments to resolved path
1185+
path_to_resolve /= Path(*relative_parts[i:])
1186+
break # restart path resolution as a symlink has just been resolved
1187+
else:
1188+
# `path_to_resolve` can be considered resolved, return it
1189+
return path_to_resolve
1190+
1191+
# prevent symlinks infinite loop by tracking successive resolutions
1192+
if path_to_resolve in seen_paths:
1193+
raise FileNotFoundError
1194+
seen_paths.add(path_to_resolve)
1195+
10941196
@cached_property
10951197
def _os_release_info(self) -> Dict[str, str]:
10961198
"""
@@ -1099,10 +1201,14 @@ def _os_release_info(self) -> Dict[str, str]:
10991201
Returns:
11001202
A dictionary containing all information items.
11011203
"""
1102-
if os.path.isfile(self.os_release_file):
1103-
with open(self.os_release_file, encoding="utf-8") as release_file:
1204+
try:
1205+
with open(
1206+
self._resolve_path_relatively_to_chroot(self.os_release_file),
1207+
encoding="utf-8",
1208+
) as release_file:
11041209
return self._parse_os_release_content(release_file)
1105-
return {}
1210+
except FileNotFoundError:
1211+
return {}
11061212

11071213
@staticmethod
11081214
def _parse_os_release_content(lines: TextIO) -> Dict[str, str]:
@@ -1225,7 +1331,10 @@ def _oslevel_info(self) -> str:
12251331
def _debian_version(self) -> str:
12261332
try:
12271333
with open(
1228-
os.path.join(self.etc_dir, "debian_version"), encoding="ascii"
1334+
self._resolve_path_relatively_to_chroot(
1335+
os.path.join(self.etc_dir, "debian_version")
1336+
),
1337+
encoding="ascii",
12291338
) as fp:
12301339
return fp.readline().rstrip()
12311340
except FileNotFoundError:
@@ -1235,7 +1344,10 @@ def _debian_version(self) -> str:
12351344
def _armbian_version(self) -> str:
12361345
try:
12371346
with open(
1238-
os.path.join(self.etc_dir, "armbian-release"), encoding="ascii"
1347+
self._resolve_path_relatively_to_chroot(
1348+
os.path.join(self.etc_dir, "armbian-release")
1349+
),
1350+
encoding="ascii",
12391351
) as fp:
12401352
return self._parse_os_release_content(fp).get("version", "")
12411353
except FileNotFoundError:
@@ -1287,9 +1399,10 @@ def _distro_release_info(self) -> Dict[str, str]:
12871399
try:
12881400
basenames = [
12891401
basename
1290-
for basename in os.listdir(self.etc_dir)
1402+
for basename in os.listdir(
1403+
self._resolve_path_relatively_to_chroot(self.etc_dir)
1404+
)
12911405
if basename not in _DISTRO_RELEASE_IGNORE_BASENAMES
1292-
and os.path.isfile(os.path.join(self.etc_dir, basename))
12931406
]
12941407
# We sort for repeatability in cases where there are multiple
12951408
# distro specific files; e.g. CentOS, Oracle, Enterprise all
@@ -1305,12 +1418,13 @@ def _distro_release_info(self) -> Dict[str, str]:
13051418
match = _DISTRO_RELEASE_BASENAME_PATTERN.match(basename)
13061419
if match is None:
13071420
continue
1308-
filepath = os.path.join(self.etc_dir, basename)
1309-
distro_info = self._parse_distro_release_file(filepath)
1421+
# NOTE: _parse_distro_release_file below will be resolving for us
1422+
unresolved_filepath = os.path.join(self.etc_dir, basename)
1423+
distro_info = self._parse_distro_release_file(unresolved_filepath)
13101424
# The name is always present if the pattern matches.
13111425
if "name" not in distro_info:
13121426
continue
1313-
self.distro_release_file = filepath
1427+
self.distro_release_file = unresolved_filepath
13141428
break
13151429
else: # the loop didn't "break": no candidate.
13161430
return {}
@@ -1344,7 +1458,10 @@ def _parse_distro_release_file(self, filepath: str) -> Dict[str, str]:
13441458
A dictionary containing all information items.
13451459
"""
13461460
try:
1347-
with open(filepath, encoding="utf-8") as fp:
1461+
with open(
1462+
self._resolve_path_relatively_to_chroot(filepath),
1463+
encoding="utf-8",
1464+
) as fp:
13481465
# Only parse the first line. For instance, on SLES there
13491466
# are multiple lines. We don't want them...
13501467
return self._parse_distro_release_content(fp.readline())
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
/tmp/another-link
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
/usr/lib/os-release
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
ID=absolute_symlinks
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
../../../../distros/debian8/etc/os-release
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
/../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../usr/lib/os-release
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
/tmp/another-link/../../../../../../usr/lib/os-release
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
nested/nested

tests/resources/testdistros/distro/root_dir_non_escape/tmp/nested/nested/.gitkeep

Whitespace-only changes.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
ID=root_dir_non_escape

0 commit comments

Comments
 (0)