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.
This commit is contained in:
1 parent
50ede0b2f9
commit
c15763d9f8
12 files changed
+478
-38
No files matched your search
+26
-23
@@ -12,7 +12,7 @@ from urllib3.util.retry import Retry
|
||||
import seafile
|
||||
|
||||
from dsc import const
|
||||
from dsc.misc import create_dir, hide_password
|
||||
from dsc.misc import create_dir, hide_password, user_cmd
|
||||
|
||||
_lg = logging.getLogger(__name__)
|
||||
|
||||
@@ -56,8 +56,8 @@ class SeafileClient:
|
||||
def __str__(self):
|
||||
return f"SeafileClient({self.user}@{self.url})"
|
||||
|
||||
def __gen_cmd(self, cmd: str) -> list:
|
||||
return ["su", "-", const.DEFAULT_USERNAME, "-c", cmd]
|
||||
def __gen_cmd(self, argv: list) -> list:
|
||||
return user_cmd(argv)
|
||||
|
||||
@property
|
||||
def token(self):
|
||||
@@ -87,8 +87,8 @@ class SeafileClient:
|
||||
|
||||
@property
|
||||
def daemon_ready(self) -> bool:
|
||||
cmd = "seaf-cli status"
|
||||
_lg.info("Checking seafile daemon status: %s", cmd)
|
||||
cmd = ["seaf-cli", "status"]
|
||||
_lg.info("Checking seafile daemon status: %s", " ".join(cmd))
|
||||
proc = subprocess.run(
|
||||
self.__gen_cmd(cmd),
|
||||
stdout=subprocess.DEVNULL,
|
||||
@@ -99,13 +99,13 @@ class SeafileClient:
|
||||
def init_config(self):
|
||||
if self.config_initialized:
|
||||
return
|
||||
cmd = "seaf-cli init -d %s" % self.app_dir
|
||||
_lg.info("Initializing seafile config: %s", cmd)
|
||||
cmd = ["seaf-cli", "init", "-d", self.app_dir]
|
||||
_lg.info("Initializing seafile config: %s", " ".join(cmd))
|
||||
subprocess.run(self.__gen_cmd(cmd))
|
||||
|
||||
def start_daemon(self):
|
||||
cmd = "seaf-cli start"
|
||||
_lg.info("Starting seafile daemon: %s", cmd)
|
||||
cmd = ["seaf-cli", "start"]
|
||||
_lg.info("Starting seafile daemon: %s", " ".join(cmd))
|
||||
subprocess.run(self.__gen_cmd(cmd))
|
||||
_lg.info("Waiting for seafile daemon to start")
|
||||
|
||||
@@ -117,8 +117,8 @@ class SeafileClient:
|
||||
_lg.info("Seafile daemon is ready")
|
||||
|
||||
def stop_daemon(self):
|
||||
cmd = "seaf-cli stop"
|
||||
_lg.info("Stopping seafile daemon: %s", cmd)
|
||||
cmd = ["seaf-cli", "stop"]
|
||||
_lg.info("Stopping seafile daemon: %s", " ".join(cmd))
|
||||
subprocess.run(self.__gen_cmd(cmd))
|
||||
_lg.info("Waiting for seafile daemon to stop")
|
||||
|
||||
@@ -135,9 +135,12 @@ class SeafileClient:
|
||||
return lib_id
|
||||
return None
|
||||
|
||||
def sync_lib(self, lib_id: str, parent_dir: str = const.DEFAULT_LIBS_DIR):
|
||||
lib_name = self.remote_libraries[lib_id]
|
||||
lib_dir = os.path.join(parent_dir, lib_name.replace(" ", "_"))
|
||||
def sync_lib(self, lib_id: str, lib_dir: str):
|
||||
"""
|
||||
Sync a library into an already resolved directory. The caller decides
|
||||
the directory, see dsc.paths.plan_lib_dirs: the name comes from the
|
||||
server and must not be turned into a path here.
|
||||
"""
|
||||
create_dir(lib_dir)
|
||||
cmd = [
|
||||
"seaf-cli",
|
||||
@@ -146,13 +149,13 @@ class SeafileClient:
|
||||
"-s", self.url,
|
||||
"-d", lib_dir,
|
||||
"-u", self.user,
|
||||
"-p", '"' + self.password + '"',
|
||||
"-p", self.password,
|
||||
]
|
||||
_lg.info(
|
||||
"Syncing library %s: %s", lib_name,
|
||||
"Syncing library %s into %s: %s", lib_id, lib_dir,
|
||||
" ".join(hide_password(cmd, self.password)),
|
||||
)
|
||||
subprocess.run(self.__gen_cmd(" ".join(cmd)))
|
||||
subprocess.run(self.__gen_cmd(cmd))
|
||||
|
||||
def __print_tx_task(self, tx_task) -> str:
|
||||
""" Print transfer task status """
|
||||
@@ -234,8 +237,8 @@ class SeafileClient:
|
||||
prev_status[library] = cur_status[library]
|
||||
|
||||
def get_local_libraries(self) -> set:
|
||||
cmd = "seaf-cli list"
|
||||
_lg.info("Listing local libraries: %s", cmd)
|
||||
cmd = ["seaf-cli", "list"]
|
||||
_lg.info("Listing local libraries: %s", " ".join(cmd))
|
||||
out = subprocess.check_output(self.__gen_cmd(cmd))
|
||||
out = out.decode().splitlines()[1:] # first line is a header
|
||||
|
||||
@@ -256,8 +259,8 @@ class SeafileClient:
|
||||
continue
|
||||
|
||||
# check current value
|
||||
cmd = f"seaf-cli config -k {key}"
|
||||
_lg.info("Checking seafile client option: %s", cmd)
|
||||
cmd = ["seaf-cli", "config", "-k", key]
|
||||
_lg.info("Checking seafile client option: %s", " ".join(cmd))
|
||||
proc = subprocess.run(self.__gen_cmd(cmd), stdout=subprocess.PIPE)
|
||||
# stdout looks like "option = value"
|
||||
cur_value = proc.stdout.decode().strip()
|
||||
@@ -269,8 +272,8 @@ class SeafileClient:
|
||||
continue
|
||||
|
||||
# set new value
|
||||
cmd = f"seaf-cli config -k {key} -v {value}"
|
||||
_lg.info("Setting seafile client option: %s", cmd)
|
||||
cmd = ["seaf-cli", "config", "-k", key, "-v", str(value)]
|
||||
_lg.info("Setting seafile client option: %s", " ".join(cmd))
|
||||
subprocess.run(self.__gen_cmd(cmd))
|
||||
need_restart = True
|
||||
|
||||
|
||||
+15
@@ -40,6 +40,21 @@ def create_dir(dir_path: str):
|
||||
raise RuntimeError(f"Data dir {dir_path} is not a directory")
|
||||
|
||||
|
||||
def user_cmd(argv: list) -> list:
|
||||
"""
|
||||
Wrap a command so that it runs as the seafile user.
|
||||
|
||||
``runuser --`` execs the command directly. ``su -c`` would hand it to a
|
||||
login shell instead, which turns every server-provided value in the
|
||||
command into shell code. Like ``su``, ``runuser`` sets HOME, USER and a
|
||||
default PATH for the target user, so seaf-cli still finds its config and
|
||||
its own wrapper.
|
||||
"""
|
||||
if not isinstance(argv, list):
|
||||
raise TypeError(f"Command must be a list of arguments, got {type(argv).__name__}")
|
||||
return ["runuser", "-u", DEFAULT_USERNAME, "--"] + argv
|
||||
|
||||
|
||||
def hide_password(cmd: list, password: str) -> list:
|
||||
cmd = cmd.copy()
|
||||
for i, arg in enumerate(cmd):
|
||||
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
"""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
|
||||
Reference in new issue
Block a user