More TOCTOU, update to tests for jinjaturtle
This commit is contained in:
parent
56ae883948
commit
3c1e08bdde
9 changed files with 116 additions and 17 deletions
|
|
@ -7,7 +7,13 @@ import stat
|
|||
from typing import Tuple
|
||||
|
||||
|
||||
def open_no_follow_path(path: str, *, write: bool = False, mode: int = 0o600) -> int:
|
||||
def open_no_follow_path(
|
||||
path: str,
|
||||
*,
|
||||
write: bool = False,
|
||||
mode: int = 0o600,
|
||||
directory: bool = False,
|
||||
) -> int:
|
||||
"""Open ``path`` without following a symlink in *any* path component.
|
||||
|
||||
``O_NOFOLLOW`` only protects the final component of a path. A regular
|
||||
|
|
@ -42,10 +48,21 @@ def open_no_follow_path(path: str, *, write: bool = False, mode: int = 0o600) ->
|
|||
o_directory = getattr(os, "O_DIRECTORY", 0)
|
||||
o_path = getattr(os, "O_PATH", 0)
|
||||
|
||||
if write and directory:
|
||||
raise ValueError("directory=True cannot be combined with write=True")
|
||||
|
||||
if write:
|
||||
final_flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | cloexec | nofollow
|
||||
elif directory and o_path:
|
||||
# O_PATH|O_NOFOLLOW opens a final symlink as the symlink object itself
|
||||
# on Linux, allowing inspect_dir_no_follow() to reject it via fstat().
|
||||
# O_RDONLY|O_DIRECTORY|O_NOFOLLOW can still follow a symlink-to-dir on
|
||||
# some kernels/filesystems.
|
||||
final_flags = o_path | cloexec | nofollow
|
||||
else:
|
||||
final_flags = os.O_RDONLY | cloexec | nofollow
|
||||
if directory:
|
||||
final_flags |= o_directory
|
||||
|
||||
supports_openat = bool(
|
||||
o_directory and nofollow and os.open in getattr(os, "supports_dir_fd", set())
|
||||
|
|
@ -126,6 +143,40 @@ def open_no_follow_path(path: str, *, write: bool = False, mode: int = 0o600) ->
|
|||
os.close(dir_fd)
|
||||
|
||||
|
||||
def inspect_dir_no_follow(path: str) -> os.stat_result:
|
||||
"""Return fstat() metadata for a directory opened without following symlinks.
|
||||
|
||||
Directory metadata capture must have the same TOCTOU properties as file
|
||||
capture: inspect the exact object reached through a no-follow descriptor,
|
||||
and reject symlink components anywhere in the path. Path-based
|
||||
``os.stat()`` / ``os.path.isdir()`` checks can be swapped between check and
|
||||
use when an include root is attacker-writable; this helper keeps the check
|
||||
bound to the opened descriptor.
|
||||
"""
|
||||
|
||||
fd = open_no_follow_path(path, directory=True)
|
||||
try:
|
||||
st = os.fstat(fd)
|
||||
if stat.S_ISLNK(st.st_mode):
|
||||
raise OSError(errno.ELOOP, "symlinked directory path", path)
|
||||
if not stat.S_ISDIR(st.st_mode):
|
||||
raise OSError(errno.ENOTDIR, "not a directory", path)
|
||||
return st
|
||||
finally:
|
||||
os.close(fd)
|
||||
|
||||
|
||||
def stat_dir_triplet(path: str) -> Tuple[str, str, str]:
|
||||
"""Return (owner, group, mode) for a safely-opened directory path.
|
||||
|
||||
Unlike :func:`stat_triplet`, this refuses final symlinks and symlinked
|
||||
parent components, and derives metadata from the directory descriptor that
|
||||
passed those checks.
|
||||
"""
|
||||
|
||||
return stat_triplet_from_stat(inspect_dir_no_follow(path))
|
||||
|
||||
|
||||
def path_has_symlink_component(path: str) -> bool:
|
||||
"""Return True if any existing component of *path* is a symlink.
|
||||
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ from typing import Any, Dict, List, Optional, Set, Tuple
|
|||
|
||||
from . import accounts as _accounts
|
||||
from . import systemd as _systemd
|
||||
from .fsutil import stat_triplet
|
||||
from .fsutil import stat_dir_triplet
|
||||
from .platform import detect_platform, get_backend
|
||||
from .ignore import IgnorePolicy
|
||||
from .harvest_safety import ensure_private_empty_dir, prepare_new_private_dir
|
||||
|
|
@ -118,7 +118,7 @@ def _merge_parent_dirs(
|
|||
continue
|
||||
|
||||
try:
|
||||
owner, group, mode = stat_triplet(dpath)
|
||||
owner, group, mode = stat_dir_triplet(dpath)
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
|
|
|
|||
|
|
@ -254,7 +254,7 @@ class ExtraPathsCollector(HarvestCollector):
|
|||
deny = None
|
||||
if not deny:
|
||||
try:
|
||||
owner, group, mode = h.stat_triplet(dirpath)
|
||||
owner, group, mode = h.stat_dir_triplet(dirpath)
|
||||
self.managed_dirs.append(
|
||||
ManagedDir(
|
||||
path=dirpath,
|
||||
|
|
|
|||
|
|
@ -1,14 +1,14 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import fnmatch
|
||||
import errno
|
||||
import fnmatch
|
||||
import os
|
||||
import re
|
||||
import stat
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
from .fsutil import open_no_follow_path
|
||||
from .fsutil import inspect_dir_no_follow, open_no_follow_path
|
||||
|
||||
|
||||
DEFAULT_DENY_GLOBS = [
|
||||
|
|
@ -407,16 +407,14 @@ class IgnorePolicy:
|
|||
return "denied_path"
|
||||
|
||||
try:
|
||||
os.stat(path, follow_symlinks=True)
|
||||
except OSError:
|
||||
inspect_dir_no_follow(path)
|
||||
except OSError as exc:
|
||||
if exc.errno == errno.ELOOP:
|
||||
return "symlink"
|
||||
if exc.errno == errno.ENOTDIR:
|
||||
return "not_directory"
|
||||
return "unreadable"
|
||||
|
||||
if os.path.islink(path):
|
||||
return "symlink"
|
||||
|
||||
if not os.path.isdir(path):
|
||||
return "not_directory"
|
||||
|
||||
return None
|
||||
|
||||
def deny_reason_link(self, path: str) -> Optional[str]:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue