Files
Maxim Shulga c15763d9f8 Stop server-provided values from reaching a shell or escaping /dsc/seafile
Commands were joined into a string and passed to "su -c", so a login shell
parsed every interpolated value. A library name is chosen on the server, and
"sync -d /dsc/seafile/<name>" put it straight into that string: a library
named 'Docs; touch /tmp/pwned; #' ran as an arbitrary command. Confirmed
against the built image before and after this change.

Every command is now an argument list executed by "runuser --", which execs
directly with no shell. runuser -u sets the same HOME and a PATH that still
finds the seaf-cli wrapper, so the daemon environment is unchanged.

The password no longer needs its surrounding quotes, which also fixes
passwords containing quotes, dollar signs, and backticks.

Library names are untrusted, so dsc/paths.py reduces each one to a single
path component and proves the result stays under the libraries directory
with symlinks resolved. Empty, ".", "..", and names containing a separator
are skipped with a warning; names that would collide are suffixed with their
library ID rather than sharing a directory.

Tests cover quotes, command substitutions, separators, newlines, absolute
paths, parent traversal, Unicode, and colliding names. They run in a separate
CI job so application failures stay distinguishable from packaging failures.
2026-08-02 16:53:19 -07:00

102 lines
3.5 KiB
Python

"""Turn server-provided library names into safe local directories.
Library names come from the Seafile server and are untrusted: a name is chosen
by whoever can create a library, and it must never decide where the container
writes. Every name is reduced to a single path component and every resulting
path is proven to stay under the libraries directory.
"""
import logging
import os
from collections import Counter
_lg = logging.getLogger(__name__)
# Whitespace is folded into underscores so that library names survive as
# readable directory names, as they always have.
_WHITESPACE_REPLACEMENT = "_"
# Names that are not usable as a directory of their own.
_RESERVED_NAMES = {"", ".", "..", "..."}
# Length of the library id used to break ties between duplicate names.
_ID_SUFFIX_LEN = 8
class UnsafeLibraryName(ValueError):
"""A library name cannot be mapped to a directory under the parent."""
def lib_dirname(lib_name: str) -> str:
"""
Return the directory name for a library, or raise for a name that cannot
be one. Shell metacharacters are ordinary filename characters here: nothing
reaches a shell any more, so they are kept rather than mangled.
"""
if not isinstance(lib_name, str):
raise UnsafeLibraryName(f"Library name is not a string: {lib_name!r}")
name = "".join(
_WHITESPACE_REPLACEMENT if ch.isspace() else ch
for ch in lib_name.strip()
)
if "\0" in name:
raise UnsafeLibraryName("Library name contains a null byte")
if os.sep in name or (os.altsep and os.altsep in name):
raise UnsafeLibraryName(f"Library name contains a path separator: {lib_name!r}")
if name in _RESERVED_NAMES:
raise UnsafeLibraryName(f"Library name is not usable as a directory: {lib_name!r}")
return name
def resolve_lib_dir(parent_dir: str, dir_name: str) -> str:
"""
Resolve ``dir_name`` inside ``parent_dir`` and prove that the result stays
there. Symlinks are resolved first, so an existing symlink inside the
parent cannot be used to write outside it.
"""
parent = os.path.realpath(parent_dir)
resolved = os.path.realpath(os.path.join(parent, dir_name))
if resolved != parent and not resolved.startswith(parent + os.sep):
raise UnsafeLibraryName(
f"Library directory {resolved} is outside {parent}"
)
if resolved == parent:
raise UnsafeLibraryName(f"Library directory resolves to {parent} itself")
return resolved
def plan_lib_dirs(libraries: dict, parent_dir: str) -> dict:
"""
Map library id to its directory for every library that can safely have one.
Libraries whose names collide after whitespace folding would otherwise
share a directory and corrupt each other, so each of them is suffixed with
the start of its own id. Unsafe names are skipped with a warning rather
than aborting the sync of the remaining libraries.
"""
names = dict()
for lib_id, lib_name in libraries.items():
try:
names[lib_id] = lib_dirname(lib_name)
except UnsafeLibraryName as err:
_lg.warning("Skipping library %s: %s", lib_id, err)
counts = Counter(names.values())
duplicates = {name for name, count in counts.items() if count > 1}
plan = dict()
for lib_id, name in names.items():
if name in duplicates:
name = f"{name}-{lib_id[:_ID_SUFFIX_LEN]}"
try:
plan[lib_id] = resolve_lib_dir(parent_dir, name)
except UnsafeLibraryName as err:
_lg.warning("Skipping library %s: %s", lib_id, err)
return plan