More TOCTOU, update to tests for jinjaturtle
Some checks failed
CI / test (push) Successful in 48s
CI / test (almalinux, docker.io/library/almalinux:9, python3.11) (push) Failing after 2m32s
CI / test (debian, docker.io/library/debian:13, python3) (push) Failing after 3m0s
Lint / test (push) Successful in 44s

This commit is contained in:
Miguel Jacq 2026-06-29 14:30:07 +10:00
parent 56ae883948
commit 3c1e08bdde
Signed by: mig5
GPG key ID: 03906B4110AAD3B8
9 changed files with 116 additions and 17 deletions

View file

@ -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.

View file

@ -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

View file

@ -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,

View file

@ -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]: