From 2891bc89e6b569effc26b7e7fd5901c864c92ed2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kov=C3=A1cs=20B=C3=A1lint=20Hunor?= Date: Tue, 6 Oct 2026 08:37:38 +0300 Subject: [PATCH 1/4] Root actions through a privileged fd-relative pkexec helper Adds an AOT elevation helper with a strict JSON protocol, helper-side verification, a frozen confirmation plan and a polkit policy. Portable AppImage/Flatpak builds disable root actions. Co-Authored-By: GPT-6.1-sol (OpenAI Codex) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LeKXJc3DnK3PzAqwgA72NY --- Files.slnx | 1 + docs/linux-port/packaging.md | 8 + docs/linux-port/threat-model-elevation.md | 48 +++ packaging/linux/appimage/build-appimage.sh | 5 + packaging/linux/aur/linuxfiles-bin/PKGBUILD | 7 +- packaging/linux/aur/linuxfiles/PKGBUILD | 14 +- .../io.github.memergamer.LinuxFiles.yml | 3 + ....memergamer.LinuxFiles.root-actions.policy | 16 + scripts/linux/install-local.sh | 12 +- scripts/linux/install-root-helper.py | 35 ++ scripts/linux/publish.sh | 10 +- ...entPageContextFlyoutFactory.RootActions.cs | 62 ++++ .../ContentPageContextFlyoutFactory.cs | 1 + src/Files.App/Helpers/RootActionsHelper.cs | 141 ++++++++ src/Files.App/Strings/en-US/Resources.resw | 27 +- .../Elevation/ElevationHelperProtocol.cs | 125 +++++++ .../Elevation/IElevationService.cs | 73 +++- ...iles.Platform.Linux.ElevationHelper.csproj | 22 ++ .../HelperEngine.cs | 314 ++++++++++++++++++ .../Program.cs | 48 +++ .../Elevation/ElevationPlanPreview.cs | 91 +++++ .../Elevation/ElevationTrust.cs | 188 +++++++++++ .../Elevation/PkexecElevationService.cs | 188 ++++++++--- .../Native/ElevationNative.cs | 131 ++++++++ .../Native/PosixNative.cs | 9 + ...mIntegrationServiceCollectionExtensions.cs | 1 + .../Files.Platform.Tests.csproj | 1 + .../SystemIntegration/ElevationHelperTests.cs | 233 +++++++++++++ .../ElevationPlanPreviewTests.cs | 95 ++++++ .../ElevationServiceTests.cs | 127 ++++--- 30 files changed, 1920 insertions(+), 116 deletions(-) create mode 100644 docs/linux-port/threat-model-elevation.md create mode 100644 packaging/linux/io.github.memergamer.LinuxFiles.root-actions.policy create mode 100644 scripts/linux/install-root-helper.py create mode 100644 src/Files.App/Data/Factories/ContentPageContextFlyoutFactory.RootActions.cs create mode 100644 src/Files.App/Helpers/RootActionsHelper.cs create mode 100644 src/Files.Platform.Abstractions/Elevation/ElevationHelperProtocol.cs create mode 100644 src/Files.Platform.Linux.ElevationHelper/Files.Platform.Linux.ElevationHelper.csproj create mode 100644 src/Files.Platform.Linux.ElevationHelper/HelperEngine.cs create mode 100644 src/Files.Platform.Linux.ElevationHelper/Program.cs create mode 100644 src/Files.Platform.Linux/Elevation/ElevationPlanPreview.cs create mode 100644 src/Files.Platform.Linux/Elevation/ElevationTrust.cs create mode 100644 src/Files.Platform.Linux/Native/ElevationNative.cs create mode 100644 tests/Files.Platform.Tests/SystemIntegration/ElevationHelperTests.cs create mode 100644 tests/Files.Platform.Tests/SystemIntegration/ElevationPlanPreviewTests.cs diff --git a/Files.slnx b/Files.slnx index 29f232f17d31..a0b303381e7f 100644 --- a/Files.slnx +++ b/Files.slnx @@ -28,6 +28,7 @@ + diff --git a/docs/linux-port/packaging.md b/docs/linux-port/packaging.md index 91ead674b1ff..ca7ad31dd41f 100644 --- a/docs/linux-port/packaging.md +++ b/docs/linux-port/packaging.md @@ -141,3 +141,11 @@ Try it: `curl -LO https://github.com/MemerGamer/LinuxFiles/releases/download/nig - Whether an SVG logo can be supplied for `scalable/`. - Runtime behaviour of the Flatpak (sandboxed run, portals) and a `namcap` lint of the AUR package. - arm64 publishing has not been tested. + +## Root actions + +Native AUR packages install a root-owned Native AOT helper at `/usr/lib/linuxfiles/files-elevation-helper` and `packaging/linux/io.github.memergamer.LinuxFiles.root-actions.policy` under `/usr/share/polkit-1/actions/`. Install polkit to enable the menu. The helper is never setuid; the action uses `auth_admin` without retaining authorization. AOT publishing requires clang and zlib development files. + +For local installation, publish normally and opt in with `scripts/linux/install-local.sh --install-root-helper`. This separately runs `sudo python3 scripts/linux/install-root-helper.py --from artifacts/linux-x64/elevation-helper`; the app stays in the user prefix. To inspect the package layout without privileges, run `python3 scripts/linux/install-root-helper.py --from --destdir `. Remove the system helper and policy as administrator when uninstalling; the user-local uninstaller intentionally cannot remove system files. + +AppImage and Flatpak omit the helper, install no policy, and disable Root actions. They cannot supply host elevation, even when the native helper is installed separately. See [the elevation threat model](threat-model-elevation.md) for protocol, failure semantics and limitations. diff --git a/docs/linux-port/threat-model-elevation.md b/docs/linux-port/threat-model-elevation.md new file mode 100644 index 000000000000..46403a59afe7 --- /dev/null +++ b/docs/linux-port/threat-model-elevation.md @@ -0,0 +1,48 @@ +# Threat model: root actions (Linux) + +Root actions offers delete, rename and paste (copy or move). It stays hidden in virtual locations and in AppImage/Flatpak, and unless pkexec, the native helper and its policy are installed at trusted system locations. AppImage/Flatpak neither install nor execute a host elevation helper. + +## Privilege boundary + +The only elevated executable is `/usr/lib/linuxfiles/files-elevation-helper`, a small .NET 10 Native AOT console program. Native packages install it root-owned, mode 0755, with root-owned ancestors that are not writable by group/others. It has no setuid bit. The matching `packaging/linux/io.github.memergamer.LinuxFiles.root-actions.policy` requires `auth_admin` for an active session; other sessions are denied. Authorization is not retained (`auth_admin_keep` is never used). + +Files executes the trusted absolute pkexec path with exactly one argument: the fixed helper path. It sends the confirmed plan on stdin, closes stdin, and reads JSON results capped at 512 KiB. It never executes rm, cp, mv, a shell, commands from JSON, or programs found through PATH. The subprocess starts with a cleared environment and LANG=C. pkexec removes dangerous loader variables before launching the AOT executable; the helper requires effective uid 0 and a non-root PKEXEC_UID, then clears its environment, sets umask 0077 and changes cwd to `/`. It takes no command-line switches and has no daemon, plugin loading, configuration files or test mode. + +## Frozen confirmation and protocol + +ElevationPlanPreview copies the plan into read-only collections and shows the exact operation, absolute sources and target encoded as JSON, together with the executable and the stdin marker. DisplaySanitizer escapes control, bidi and zero-width characters; oversized previews cannot be confirmed. Confirmation hands out that displayed plan once. Cancel, close and Escape do nothing; Cancel is the default button. Only one dialog can be open. + +The application compares the complete serialized request with the displayed command data before sending it. Its path checks are UI preflight only. It never trusts unprivileged post-operation filesystem checks, including for destinations under `/root`. + +The helper independently requires version 1, exactly the known JSON fields, unique property names, a known operation, 1–64 unique, non-overlapping sources, and normalized absolute paths with no NUL, empty components, dot components or root `/`. Rename carries an absolute target in the same parent; copy/move cannot target the source or its descendants or introduce duplicate destination names. Input is strict UTF-8, at most 64 KiB, with bounded JSON depth. Unknown/missing fields, trailing data and unsupported versions fail closed. Results have one ordered entry per source; malformed, missing, duplicate or mismatched results can never become success in the app. + +## Descriptor-relative execution + +After authorization, the helper opens `/` once and walks every ancestor with single-component descriptor-relative opens. It prefers openat2 with `RESOLVE_NO_SYMLINKS|RESOLVE_BENEATH`. Only ENOSYS permits fallback to the per-component `O_PATH|O_NOFOLLOW|O_DIRECTORY` walk. Other errors fail closed. Each opened ancestor must be root/caller-owned and not group/other-writable, except root-owned sticky directories. No path operation follows a user-controlled symlink. + +The complete source trees and destination directory are pinned before mutation. Every entry is checked with statx before and after opening, requiring identity, mode, uid, size, timestamps and mount id. Sources are regular files or directories only: symlinks (including inside trees), special files and mount crossings are refused. Tree walks stop at 4096 entries or depth 64; descriptor exhaustion and enumeration errors are failures. Invalid UTF-8 names are refused, rather than converted into different names. The helper checks source/destination containment by descriptor identity as well as by path. + +- Delete uses unlinkat relative to pinned parents, recursing only into pinned source directories. Identity and tree membership are checked again before deleting; absence is confirmed with a descriptor-relative statx lookup. Only ENOENT proves absence; permission/stat failures never do. +- Rename uses renameat2(RENAME_NOREPLACE) with the pinned parent and plain leaf names. Collisions, including dangling symlinks, fail atomically. No unsafe rename fallback exists. The renamed identity and source absence are checked inside the helper. +- Copy opens source files without following links, creates destination files with O_CREAT|O_EXCL and directories with mkdirat (existing names fail), and transfers bytes through descriptors. Copies are root-owned mode 0600, directories mode 0700, and carry no original ownership, setuid/setgid bits, timestamps, xattrs or hardlink relationships. Setuid/setgid source trees are refused. Size and SHA-256 of source and the helper's own created output are verified; completed output and its destination parent are fsynced and its directory membership and metadata are checked again. +- Move follows the same verified-copy path in the same authenticated helper process. It removes only the pinned originals, checking identity and file metadata again before removal. A skipped/colliding/failed/unverified copy never triggers source deletion. Each item reports its own result; failure does not erase another item's verified success. + +Partial outputs remain in place and are reported as failures. The helper never performs recursive cleanup through an untrusted destination name. Partial deletion/move can leave a partially changed source tree; surviving originals remain, and the UI receives failure rather than success. + +## Races and residual limits + +An ancestor replaced by a symlink during polkit authentication is refused. An ancestor renamed/replaced after opening cannot redirect the operation: it acts on the original pinned directory, even if its pathname now displays another object. Destination collisions never replace existing objects; copy verification concerns the helper-created inode, not an unrelated file that happens to exist. + +Authentication authorizes the absolute plan after the prompt; the app does not attest every source inode before authentication. Concurrent modifications to regular file contents or directory entries are rejected when detected by identity, metadata, membership and byte checks. Linux has no atomic compare-inode-and-unlink primitive and no snapshot of concurrently writable file contents: a writer retaining access can still race a final check, and operations are not a transaction. No symlink race can escape the pinned parents, and no unexpected verification failure is accepted. For move, modifications detected before deletion preserve the affected original; undetectable writes after its final check remain a limitation. Users should quiesce writers before requesting privileged moves. + +Root authentication intentionally permits destructive actions on root-owned data. An administrator already able to replace the installed executable/policy or perform privileged mount operations is outside this boundary. Kernel/glibc with statx mount identity and renameat2 support are required; missing required facilities disable/fail the operation rather than weakening checks. + +## Packaging and verification + +AUR source packages AOT-publish the helper and install it and the policy at the fixed paths. Binary AUR packages use the helper in the release tarball. `publish.sh` includes it in `elevation-helper/`; AppImage/Flatpak explicitly remove it and set FILES_DISABLE_ROOT_ACTIONS=1 and add a .root-actions-disabled marker (the app also detects their runtime). `install-local.sh --install-root-helper` is explicit opt-in to a separate system installation through sudo; a default user-local install cannot supply a trusted helper. Policy/helper installation is never performed by the running app. + +Non-root tests run HelperEngine directly against private temp trees, substituting the current uid for the elevated identity and the observed root-directory owner for root in uid-mapped sandboxes. Hooks exist only in that in-process API; stdin cannot enable them. Tests cover both openat2 and forced fallback, authentication-time and post-pin symlink swaps, source leaf races, nested copy/move, no-replace collisions, corrupt/modified copies, changed originals and partial batches. App tests cover protocol failures, forged plans, helper-only verification and packaging/trust gates. Real polkit authentication, root-only directories, mount races and distro package installation require a separate administrator review; automated tests never invoke pkexec or use the real display. + +## Not implemented + +Open terminal as root and edit as root remain outside this helper's protocol. Symlink copying, timestamp/permission preservation, transactional rollback and concurrent-writer snapshots are unsupported. diff --git a/packaging/linux/appimage/build-appimage.sh b/packaging/linux/appimage/build-appimage.sh index a4a7c5c9455c..4872a53907fd 100755 --- a/packaging/linux/appimage/build-appimage.sh +++ b/packaging/linux/appimage/build-appimage.sh @@ -54,6 +54,10 @@ fetch "https://github.com/AppImage/type2-runtime/releases/download/20251108/runt rm -rf "$appdir" mkdir -p "$appdir/usr/bin" "$appdir/usr/lib/linuxfiles" "$appdir/usr/share" cp -a "$from/." "$appdir/usr/lib/linuxfiles/" +# Portable images cannot install the host polkit policy or privileged helper. +rm -rf "$appdir/usr/lib/linuxfiles/elevation-helper" +rm -f "$appdir/usr/lib/linuxfiles/files-elevation-helper" +touch "$appdir/usr/lib/linuxfiles/.root-actions-disabled" install -Dm755 "$root/packaging/linux/files" "$appdir/usr/bin/files" install -Dm644 "$root/packaging/linux/$app_id.desktop" "$appdir/usr/share/applications/$app_id.desktop" install -Dm644 "$root/packaging/linux/$app_id.metainfo.xml" "$appdir/usr/share/metainfo/$app_id.metainfo.xml" @@ -68,6 +72,7 @@ cat > "$appdir/AppRun" <<'RUN' #!/bin/sh here="$(dirname "$(readlink -f "$0")")" export FILES_LIBDIR="$here/usr/lib/linuxfiles" +export FILES_DISABLE_ROOT_ACTIONS=1 exec "$here/usr/bin/files" "$@" RUN chmod +x "$appdir/AppRun" diff --git a/packaging/linux/aur/linuxfiles-bin/PKGBUILD b/packaging/linux/aur/linuxfiles-bin/PKGBUILD index 4af359f24a67..ee06f0f5b0ca 100644 --- a/packaging/linux/aur/linuxfiles-bin/PKGBUILD +++ b/packaging/linux/aur/linuxfiles-bin/PKGBUILD @@ -13,7 +13,8 @@ url='https://github.com/MemerGamer/LinuxFiles' license=('MIT') # The runtime is bundled; these are the native libraries Skia/Uno load at runtime. depends=('fontconfig' 'freetype2' 'libx11' 'libxcursor' 'libxrandr' 'libxi' 'libxext' 'mesa' 'glib2' 'hicolor-icon-theme') -optdepends=('gvfs: network and MTP locations' +optdepends=('polkit: authenticated root actions' + 'gvfs: network and MTP locations' 'udisks2: mount and eject drives' 'libsecret: saved network credentials (Secret Service provider)' 'gnome-disk-utility: format and manage drives') @@ -38,6 +39,10 @@ package() { install -dm755 "$pkgdir/usr/lib/$_pkgname" cp -a app/. "$pkgdir/usr/lib/$_pkgname/" + install -Dm755 app/elevation-helper/files-elevation-helper "$pkgdir/usr/lib/linuxfiles/files-elevation-helper" + rm -rf "$pkgdir/usr/lib/$_pkgname/elevation-helper" + install -Dm644 packaging/linux/io.github.memergamer.LinuxFiles.root-actions.policy \ + "$pkgdir/usr/share/polkit-1/actions/io.github.memergamer.LinuxFiles.root-actions.policy" # The launcher finds /usr/lib/linuxfiles/Files.dll on its own. install -Dm755 packaging/linux/files "$pkgdir/usr/bin/files" diff --git a/packaging/linux/aur/linuxfiles/PKGBUILD b/packaging/linux/aur/linuxfiles/PKGBUILD index 5e4370dffeaa..316143e4397f 100644 --- a/packaging/linux/aur/linuxfiles/PKGBUILD +++ b/packaging/linux/aur/linuxfiles/PKGBUILD @@ -10,8 +10,8 @@ url='https://github.com/MemerGamer/LinuxFiles' license=('MIT') # Self-contained publish bundles the runtime; native libs below are loaded by Skia/Uno at runtime. depends=('fontconfig' 'freetype2' 'libx11' 'libxcursor' 'libxrandr' 'libxi' 'libxext' 'mesa' 'glib2' 'hicolor-icon-theme') -makedepends=('dotnet-sdk>=10.0' 'git') -optdepends=('gvfs: network and MTP locations' 'gnome-disk-utility: format and manage drives') +makedepends=('dotnet-sdk>=10.0' 'git' 'clang' 'zlib') +optdepends=('polkit: authenticated root actions' 'gvfs: network and MTP locations' 'gnome-disk-utility: format and manage drives') options=('!strip') # .NET single-file/self-contained binaries break when stripped source=("$pkgname-$pkgver.tar.gz::$url/archive/refs/tags/linux-v$pkgver.tar.gz") sha256sums=('SKIP') # replace with the real checksum on release @@ -23,10 +23,13 @@ build() { aarch64) _rid=linux-arm64 ;; esac export DOTNET_CLI_TELEMETRY_OPTOUT=1 DOTNET_NOLOGO=1 - dotnet publish src/Files.App/Files.App.csproj \ + MSBUILDDISABLENODEREUSE=1 nice -n 19 dotnet publish src/Files.App/Files.App.csproj \ -f net10.0-desktop -c Release -r "$_rid" --self-contained \ -p:PublishTrimmed=false -p:PublishAot=false \ - -o "$srcdir/publish" + -nodeReuse:false -m:2 -o "$srcdir/publish" + MSBUILDDISABLENODEREUSE=1 nice -n 19 dotnet publish \ + src/Files.Platform.Linux.ElevationHelper/Files.Platform.Linux.ElevationHelper.csproj \ + -c Release -r "$_rid" --self-contained -nodeReuse:false -m:2 -o "$srcdir/helper" } package() { @@ -35,6 +38,9 @@ package() { install -dm755 "$pkgdir/usr/lib/linuxfiles" cp -a "$srcdir/publish/." "$pkgdir/usr/lib/linuxfiles/" + install -Dm755 "$srcdir/helper/files-elevation-helper" "$pkgdir/usr/lib/linuxfiles/files-elevation-helper" + install -Dm644 packaging/linux/io.github.memergamer.LinuxFiles.root-actions.policy \ + "$pkgdir/usr/share/polkit-1/actions/io.github.memergamer.LinuxFiles.root-actions.policy" # The launcher finds /usr/lib/linuxfiles/Files.dll on its own. install -Dm755 packaging/linux/files "$pkgdir/usr/bin/files" diff --git a/packaging/linux/flatpak/io.github.memergamer.LinuxFiles.yml b/packaging/linux/flatpak/io.github.memergamer.LinuxFiles.yml index dc8ebd043bb5..6fc9ac50f8e6 100644 --- a/packaging/linux/flatpak/io.github.memergamer.LinuxFiles.yml +++ b/packaging/linux/flatpak/io.github.memergamer.LinuxFiles.yml @@ -13,6 +13,7 @@ sdk: org.freedesktop.Sdk command: files finish-args: + - --env=FILES_DISABLE_ROOT_ACTIONS=1 - --share=ipc # Uno Skia desktop is X11-only; Wayland sessions use XWayland. - --socket=x11 @@ -40,6 +41,8 @@ modules: build-commands: - tar -xzf files-linux-x64.tar.gz - mkdir -p /app/lib/linuxfiles && cp -a linux-x64/. /app/lib/linuxfiles/ + - rm -rf /app/lib/linuxfiles/elevation-helper && rm -f /app/lib/linuxfiles/files-elevation-helper + - touch /app/lib/linuxfiles/.root-actions-disabled - install -Dm755 packaging/linux/files /app/bin/files - install -Dm644 packaging/linux/io.github.memergamer.LinuxFiles.desktop /app/share/applications/io.github.memergamer.LinuxFiles.desktop diff --git a/packaging/linux/io.github.memergamer.LinuxFiles.root-actions.policy b/packaging/linux/io.github.memergamer.LinuxFiles.root-actions.policy new file mode 100644 index 000000000000..9a51c8d92987 --- /dev/null +++ b/packaging/linux/io.github.memergamer.LinuxFiles.root-actions.policy @@ -0,0 +1,16 @@ + + + + LinuxFiles + https://github.com/MemerGamer/LinuxFiles + + Perform the confirmed Files root operation + Authentication is required to perform this file operation as root + + no + no + auth_admin + + /usr/lib/linuxfiles/files-elevation-helper + + diff --git a/scripts/linux/install-local.sh b/scripts/linux/install-local.sh index 9b99d8e42289..f2de24f34b89 100755 --- a/scripts/linux/install-local.sh +++ b/scripts/linux/install-local.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash -# Install a published Files build for the current user (no root). -# Usage: scripts/linux/install-local.sh [--from artifacts/linux-x64] [--prefix ~/.local] +# Install the app for the current user; --install-root-helper opts into a separate system install. +# Usage: scripts/linux/install-local.sh [--from artifacts/linux-x64] [--prefix ~/.local] [--install-root-helper] # Layout: /share/linuxfiles, /bin/files, desktop entry, icons, metainfo. set -euo pipefail @@ -8,11 +8,13 @@ root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" from="$root/artifacts/linux-x64" prefix="${HOME}/.local" app_id="io.github.memergamer.LinuxFiles" +install_helper=false while [[ $# -gt 0 ]]; do case "$1" in --from) from="$2"; shift 2 ;; --prefix) prefix="$2"; shift 2 ;; + --install-root-helper) install_helper=true; shift ;; -h|--help) sed -n '2,4p' "$0"; exit 0 ;; *) echo "Unknown argument: $1" >&2; exit 2 ;; esac @@ -44,4 +46,10 @@ done if command -v update-desktop-database >/dev/null; then update-desktop-database "$share/applications" || true; fi if command -v gtk-update-icon-cache >/dev/null; then gtk-update-icon-cache -q -t -f "$share/icons/hicolor" || true; fi +if [[ "$install_helper" == true ]]; then + [[ -x "$from/elevation-helper/files-elevation-helper" ]] || { echo "Missing AOT helper in publish output" >&2; exit 1; } + # Explicit opt-in: this installs only the fixed helper and policy as root, not the app. + /usr/bin/sudo /usr/bin/python3 "$root/scripts/linux/install-root-helper.py" --from "$from/elevation-helper" +fi + echo "Installed to $libdir; launcher at $prefix/bin/files" diff --git a/scripts/linux/install-root-helper.py b/scripts/linux/install-root-helper.py new file mode 100644 index 000000000000..b486a6511852 --- /dev/null +++ b/scripts/linux/install-root-helper.py @@ -0,0 +1,35 @@ +# Copyright (c) Files Community. Licensed under the MIT License. +"""Install the AOT root helper and polkit policy; invoked explicitly with sudo by install-local.sh.""" +import argparse +import os +from pathlib import Path +import stat +import subprocess + +parser = argparse.ArgumentParser(description=__doc__) +parser.add_argument("--from", dest="source", type=Path, required=True) +parser.add_argument("--destdir", type=Path, default=Path("/"), help="package staging root (no live system changes)") +args = parser.parse_args() +if args.destdir == Path("/") and os.geteuid() != 0: + parser.error("live installation requires root") +helper = args.source / "files-elevation-helper" +policy = Path(__file__).resolve().parents[2] / "packaging/linux/io.github.memergamer.LinuxFiles.root-actions.policy" +if helper.is_symlink() or not helper.is_file(): + parser.error("expected a published AOT helper, not a symlink") +for source, relative, mode in ((helper, "usr/lib/linuxfiles/files-elevation-helper", "755"), + (policy, "usr/share/polkit-1/actions/io.github.memergamer.LinuxFiles.root-actions.policy", "644")): + destination = args.destdir / relative + if args.destdir == Path("/"): + for parent in reversed(destination.parents): + if not parent.exists(): + parent.mkdir(mode=0o755) + info = parent.lstat() + if not stat.S_ISDIR(info.st_mode) or info.st_uid != 0 or info.st_mode & 0o022: + parser.error(f"untrusted installation directory: {parent}") + if destination.is_symlink(): + parser.error(f"refusing symlink destination: {destination}") + command = ["/usr/bin/install", "-D", "-m", mode] + if args.destdir == Path("/"): + command += ["-o", "root", "-g", "root"] + subprocess.run(command + ["--", str(source), str(destination)], check=True) +print(f"Installed helper and policy under {args.destdir}") diff --git a/scripts/linux/publish.sh b/scripts/linux/publish.sh index 378e2392a477..1af7653b756e 100755 --- a/scripts/linux/publish.sh +++ b/scripts/linux/publish.sh @@ -27,11 +27,17 @@ out="$root/artifacts/$rid" rm -rf "$out" # Trimming and AOT are intentionally not enabled. -dotnet publish "$root/src/Files.App/Files.App.csproj" \ +MSBUILDDISABLENODEREUSE=1 nice -n 19 dotnet publish "$root/src/Files.App/Files.App.csproj" \ -f net10.0-desktop -c Release -r "$rid" \ --self-contained "$self_contained" \ -p:PublishTrimmed=false -p:PublishAot=false \ - -o "$out" + -nodeReuse:false -m:2 -o "$out" + +# This AOT executable is installed separately, root-owned, by native packages only. +MSBUILDDISABLENODEREUSE=1 nice -n 19 dotnet publish \ + "$root/src/Files.Platform.Linux.ElevationHelper/Files.Platform.Linux.ElevationHelper.csproj" \ + -c Release -r "$rid" --self-contained -nodeReuse:false -m:2 \ + -o "$out/elevation-helper" echo "Published to $out ($(du -sh "$out" | cut -f1))" diff --git a/src/Files.App/Data/Factories/ContentPageContextFlyoutFactory.RootActions.cs b/src/Files.App/Data/Factories/ContentPageContextFlyoutFactory.RootActions.cs new file mode 100644 index 000000000000..b623e29121c1 --- /dev/null +++ b/src/Files.App/Data/Factories/ContentPageContextFlyoutFactory.RootActions.cs @@ -0,0 +1,62 @@ +// Copyright (c) Files Community +// Licensed under the MIT License. + +using Windows.Storage; + +namespace Files.App.Data.Factories +{ + public static partial class ContentPageContextFlyoutFactory + { + /// + /// LINUX-TODO(root-actions): "Open terminal here as root" and "Edit as root", see docs/linux-port/threat-model-elevation.md. + /// The Linux "Root actions" submenu (hidden on Windows and when pkexec is missing). Each entry asks polkit to authenticate. + /// + internal static ContextMenuFlyoutItemViewModel GetRootActionsItem(List selectedItems, bool itemsSelected, string? workingDirectory) + { + var items = new List(); + var canUse = OperatingSystem.IsLinux() && RootActionsHelper.IsAvailable; + + if (canUse && itemsSelected) + { + var paths = selectedItems.Select(item => item.ItemPath).Where(path => !string.IsNullOrEmpty(path)).Select(path => path!).ToList(); + if (paths.Count == selectedItems.Count && paths.Count > 0) + { + items.Add(new ContextMenuFlyoutItemViewModel + { + Text = Strings.RootDeleteAction.GetLocalizedResource(), + Command = new AsyncRelayCommand(() => RootActionsHelper.DeleteAsync(paths)), + }); + + if (paths.Count == 1) + { + items.Add(new ContextMenuFlyoutItemViewModel + { + Text = Strings.RootRenameAction.GetLocalizedResource(), + Command = new AsyncRelayCommand(() => RootActionsHelper.RenameAsync(paths[0])), + }); + } + } + } + + // Paste goes into the open folder, or into the single selected folder + var pasteTarget = itemsSelected + ? (selectedItems.Count == 1 && selectedItems[0].PrimaryItemAttribute == StorageItemTypes.Folder ? selectedItems[0].ItemPath : null) + : workingDirectory; + if (canUse && !string.IsNullOrEmpty(pasteTarget) && pasteTarget.StartsWith('/')) + { + items.Add(new ContextMenuFlyoutItemViewModel + { + Text = Strings.RootPasteAction.GetLocalizedResource(), + Command = new AsyncRelayCommand(() => RootActionsHelper.PasteAsync(pasteTarget)), + }); + } + + return new ContextMenuFlyoutItemViewModel + { + Text = Strings.RootActions.GetLocalizedResource(), + ShowItem = items.Count > 0, + Items = items, + }; + } + } +} diff --git a/src/Files.App/Data/Factories/ContentPageContextFlyoutFactory.cs b/src/Files.App/Data/Factories/ContentPageContextFlyoutFactory.cs index 65c87bd92d21..d7568821be43 100644 --- a/src/Files.App/Data/Factories/ContentPageContextFlyoutFactory.cs +++ b/src/Files.App/Data/Factories/ContentPageContextFlyoutFactory.cs @@ -473,6 +473,7 @@ public static List GetBaseItemMenuItems( new ContextMenuFlyoutItemViewModelBuilder(Commands.SetAsAppBackground).Build(), ] }, + GetRootActionsItem(selectedItems, itemsSelected, itemViewModel?.WorkingDirectory), new ContextMenuFlyoutItemViewModelBuilder(Commands.RotateLeft) { IsVisible = !currentInstanceViewModel.IsPageTypeRecycleBin diff --git a/src/Files.App/Helpers/RootActionsHelper.cs b/src/Files.App/Helpers/RootActionsHelper.cs new file mode 100644 index 000000000000..487048233881 --- /dev/null +++ b/src/Files.App/Helpers/RootActionsHelper.cs @@ -0,0 +1,141 @@ +// Copyright (c) Files Community +// Licensed under the MIT License. + +using Files.Platform.Abstractions.Clipboard; +using Files.Platform.Abstractions.Elevation; +using Files.Platform.Linux.Elevation; +using Files.Platform.Linux.Launching; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using System.IO; + +namespace Files.App.Helpers +{ + /// + /// Linux "Root actions" (like Dolphin's): delete, rename and paste as root through . + /// Every operation shows the exact command first and the system's polkit prompt authenticates it. + /// + internal static class RootActionsHelper + { + private static IElevationService? Elevation => OperatingSystem.IsLinux() ? Ioc.Default.GetService() : null; + + public static bool IsAvailable => Elevation?.IsAvailable ?? false; + + private static bool dialogOpen; + + public static async Task DeleteAsync(IReadOnlyList paths) + { + if (Elevation is { } elevation) + await ConfirmAndRunAsync(elevation, new ElevationPlanPreview(_ => elevation.PlanDelete(paths)), null); + } + + public static async Task RenameAsync(string path) + { + if (Elevation is { } elevation) + await ConfirmAndRunAsync(elevation, new ElevationPlanPreview(name => elevation.PlanRename(path, name)), Path.GetFileName(path)); + } + + public static async Task PasteAsync(string destinationFolder) + { + if (Elevation is not { } elevation || Ioc.Default.GetService() is not { } clipboard) + return; + + var files = await clipboard.GetFilesAsync(); + if (files is null || files.Paths.Count == 0) + return; + + var move = files.Operation == ClipboardOperation.Cut; + await ConfirmAndRunAsync(elevation, new ElevationPlanPreview(_ => move + ? elevation.PlanMove(files.Paths, destinationFolder) + : elevation.PlanCopy(files.Paths, destinationFolder)), null); + } + + // The plan that runs is the one object the preview hands out when the confirm button is clicked, and only when its text is on screen. + // With a name box (rename) the plan is recomputed on every keystroke and confirm stays disabled while it is invalid. + private static async Task ConfirmAndRunAsync(IElevationService elevation, ElevationPlanPreview preview, string? initialName) + { + if (dialogOpen) + return; + + dialogOpen = true; + try + { + preview.Update(initialName ?? string.Empty); + var text = new TextBlock { Text = preview.DisplayText, IsTextSelectionEnabled = true, TextWrapping = TextWrapping.Wrap }; + TextBox? box = initialName is null ? null : new TextBox { Text = initialName }; + var dialog = CreateDialog(text, box); + dialog.IsPrimaryButtonEnabled = preview.CanConfirm; + + if (box is not null) + { + box.TextChanged += (_, _) => + { + preview.Update(box.Text); + text.Text = preview.DisplayText; + dialog.IsPrimaryButtonEnabled = preview.CanConfirm; + }; + } + + ElevatedPlan? confirmed = null; + dialog.PrimaryButtonClick += (_, args) => + { + confirmed = preview.Confirm(text.Text); + args.Cancel = confirmed is null; + }; + + var result = await dialog.TryShowAsync(); + preview.Close(); + if (result == ContentDialogResult.Primary && confirmed is not null) + await RunAsync(elevation, confirmed); + } + finally + { + dialogOpen = false; + } + } + + private static async Task RunAsync(IElevationService elevation, ElevatedPlan plan) + { + await ReportAsync(await elevation.RunAsync(plan)); + + if (Ioc.Default.GetRequiredService().ShellPage is { } shellPage) + await shellPage.RefreshIfNoWatcherExistsAsync(); + } + + private static async Task ReportAsync(ElevatedResult result) + { + if (result.Succeeded || result.WasDismissed) + return result.Succeeded; + + var dialog = new ContentDialog + { + Title = Strings.RootActions.GetLocalizedResource(), + Content = string.Format(Strings.RootActionFailed.GetLocalizedResource(), DisplaySanitizer.Field(result.Error, 400)), + PrimaryButtonText = Strings.OK.GetLocalizedResource(), + XamlRoot = MainWindow.Instance.Content.XamlRoot, + }; + + await dialog.TryShowAsync(); + return false; + } + + private static ContentDialog CreateDialog(UIElement commandText, UIElement? extra) + { + var panel = new StackPanel { Spacing = 8 }; + if (extra is not null) + panel.Children.Add(extra); + panel.Children.Add(new TextBlock { Text = Strings.RootConfirmContent.GetLocalizedResource(), TextWrapping = TextWrapping.Wrap }); + panel.Children.Add(commandText); + + return new ContentDialog + { + Title = Strings.RootConfirmTitle.GetLocalizedResource(), + Content = panel, + PrimaryButtonText = Strings.RootRunButton.GetLocalizedResource(), + CloseButtonText = Strings.Cancel.GetLocalizedResource(), + DefaultButton = ContentDialogButton.Close, + XamlRoot = MainWindow.Instance.Content.XamlRoot, + }; + } + } +} diff --git a/src/Files.App/Strings/en-US/Resources.resw b/src/Files.App/Strings/en-US/Resources.resw index 588f6b49b569..153f40063e3c 100644 --- a/src/Files.App/Strings/en-US/Resources.resw +++ b/src/Files.App/Strings/en-US/Resources.resw @@ -1,4 +1,4 @@ - + + + + Exe + net10.0 + files-elevation-helper + enable + true + true + true + true + $(DefineConstants);ELEVATION_HELPER + false + linux-x64;linux-arm64 + + + + + + + + diff --git a/src/Files.Platform.Linux.ElevationHelper/HelperEngine.cs b/src/Files.Platform.Linux.ElevationHelper/HelperEngine.cs new file mode 100644 index 000000000000..89e0509166ac --- /dev/null +++ b/src/Files.Platform.Linux.ElevationHelper/HelperEngine.cs @@ -0,0 +1,314 @@ +// Copyright (c) Files Community +// Licensed under the MIT License. + +using Files.Platform.Abstractions.Elevation; +using Files.Platform.Linux.Native; +using Microsoft.Win32.SafeHandles; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Security.Cryptography; +using static Files.Platform.Linux.Native.ElevationNative; + +namespace Files.Platform.Linux.ElevationHelper +{ + // Hooks are only an in-process test seam; the executable accepts no switches or hook configuration. + public sealed class HelperEngine(uint callerUid, Action? hook = null, bool forceFallback = false, uint trustedRootUid = 0) + { + private const int PathOnly = 0x200000 | 0x80000; + private int visited; + + private sealed class Descriptor(int fd) : IDisposable + { + public int Fd { get; } = fd; + public void Dispose() => PosixNative.Close(Fd); + } + + private sealed class Node(string name, Descriptor descriptor, Stamp stamp) : IDisposable + { + public string Name { get; } = name; + public Descriptor Descriptor { get; } = descriptor; + public Stamp Stamp { get; set; } = stamp; + public List Children { get; } = []; + public void Dispose() + { + foreach (var child in Children) child.Dispose(); + Descriptor.Dispose(); + } + } + + public HelperResponse Execute(HelperRequest request) + { + ElevationHelperProtocol.Validate(request); + visited = 0; + Descriptor? destination = null; + var opened = new List<(string Path, Descriptor? Parent, Node? Source, string Error)>(); + try + { + if (request.Operation is "copy" or "move") destination = OpenDirectoryPath(request.Target!); + // Pin the complete plan before starting any mutation. + foreach (var path in request.Sources) + { + Descriptor? parent = null; + Node? source = null; + try + { + parent = OpenDirectoryPath(Path.GetDirectoryName(path)!); + hook?.Invoke("after-parent-open", path); + source = Snapshot(parent.Fd, Path.GetFileName(path), Inspect(parent.Fd)!.Value.Mount, 0, + request.Operation is "copy" or "move"); + if (destination is not null) RefuseContainedDestination(source, destination.Fd); + opened.Add((path, parent, source, "")); + } + catch (Exception ex) when (IsOperationError(ex)) + { + source?.Dispose(); parent?.Dispose(); + opened.Add((path, null, null, Error(ex))); + } + } + hook?.Invoke("plan-pinned", ""); + var results = new List(); + foreach (var item in opened) + { + if (item.Source is null) { results.Add(new(item.Path, false, item.Error)); continue; } + try + { + var source = item.Source; + var parent = item.Parent!; + switch (request.Operation) + { + case "delete": + hook?.Invoke("before-delete", item.Path); + ValidateTree(parent.Fd, source, false); + Delete(parent.Fd, source); + break; + case "rename": + hook?.Invoke("before-rename", item.Path); + ValidateTree(parent.Fd, source, false); + var name = Path.GetFileName(request.Target!); + Rename(parent.Fd, source.Name, parent.Fd, name); + RequireSame(source.Stamp, Inspect(parent.Fd, name)!.Value); + RequireAbsent(parent.Fd, source.Name); + break; + case "copy": + case "move": + hook?.Invoke("before-copy", item.Path); + ValidateTree(parent.Fd, source, true); + using (var copy = Copy(source, destination!.Fd)) + { + hook?.Invoke("after-copy", item.Path); + ValidateTree(destination.Fd, copy, true); + ValidateTree(parent.Fd, source, true); + VerifyCopy(source, copy); + SyncParent(destination.Fd); + ValidateTree(parent.Fd, source, true); + ValidateTree(destination.Fd, copy, true); + if (request.Operation == "move") + { + hook?.Invoke("before-delete", item.Path); + ValidateTree(parent.Fd, source, true); + ValidateTree(destination.Fd, copy, true); + Delete(parent.Fd, source, verifyContent: true); + } + } + break; + } + results.Add(new(item.Path, true, "")); + } + catch (Exception ex) when (IsOperationError(ex)) + { + results.Add(new(item.Path, false, Error(ex) + (request.Operation is "copy" or "move" ? " Any partial copy is retained; remaining originals are preserved." : " The item may be partially changed."))); + } + } + return new(1, results.ToArray(), ""); + } + catch (Exception ex) when (IsOperationError(ex)) + { + return new(1, request.Sources.Select(path => new HelperItemResult(path, false, Error(ex))).ToArray(), ""); + } + finally + { + foreach (var item in opened) { item.Source?.Dispose(); item.Parent?.Dispose(); } + destination?.Dispose(); + } + } + + private Descriptor OpenDirectoryPath(string path) + { + // The filesystem root is the only absolute native open. All subsequent names are single components. + var rootFd = PosixNative.OpenAt(PosixNative.AtFdCwd, "/", PathOnly | PosixNative.ODirectory | PosixNative.ONofollow, out var errno); + if (rootFd < 0) throw PosixNative.CreateException(errno, "/"); + var current = new Descriptor(rootFd); + try + { + TrustDirectory(Inspect(current.Fd)!.Value); + foreach (var name in path.Split('/', StringSplitOptions.RemoveEmptyEntries)) + { + hook?.Invoke("before-component-open", name); + var next = new Descriptor(Open(current.Fd, name, PathOnly | PosixNative.ODirectory, forceFallback)); + try { TrustDirectory(Inspect(next.Fd)!.Value); } + catch { next.Dispose(); throw; } + current.Dispose(); current = next; + } + return current; + } + catch { current.Dispose(); throw; } + } + + private void TrustDirectory(Stamp stamp) + { + if (!stamp.Directory || (stamp.Owner != trustedRootUid && stamp.Owner != callerUid) + || ((stamp.Mode & 0x12) != 0 && !(stamp.Owner == trustedRootUid && (stamp.Mode & 0x200) != 0))) + throw new IOException("Untrusted directory component."); + } + + private Node Snapshot(int parentFd, string name, ulong mount, int depth, bool forCopy) + { + if (++visited > 4096 || depth > 64) throw new IOException("Tree exceeds safety limits."); + var before = Inspect(parentFd, name)!.Value; + if ((!before.Regular && !before.Directory) || before.Mount != mount) + throw new IOException("Symlinks, special files and mount crossings are refused."); + if (forCopy && (before.Mode & 0xC00) != 0) throw new IOException("Setuid/setgid items are refused."); + hook?.Invoke("before-entry-open", name); + var flags = before.Directory ? PosixNative.ReadOnlyFlags | PosixNative.ODirectory : + forCopy ? PosixNative.NonBlockingFlags : PathOnly; + var descriptor = new Descriptor(Open(parentFd, name, flags, forceFallback)); + var node = new Node(name, descriptor, before); + try + { + RequireSame(before, Inspect(descriptor.Fd)!.Value); + if (before.Directory) + foreach (var child in Names(descriptor.Fd)) node.Children.Add(Snapshot(descriptor.Fd, child, mount, depth + 1, forCopy)); + ValidateTree(parentFd, node, forCopy); + return node; + } + catch { node.Dispose(); throw; } + } + + private static void RefuseContainedDestination(Node source, int targetFd) + { + var target = Inspect(targetFd)!.Value; + if (source.Stamp.SameObject(target) || source.Children.Any(child => child.Stamp.Directory && Contains(child, target))) + throw new IOException("Destination lies inside source tree."); + static bool Contains(Node node, Stamp target) => node.Stamp.SameObject(target) || node.Children.Any(child => Contains(child, target)); + } + + private static void RequireSame(Stamp expected, Stamp actual) + { + if (!expected.SameObject(actual)) throw new IOException("An entry changed identity."); + } + + private static void ValidateTree(int parentFd, Node node, bool content) + { + var current = Inspect(node.Descriptor.Fd)!.Value; + RequireSame(node.Stamp, Inspect(parentFd, node.Name)!.Value); + RequireSame(node.Stamp, current); + if (!node.Stamp.Directory && content && node.Stamp != current) throw new IOException("Source content or metadata changed."); + if (node.Stamp.Directory) + { + if (!Names(node.Descriptor.Fd).Order(StringComparer.Ordinal).SequenceEqual(node.Children.Select(child => child.Name).Order(StringComparer.Ordinal))) + throw new IOException("Directory contents changed."); + foreach (var child in node.Children) ValidateTree(node.Descriptor.Fd, child, content); + } + } + + private Node Copy(Node source, int parentFd) + { + hook?.Invoke("before-output-create", source.Name); + Descriptor descriptor; + if (source.Stamp.Directory) + { + if (!PosixNative.MakeDirectoryAt(parentFd, source.Name, out var errno)) throw PosixNative.CreateException(errno, source.Name); + descriptor = new Descriptor(Open(parentFd, source.Name, PosixNative.ReadOnlyFlags | PosixNative.ODirectory, forceFallback)); + } + else + { + descriptor = new Descriptor(Open(parentFd, source.Name, PosixNative.CreateExclusiveFlags, forceFallback, 0x180)); // 0600 + } + var copy = new Node(source.Name, descriptor, Inspect(descriptor.Fd)!.Value); + try + { + if (copy.Stamp.Owner != GetEffectiveUid() || (copy.Stamp.Mode & 0xC12) != 0) + throw new IOException("Unsafe copy ownership or permissions."); + if (source.Stamp.Directory) + foreach (var child in source.Children) copy.Children.Add(Copy(child, descriptor.Fd)); + else + { + using var sourceHandle = new SafeFileHandle((nint)source.Descriptor.Fd, ownsHandle: false); + using var targetHandle = new SafeFileHandle((nint)descriptor.Fd, ownsHandle: false); + var buffer = new byte[65536]; + long offset = 0; + while ((ulong)offset < source.Stamp.Size) + { + var count = RandomAccess.Read(sourceHandle, buffer.AsSpan(0, (int)Math.Min((ulong)buffer.Length, source.Stamp.Size - (ulong)offset)), offset); + if (count == 0) throw new IOException("Source shortened during copy."); + RandomAccess.Write(targetHandle, buffer.AsSpan(0, count), offset); + offset += count; + } + Sync(descriptor.Fd); + } + Sync(descriptor.Fd); + // Capture the completed copy's metadata, not its pre-write size/timestamps. + copy.Stamp = Inspect(descriptor.Fd)!.Value; + return copy; + } + catch { copy.Dispose(); throw; } + } + + private static void VerifyCopy(Node source, Node copy) + { + if (source.Stamp.Directory != copy.Stamp.Directory || source.Children.Count != copy.Children.Count) + throw new IOException("Copy verification failed."); + if (source.Stamp.Directory) + { + for (var i = 0; i < source.Children.Count; i++) VerifyCopy(source.Children[i], copy.Children[i]); + } + else + { + if (source.Stamp.Size != Inspect(copy.Descriptor.Fd)!.Value.Size || !Hash(source).AsSpan().SequenceEqual(Hash(copy))) + throw new IOException("Copy bytes differ from source."); + } + } + + private static byte[] Hash(Node node) + { + using var handle = new SafeFileHandle((nint)node.Descriptor.Fd, ownsHandle: false); + using var hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256); + var buffer = new byte[65536]; + long offset = 0; + while ((ulong)offset < node.Stamp.Size) + { + var count = RandomAccess.Read(handle, buffer.AsSpan(0, (int)Math.Min((ulong)buffer.Length, node.Stamp.Size - (ulong)offset)), offset); + if (count == 0) throw new IOException("File shortened during verification."); + hash.AppendData(buffer, 0, count); offset += count; + } + return hash.GetHashAndReset(); + } + + private void Delete(int parentFd, Node node, bool verifyContent = false) + { + RequireSame(node.Stamp, Inspect(parentFd, node.Name)!.Value); + if (verifyContent && !node.Stamp.Directory && node.Stamp != Inspect(node.Descriptor.Fd)!.Value) + throw new IOException("Original changed after copy."); + foreach (var child in node.Children) Delete(node.Descriptor.Fd, child, verifyContent); + RequireSame(node.Stamp, Inspect(parentFd, node.Name)!.Value); + PosixNative.UnlinkAt(parentFd, node.Name, node.Stamp.Directory ? PosixNative.AtRemoveDir : 0, node.Name); + RequireAbsent(parentFd, node.Name); + } + + private void SyncParent(int fd) + { + using var readable = new Descriptor(Open(fd, ".", PosixNative.ReadOnlyFlags | PosixNative.ODirectory, forceFallback)); + Sync(readable.Fd); + } + + private static void RequireAbsent(int fd, string name) + { + if (Inspect(fd, name, allowMissing: true) is not null) throw new IOException("Entry remains after operation."); + } + + private static bool IsOperationError(Exception ex) => ex is IOException or UnauthorizedAccessException or System.Text.DecoderFallbackException; + private static string Error(Exception ex) => ex.Message.Length <= 512 ? ex.Message : ex.Message[..512]; + } +} diff --git a/src/Files.Platform.Linux.ElevationHelper/Program.cs b/src/Files.Platform.Linux.ElevationHelper/Program.cs new file mode 100644 index 000000000000..18d2e77b0e6c --- /dev/null +++ b/src/Files.Platform.Linux.ElevationHelper/Program.cs @@ -0,0 +1,48 @@ +// Copyright (c) Files Community +// Licensed under the MIT License. + +using Files.Platform.Abstractions.Elevation; +using Files.Platform.Linux.Native; +using System; +using System.Globalization; +using System.IO; +using System.Text; + +namespace Files.Platform.Linux.ElevationHelper +{ + internal static class Program + { + private static int Main(string[] args) + { + try + { + if (!OperatingSystem.IsLinux() || System.Runtime.InteropServices.RuntimeInformation.ProcessArchitecture is not (System.Runtime.InteropServices.Architecture.X64 or System.Runtime.InteropServices.Architecture.Arm64) + || args.Length != 0 || ElevationNative.GetEffectiveUid() != 0 + || !uint.TryParse(Environment.GetEnvironmentVariable("PKEXEC_UID"), NumberStyles.None, CultureInfo.InvariantCulture, out var caller) || caller == 0) + throw new InvalidDataException("This helper requires pkexec authorization."); + foreach (System.Collections.DictionaryEntry entry in Environment.GetEnvironmentVariables()) + Environment.SetEnvironmentVariable((string)entry.Key, null); + ElevationNative.Umask(0x3F); // 0077 + Directory.SetCurrentDirectory("/"); + using var input = Console.OpenStandardInput(); + using var data = new MemoryStream(); + var buffer = new byte[4096]; + int count; + while ((count = input.Read(buffer)) != 0) + { + if (data.Length + count > ElevationHelperProtocol.MaximumBytes) throw new InvalidDataException("Plan too large."); + data.Write(buffer, 0, count); + } + var request = ElevationHelperProtocol.ParseRequest(new UTF8Encoding(false, true).GetString(data.ToArray())); + var response = new HelperEngine(caller).Execute(request); + Console.WriteLine(ElevationHelperProtocol.Serialize(response)); + return Array.TrueForAll(response.Items, item => item.Succeeded) ? 0 : 1; + } + catch (Exception ex) when (ex is InvalidDataException or IOException or UnauthorizedAccessException or System.Text.DecoderFallbackException or System.Text.Json.JsonException or ArgumentException) + { + Console.WriteLine(ElevationHelperProtocol.Serialize(new HelperResponse(1, [], ex.Message.Length > 512 ? ex.Message[..512] : ex.Message))); + return 1; + } + } + } +} diff --git a/src/Files.Platform.Linux/Elevation/ElevationPlanPreview.cs b/src/Files.Platform.Linux/Elevation/ElevationPlanPreview.cs new file mode 100644 index 000000000000..bb0bfdc52033 --- /dev/null +++ b/src/Files.Platform.Linux/Elevation/ElevationPlanPreview.cs @@ -0,0 +1,91 @@ +// Copyright (c) Files Community +// Licensed under the MIT License. + +using Files.Platform.Abstractions.Elevation; +using Files.Platform.Linux.Launching; +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Files.Platform.Linux.Elevation +{ + /// + /// Holds the plan a confirmation dialog shows and hands out exactly that plan, once, when the user confirms. + /// The plan is recomputed on every input change; only succeeds for the plan whose display text is on screen, + /// a plan that cannot be displayed completely never confirms, and nothing changes after the dialog was confirmed or closed. + /// Not thread-safe: call it from the UI thread. + /// + public sealed class ElevationPlanPreview + { + private readonly Func planner; + private ElevatedPlan? plan; + private bool finished; + + /// Creates the preview. builds the plan for the current input. + public ElevationPlanPreview(Func planner) + { + this.planner = planner; + } + + /// Gets the text to display for the current plan, or the reason it is refused. + public string DisplayText { get; private set; } = string.Empty; + + /// Gets whether the confirm button may be enabled. + public bool CanConfirm => !finished && plan is not null; + + /// Recomputes the plan for . Ignored once confirmed or closed (late updates). + public void Update(string input) + { + if (finished) + return; + + var result = planner(input); + var text = result.Plan is { } candidate ? Format(candidate) : null; + + // A plan that cannot be shown completely is never offered + plan = text is null || result.Plan is null ? null : result.Plan with + { + Sources = Array.AsReadOnly(result.Plan.Sources.ToArray()), + Commands = Array.AsReadOnly(result.Plan.Commands.Select(command => command with { Arguments = Array.AsReadOnly(command.Arguments.ToArray()) }).ToArray()), + }; + DisplayText = text ?? (result.Plan is null ? DisplaySanitizer.Field(result.Refusal, 400) : "Too many items to display."); + } + + /// + /// Returns the displayed plan if is what is on screen for it, and finishes the preview, so a second + /// confirm (double click, Enter) or a later update cannot produce another plan. Returns null otherwise. + /// + public ElevatedPlan? Confirm(string shownText) + { + if (!CanConfirm || !string.Equals(shownText, DisplayText, StringComparison.Ordinal)) + return null; + + finished = true; + return plan; + } + + /// Ends the preview without confirming (cancel, close, Escape). + public void Close() => finished = true; + + /// + /// Renders every command: pkexec, the program and each argument escaped on its own line; commands are separated by a blank line. + /// Returns null when the plan is too large to be shown in full. + /// + public static string? Format(ElevatedPlan plan) + { + var blocks = new List(); + foreach (var command in plan.Commands) + { + var lines = DisplaySanitizer.FullArguments(command.Program == ElevationHelperProtocol.HelperPath + ? ["pkexec", command.Program, "stdin (JSON)", .. command.Arguments] + : ["pkexec", command.Program, .. command.Arguments]); + if (lines is null) + return null; + + blocks.Add(string.Join(Environment.NewLine, lines)); + } + + return string.Join(Environment.NewLine + Environment.NewLine, blocks); + } + } +} diff --git a/src/Files.Platform.Linux/Elevation/ElevationTrust.cs b/src/Files.Platform.Linux/Elevation/ElevationTrust.cs new file mode 100644 index 000000000000..7a8b05ee6eab --- /dev/null +++ b/src/Files.Platform.Linux/Elevation/ElevationTrust.cs @@ -0,0 +1,188 @@ +// Copyright (c) Files Community +// Licensed under the MIT License. + +using Files.Platform.Linux.Native; +using Files.Platform.Abstractions.Elevation; +using System; +using System.Collections.Generic; +using System.IO; + +namespace Files.Platform.Linux.Elevation +{ + /// + /// Resolves the few programs that are run as root to absolute paths. Only fixed system locations are considered, never $PATH. + /// + public interface ITrustedToolResolver + { + /// + /// Returns the absolute path of (pkexec or files-elevation-helper), or null if it is not installed in a trusted location. + /// + string? Resolve(string name); + } + + /// + /// Path trust checks for privileged operations: symlinks are resolved up front and every directory on the way must be owned by root + /// or the current user and not be writable by anyone else, so nobody else can swap a component between confirmation and execution. + /// + public sealed class ElevationPathChecker + { + private const int MaxLinkHops = 40; + private readonly IFileOwnershipInspector inspector; + private readonly uint userId; + + /// Creates the checker. + public ElevationPathChecker(IFileOwnershipInspector inspector, uint userId) + { + this.inspector = inspector; + this.userId = userId; + } + + /// Gets the inspector used for all lookups. + public IFileOwnershipInspector Inspector => inspector; + + /// + /// Resolves every symbolic link in , including the last component. Returns null for broken links or loops. + /// + public string? Canonicalize(string absolutePath) + { + var pending = new Stack(); + foreach (var segment in absolutePath.Split('/', StringSplitOptions.RemoveEmptyEntries)) + pending.Push(segment); + + // Stack top must be the first segment + var ordered = new Stack(pending); + var current = new List(); + var hops = 0; + while (ordered.Count > 0) + { + var segment = ordered.Pop(); + if (segment == ".") + continue; + if (segment == "..") + { + if (current.Count > 0) + current.RemoveAt(current.Count - 1); + continue; + } + + current.Add(segment); + var path = "/" + string.Join('/', current); + if (!inspector.TryGetInfo(path, out var info) || !info.IsSymbolicLink) + continue; + + if (++hops > MaxLinkHops) + return null; + + string? target; + try + { + target = new FileInfo(path).LinkTarget; + } + catch (IOException) + { + return null; + } + + if (string.IsNullOrEmpty(target)) + return null; + + current.RemoveAt(current.Count - 1); + if (target.StartsWith('/')) + current.Clear(); + + var parts = target.Split('/', StringSplitOptions.RemoveEmptyEntries); + for (var i = parts.Length - 1; i >= 0; i--) + ordered.Push(parts[i]); + } + + return "/" + string.Join('/', current); + } + + /// + /// Checks that and all its ancestors can only be changed by root or the current user. + /// Returns an explanation when not, otherwise null. + /// + public string? CheckDirectoryChain(string canonicalDirectory) + { + var parts = canonicalDirectory.Split('/', StringSplitOptions.RemoveEmptyEntries); + var path = "/"; + for (var depth = 0; depth <= parts.Length; depth++) + { + if (depth > 0) + path = path == "/" ? "/" + parts[depth - 1] : path + "/" + parts[depth - 1]; + + if (!inspector.TryGetInfo(path, out var info) || !info.IsDirectory) + return $"\"{path}\" is not an accessible directory."; + + if (info.OwnerUserId != 0 && info.OwnerUserId != userId) + return $"\"{path}\" belongs to another user, who could swap its contents before the command runs."; + + var othersWrite = (info.Mode & (UnixFileMode.GroupWrite | UnixFileMode.OtherWrite)) != 0; + var stickyRoot = info.OwnerUserId == 0 && (info.Mode & UnixFileMode.StickyBit) != 0; + if (othersWrite && !stickyRoot) + return $"\"{path}\" can be modified by other users, who could swap its contents before the command runs."; + } + + return null; + } + } + + /// + /// Resolves tools from /usr/bin then /bin and accepts them only if the file is owned by root and not writable by group or others. + /// + public sealed class SystemToolResolver : ITrustedToolResolver + { + private static readonly HashSet Allowed = new(StringComparer.Ordinal) { "pkexec" }; + private static readonly string[] Directories = ["/usr/bin", "/bin"]; + private readonly ElevationPathChecker checker; + + /// Creates the resolver. + public SystemToolResolver(ElevationPathChecker checker) + { + this.checker = checker; + } + + /// + public string? Resolve(string name) + { + if (name == "files-elevation-helper") + return IsRootOwnedChain(ElevationHelperProtocol.HelperPath) && IsRootOwnedChain(ElevationHelperProtocol.PolicyPath) + && checker.Inspector.TryGetInfo(ElevationHelperProtocol.HelperPath, out var helper) + && (helper.Mode & UnixFileMode.UserExecute) != 0 + ? ElevationHelperProtocol.HelperPath : null; + + if (!Allowed.Contains(name)) + return null; + + foreach (var directory in Directories) + { + var candidate = directory + "/" + name; + var real = checker.Canonicalize(candidate); + if (real is null || !checker.Inspector.TryGetInfo(real, out var info) || info.IsDirectory || info.IsSymbolicLink) + continue; + + if (IsRootOwnedChain(real, allowSetIdFile: true) && (info.Mode & UnixFileMode.UserExecute) != 0) + return candidate; + } + + return null; + } + + private bool IsRootOwnedChain(string path, bool allowSetIdFile = false) + { + var parts = path.Split('/', StringSplitOptions.RemoveEmptyEntries); + var current = "/"; + for (var index = -1; index < parts.Length; index++) + { + if (index >= 0) current = Path.Combine(current, parts[index]); + if (!checker.Inspector.TryGetInfo(current, out var info) || info.IsSymbolicLink || info.OwnerUserId != 0 + || (info.Mode & (UnixFileMode.GroupWrite | UnixFileMode.OtherWrite)) != 0 + || ((info.Mode & (UnixFileMode.SetUser | UnixFileMode.SetGroup)) != 0 && !(allowSetIdFile && index == parts.Length - 1)) + || (index < parts.Length - 1 && !info.IsDirectory) || (index == parts.Length - 1 && info.IsDirectory)) + return false; + } + return true; + } + + } +} diff --git a/src/Files.Platform.Linux/Elevation/PkexecElevationService.cs b/src/Files.Platform.Linux/Elevation/PkexecElevationService.cs index edf3ac6efc6e..bf05c415d9b2 100644 --- a/src/Files.Platform.Linux/Elevation/PkexecElevationService.cs +++ b/src/Files.Platform.Linux/Elevation/PkexecElevationService.cs @@ -3,10 +3,14 @@ using Files.Platform.Abstractions.Elevation; using Files.Platform.Linux.Launching; +using Files.Platform.Linux.Native; using System; using System.Collections.Generic; using System.Diagnostics; using System.IO; +using System.Linq; +using System.Text.Json; +using System.Text; using System.Threading; using System.Threading.Tasks; @@ -26,7 +30,7 @@ public interface IElevatedProcessRunner /// /// Starts real processes. /// - public sealed class ProcessElevatedRunner : IElevatedProcessRunner + public sealed class ProcessElevatedRunner : IElevatedProcessRunner, IRootHelperProcessRunner { /// public async Task<(int ExitCode, string StandardError)> RunAsync(string fileName, IReadOnlyList arguments, CancellationToken cancellationToken) @@ -61,83 +65,161 @@ public sealed class ProcessElevatedRunner : IElevatedProcessRunner await stdout.ConfigureAwait(false); return (process.ExitCode, await stderr.ConfigureAwait(false)); } + public async Task<(int ExitCode, string Output, string Error)> RunHelperAsync(string pkexec, string json, CancellationToken cancellationToken) + { + var info = new ProcessStartInfo(pkexec) + { + UseShellExecute = false, RedirectStandardInput = true, RedirectStandardOutput = true, + RedirectStandardError = true, CreateNoWindow = true, + }; + info.Environment.Clear(); + info.Environment["LANG"] = "C"; + info.ArgumentList.Add(ElevationHelperProtocol.HelperPath); + using var process = Process.Start(info) ?? throw new IOException("Unable to start authorization."); + try + { + var output = ReadBoundedAsync(process.StandardOutput, cancellationToken, () => process.Kill(entireProcessTree: true)); + var error = ReadBoundedAsync(process.StandardError, cancellationToken, () => process.Kill(entireProcessTree: true)); + var send = SendAsync(); + await Task.WhenAll(send, output, error, process.WaitForExitAsync(cancellationToken)).ConfigureAwait(false); + return (process.ExitCode, await output.ConfigureAwait(false), await error.ConfigureAwait(false)); + async Task SendAsync() + { + try { await process.StandardInput.WriteAsync(json.AsMemory(), cancellationToken).ConfigureAwait(false); } + catch (IOException) { } // A dismissed prompt can close its input without reading the plan. + finally { process.StandardInput.Close(); } + } + } + finally + { + if (!process.HasExited) + { + try { process.Kill(entireProcessTree: true); } catch (InvalidOperationException) { } + await process.WaitForExitAsync(CancellationToken.None).ConfigureAwait(false); + } + } + } + + private static async Task ReadBoundedAsync(StreamReader reader, CancellationToken cancellationToken, Action stop) + { + var text = new StringBuilder(); + var buffer = new char[4096]; + int count; + while ((count = await reader.ReadAsync(buffer.AsMemory(), cancellationToken).ConfigureAwait(false)) != 0) + { + if (text.Length + count > ElevationHelperProtocol.MaximumResultBytes) + { + try { stop(); } catch (InvalidOperationException) { } + throw new IOException("Helper output exceeds protocol limit."); + } + text.Append(buffer, 0, count); + } + return text.ToString(); + } + } - /// - /// Runs single privileged operations through pkexec (polkit shows the authentication dialog). - /// Only fixed, argument-vector commands are run (rm, cp); paths are never interpolated into a shell. - /// + public interface IRootHelperProcessRunner + { + Task<(int ExitCode, string Output, string Error)> RunHelperAsync(string pkexec, string json, CancellationToken cancellationToken); + } + + /// Plans root actions and sends the frozen plan to the installed fd-relative helper through pkexec. public sealed class PkexecElevationService : IElevationService { - // pkexec: 126 = the user dismissed the dialog or is not authorized, 127 = authentication failed or could not be started - private const int PkexecNotAuthorized = 126; - private const int PkexecAuthFailed = 127; + private readonly ElevationPathChecker checker; + private readonly ITrustedToolResolver tools; + private readonly IRootHelperProcessRunner runner; + private readonly Func packagedWithoutHelper; - private readonly IExecutableLocator locator; - private readonly IElevatedProcessRunner runner; + public PkexecElevationService(IRootHelperProcessRunner runner) + : this(new ElevationPathChecker(new StatxFileOwnershipInspector(), ProcessIdentityNative.CurrentUserId), null, runner) { } - /// - /// Creates the service. - /// - public PkexecElevationService(IExecutableLocator locator, IElevatedProcessRunner runner) + public PkexecElevationService(ElevationPathChecker checker, ITrustedToolResolver? tools, IRootHelperProcessRunner runner, Func? packagedWithoutHelper = null) { - this.locator = locator; + this.checker = checker; + this.tools = tools ?? new SystemToolResolver(checker); this.runner = runner; + this.packagedWithoutHelper = packagedWithoutHelper ?? (() => File.Exists("/.flatpak-info") || File.Exists(Path.Combine(AppContext.BaseDirectory, ".root-actions-disabled")) + || Environment.GetEnvironmentVariable("APPIMAGE") is not null || Environment.GetEnvironmentVariable("APPDIR") is not null + || Environment.GetEnvironmentVariable("FILES_DISABLE_ROOT_ACTIONS") == "1"); } - /// - public bool IsAvailable => locator.Locate("pkexec") is not null; + public bool IsAvailable => !packagedWithoutHelper() && tools.Resolve("pkexec") is not null && tools.Resolve("files-elevation-helper") == ElevationHelperProtocol.HelperPath; - /// - public Task DeleteAsync(string path, CancellationToken cancellationToken = default) - { - if (!IsSafeAbsolutePath(path) || IsFilesystemRoot(path)) - return Task.FromResult(Failure("Refusing to delete this path.")); + public ElevatedPlanResult PlanDelete(IReadOnlyList paths) => Plan(ElevatedOperation.Delete, paths, null); + public ElevatedPlanResult PlanCopy(IReadOnlyList sources, string destinationFolder) => Plan(ElevatedOperation.Copy, sources, destinationFolder); + public ElevatedPlanResult PlanMove(IReadOnlyList sources, string destinationFolder) => Plan(ElevatedOperation.Move, sources, destinationFolder); - return RunPrivilegedAsync("rm", ["-rf", "--", Normalize(path)], cancellationToken); + public ElevatedPlanResult PlanRename(string path, string newName) + { + if (string.IsNullOrEmpty(newName) || newName is "." or ".." || newName.IndexOfAny(['/', '\0']) >= 0) + return ElevatedPlanResult.Refuse("The new name is not a plain file name."); + return Plan(ElevatedOperation.Rename, [path], newName); } - /// - public Task CopyAsync(string sourcePath, string destinationFolder, CancellationToken cancellationToken = default) + private ElevatedPlanResult Plan(ElevatedOperation operation, IReadOnlyList sources, string? target) { - if (!IsSafeAbsolutePath(sourcePath) || !IsSafeAbsolutePath(destinationFolder)) - return Task.FromResult(Failure("Paths must be absolute.")); - - // -a keeps the source's mode and timestamps; -T is not used so an existing destination folder receives the item - return RunPrivilegedAsync("cp", ["-a", "--", Normalize(sourcePath), Normalize(destinationFolder) + "/"], cancellationToken); + try + { + if (!IsAvailable) return ElevatedPlanResult.Refuse("The privileged helper is not installed in a trusted location."); + var paths = sources.Select(path => + { + ElevationHelperProtocol.ValidatePath(path); + var parent = checker.Canonicalize(Path.GetDirectoryName(path)!); + if (parent is null || checker.CheckDirectoryChain(parent) is { }) throw new IOException("The source parent is not trusted."); + return Path.Combine(parent, Path.GetFileName(path)); + }).ToArray(); + string? absoluteTarget = null; + if (operation == ElevatedOperation.Rename) absoluteTarget = Path.Combine(Path.GetDirectoryName(paths.Single())!, target!); + else if (target is not null) + { + ElevationHelperProtocol.ValidatePath(target); + absoluteTarget = checker.Canonicalize(target); + if (absoluteTarget is null || checker.CheckDirectoryChain(absoluteTarget) is { }) throw new IOException("The destination is not trusted."); + } + var request = new HelperRequest(1, operation.ToString().ToLowerInvariant(), paths, absoluteTarget); + ElevationHelperProtocol.Validate(request); + var json = ElevationHelperProtocol.Serialize(request); + // Parsing here also enforces the wire size bound before offering confirmation. + ElevationHelperProtocol.ParseRequest(json); + return ElevatedPlanResult.Ok(new ElevatedPlan(operation, Array.AsReadOnly(paths), operation == ElevatedOperation.Rename ? target : absoluteTarget, + Array.AsReadOnly(new[] { new ElevatedCommand(ElevationHelperProtocol.HelperPath, Array.AsReadOnly(new[] { json })) }))); + } + catch (Exception ex) when (ex is InvalidDataException or IOException or ArgumentException or InvalidOperationException or JsonException) + { + return ElevatedPlanResult.Refuse(ex.Message); + } } - private async Task RunPrivilegedAsync(string program, string[] arguments, CancellationToken cancellationToken) + public async Task RunAsync(ElevatedPlan plan, CancellationToken cancellationToken = default) { - var pkexec = locator.Locate("pkexec"); - if (pkexec is null) - return Failure("pkexec is not installed."); - - var target = locator.Locate(program); - if (target is null) - return Failure($"{program} was not found."); - - var full = new List(arguments.Length + 1) { target }; - full.AddRange(arguments); - + if (!IsAvailable || tools.Resolve("pkexec") is not { } pkexec) return Failure("The privileged helper is unavailable."); try { - var (exitCode, error) = await runner.RunAsync(pkexec, full, cancellationToken).ConfigureAwait(false); - var dismissed = exitCode is PkexecNotAuthorized or PkexecAuthFailed; - return new ElevatedResult(exitCode == 0, dismissed, exitCode, error); + // Rebuild only the serialized data: filesystem authority and post-verification belong to the helper. + var target = plan.Operation == ElevatedOperation.Rename ? Path.Combine(Path.GetDirectoryName(plan.Sources.Single())!, plan.Target!) : plan.Target; + var request = new HelperRequest(1, plan.Operation.ToString().ToLowerInvariant(), plan.Sources.ToArray(), target); + ElevationHelperProtocol.Validate(request); + var json = ElevationHelperProtocol.Serialize(request); + ElevationHelperProtocol.ParseRequest(json); + if (plan.Commands.Count != 1 || plan.Commands[0].Program != ElevationHelperProtocol.HelperPath || !plan.Commands[0].Arguments.SequenceEqual(new[] { json })) + return Failure("The operation no longer matches what was confirmed."); + var (exitCode, output, error) = await runner.RunHelperAsync(pkexec, json, cancellationToken).ConfigureAwait(false); + if (exitCode == 126) return new(false, true, exitCode, error); + if (exitCode is not (0 or 1)) return new(false, false, exitCode, error); + var response = ElevationHelperProtocol.ParseResponse(output); + if (response.Error.Length != 0) return new(false, false, exitCode, response.Error); + if (!response.Items.Select(item => item.Source).SequenceEqual(request.Sources)) return Failure("Helper returned incomplete or mismatched results."); + if (exitCode == 0 && response.Items.All(item => item.Succeeded)) return new(true, false, 0, ""); + return new(false, false, exitCode, string.Join("\n", response.Items.Where(item => !item.Succeeded).Select(item => item.Source + ": " + item.Error))); } - catch (Exception ex) when (ex is IOException or InvalidOperationException or System.ComponentModel.Win32Exception) + catch (Exception ex) when (ex is InvalidDataException or IOException or InvalidOperationException or ArgumentException or JsonException or System.ComponentModel.Win32Exception) { return Failure(ex.Message); } } - private static ElevatedResult Failure(string message) => new(false, false, -1, message); - - private static bool IsSafeAbsolutePath(string path) => !string.IsNullOrEmpty(path) && path.StartsWith('/') && !path.Contains('\0'); - - private static string Normalize(string path) => Path.GetFullPath(path).TrimEnd('/') is { Length: > 0 } full ? full : "/"; - - private static bool IsFilesystemRoot(string path) => Normalize(path) == "/"; + private static ElevatedResult Failure(string error) => new(false, false, -1, error); } } diff --git a/src/Files.Platform.Linux/Native/ElevationNative.cs b/src/Files.Platform.Linux/Native/ElevationNative.cs new file mode 100644 index 000000000000..4b55a28595b3 --- /dev/null +++ b/src/Files.Platform.Linux/Native/ElevationNative.cs @@ -0,0 +1,131 @@ +// Copyright (c) Files Community +// Licensed under the MIT License. + +using System; +using System.IO; +using System.Runtime.InteropServices; + +namespace Files.Platform.Linux.Native +{ + internal static unsafe partial class ElevationNative + { + [StructLayout(LayoutKind.Sequential)] + private struct OpenHow { public ulong Flags; public ulong Mode; public ulong Resolve; } + + [LibraryImport("libc", EntryPoint = "syscall", StringMarshalling = StringMarshalling.Utf8, SetLastError = true)] + private static partial nint OpenAt2(nint number, int dirfd, string name, ref OpenHow how, nuint size); + + [LibraryImport("libc", EntryPoint = "renameat2", StringMarshalling = StringMarshalling.Utf8, SetLastError = true)] + private static partial int RenameAt2(int sourceFd, string source, int targetFd, string target, uint flags); + + [LibraryImport("libc", EntryPoint = "fsync", SetLastError = true)] + private static partial int Fsync(int fd); + + [LibraryImport("libc", EntryPoint = "geteuid")] + internal static partial uint GetEffectiveUid(); + + [LibraryImport("libc", EntryPoint = "umask")] + internal static partial uint Umask(uint mode); + + + internal readonly record struct Stamp(uint Mode, uint Owner, ulong Inode, ulong Size, ulong Mount, uint Major, uint Minor, + long ModifiedSeconds, uint ModifiedNanos, long ChangedSeconds, uint ChangedNanos) + { + public bool Directory => (Mode & 0xF000) == 0x4000; + public bool Regular => (Mode & 0xF000) == 0x8000; + public bool SameObject(Stamp other) => Inode == other.Inode && Major == other.Major && Minor == other.Minor && Mount == other.Mount + && (Mode & 0xF000) == (other.Mode & 0xF000); + } + + [LibraryImport("libc", EntryPoint = "statx", StringMarshalling = StringMarshalling.Utf8, SetLastError = true)] + private static partial int Statx(int fd, string name, int flags, uint mask, byte* buffer); + + internal static Stamp? Inspect(int fd, string name = "", bool allowMissing = false) + { + const uint required = 0x1 | 0x2 | 0x8 | 0x40 | 0x80 | 0x100 | 0x200 | 0x1000; + var buffer = new byte[256]; + fixed (byte* pointer = buffer) + { + if (Statx(fd, name, name.Length == 0 ? 0x1000 : 0x100, required, pointer) != 0) + { + var errno = Marshal.GetLastPInvokeError(); + if (allowMissing && errno == 2) return null; + throw PosixNative.CreateException(errno, name); + } + } + if ((BitConverter.ToUInt32(buffer, 0) & required) != required) throw new IOException("Required statx identity fields unavailable."); + return new Stamp(BitConverter.ToUInt16(buffer, 28), BitConverter.ToUInt32(buffer, 20), BitConverter.ToUInt64(buffer, 32), + BitConverter.ToUInt64(buffer, 40), BitConverter.ToUInt64(buffer, 144), BitConverter.ToUInt32(buffer, 136), BitConverter.ToUInt32(buffer, 140), + BitConverter.ToInt64(buffer, 112), BitConverter.ToUInt32(buffer, 120), BitConverter.ToInt64(buffer, 96), BitConverter.ToUInt32(buffer, 104)); + } + + [LibraryImport("libc", EntryPoint = "dup", SetLastError = true)] + private static partial int Duplicate(int fd); + [LibraryImport("libc", EntryPoint = "fdopendir", SetLastError = true)] + private static partial nint OpenDirectory(int fd); + [LibraryImport("libc", EntryPoint = "readdir", SetLastError = true)] + private static partial byte* ReadDirectory(nint directory); + [LibraryImport("libc", EntryPoint = "closedir")] + private static partial int CloseDirectory(nint directory); + [LibraryImport("libc", EntryPoint = "rewinddir")] + private static partial void RewindDirectory(nint directory); + + internal static System.Collections.Generic.List Names(int fd) + { + var duplicate = Duplicate(fd); + if (duplicate < 0) throw new IOException("Cannot duplicate directory descriptor."); + var directory = OpenDirectory(duplicate); + if (directory == 0) { PosixNative.Close(duplicate); throw new IOException("Cannot enumerate directory."); } + try + { + RewindDirectory(directory); + var names = new System.Collections.Generic.List(); + while (true) + { + var entry = ReadDirectory(directory); + if (entry == null) + { + if (Marshal.GetLastPInvokeError() != 0) throw new IOException("Directory enumeration failed."); + return names; + } + var length = 0; + while (length < 256 && entry[19 + length] != 0) length++; + if (length == 256) throw new IOException("Invalid directory entry."); + var name = new System.Text.UTF8Encoding(false, true).GetString(new ReadOnlySpan(entry + 19, length)); + if (name is "." or "..") continue; + if (name.Length == 0 || name.Contains('/')) throw new IOException("Invalid directory entry."); + if (names.Count >= 4096) throw new IOException("Tree exceeds entry limit."); + names.Add(name); + } + } + finally { CloseDirectory(directory); } + } + + internal static int Open(int dirfd, string name, int flags, bool fallback, uint mode = 0) + { + int fd; + if (!fallback) + { + var how = new OpenHow { Flags = (ulong)flags, Mode = mode, Resolve = 0x04 | 0x08 }; // NO_SYMLINKS | BENEATH + fd = (int)OpenAt2(437, dirfd, name, ref how, 24); + if (fd >= 0) return fd; + var error = Marshal.GetLastPInvokeError(); + if (error != 38) throw PosixNative.CreateException(error, name); // Only ENOSYS permits the component walk fallback. + } + fd = PosixNative.OpenAt(dirfd, name, flags | PosixNative.ONofollow, out var errno, mode); + if (fd < 0) throw PosixNative.CreateException(errno, name); + return fd; + } + + internal static void Rename(int sourceFd, string source, int targetFd, string target) + { + if (RenameAt2(sourceFd, source, targetFd, target, 1) != 0) + throw PosixNative.CreateException(Marshal.GetLastPInvokeError(), target); + } + + internal static void Sync(int fd) + { + if (Fsync(fd) != 0) throw new IOException("Unable to synchronize copy."); + } + } +} diff --git a/src/Files.Platform.Linux/Native/PosixNative.cs b/src/Files.Platform.Linux/Native/PosixNative.cs index 917f193eb86b..97ee09817561 100644 --- a/src/Files.Platform.Linux/Native/PosixNative.cs +++ b/src/Files.Platform.Linux/Native/PosixNative.cs @@ -274,6 +274,13 @@ public static Exception CreateException(int errno, string path) _ => new IOException($"The operation on '{path}' failed with errno {errno}.", errno), }; +#if ELEVATION_HELPER + [LibraryImport("libc", EntryPoint = "mkdirat", StringMarshalling = StringMarshalling.Utf8, SetLastError = true)] + private static partial int mkdirat(int fd, string name, uint mode); + [LibraryImport("libc", EntryPoint = "symlinkat", StringMarshalling = StringMarshalling.Utf8, SetLastError = true)] + private static partial int symlinkat(string target, int fd, string name); +#endif + [LibraryImport("libc", EntryPoint = "fcntl", SetLastError = true)] private static partial int fcntl(int fd, int command, int argument); @@ -311,6 +318,7 @@ public static Exception CreateException(int errno, string path) private static partial void rewinddir(nint stream); } +#if !ELEVATION_HELPER /// /// An open directory descriptor that child operations are performed relative to. /// @@ -501,4 +509,5 @@ public void Dispose() } } } +#endif } diff --git a/src/Files.Platform.Linux/SystemIntegrationServiceCollectionExtensions.cs b/src/Files.Platform.Linux/SystemIntegrationServiceCollectionExtensions.cs index 82728ada3af0..a99018945af7 100644 --- a/src/Files.Platform.Linux/SystemIntegrationServiceCollectionExtensions.cs +++ b/src/Files.Platform.Linux/SystemIntegrationServiceCollectionExtensions.cs @@ -43,6 +43,7 @@ public static IServiceCollection AddLinuxSystemIntegration(this IServiceCollecti services.TryAddSingleton(_ => new DBusNotificationService()); services.TryAddSingleton(); services.TryAddSingleton(); + services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddSingleton(sp => new UserFontInstallService(UserFontInstallService.DefaultFontsDirectory(System.Environment.GetEnvironmentVariable), sp.GetRequiredService(), sp.GetRequiredService())); services.TryAddSingleton(_ => new PortalWallpaperService()); diff --git a/tests/Files.Platform.Tests/Files.Platform.Tests.csproj b/tests/Files.Platform.Tests/Files.Platform.Tests.csproj index 0486683bcbb1..7ca6dca46628 100644 --- a/tests/Files.Platform.Tests/Files.Platform.Tests.csproj +++ b/tests/Files.Platform.Tests/Files.Platform.Tests.csproj @@ -27,6 +27,7 @@ + diff --git a/tests/Files.Platform.Tests/SystemIntegration/ElevationHelperTests.cs b/tests/Files.Platform.Tests/SystemIntegration/ElevationHelperTests.cs new file mode 100644 index 000000000000..769742fdf79a --- /dev/null +++ b/tests/Files.Platform.Tests/SystemIntegration/ElevationHelperTests.cs @@ -0,0 +1,233 @@ +// Copyright (c) Files Community +// Licensed under the MIT License. + +extern alias ElevationHelper; + +using Files.Platform.Abstractions.Elevation; + +using HelperEngine = ElevationHelper::Files.Platform.Linux.ElevationHelper.HelperEngine; +using Files.Platform.Linux.Native; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System; +using System.IO; +using System.Linq; + +namespace Files.Platform.Tests.SystemIntegration +{ + [System.Runtime.Versioning.SupportedOSPlatform("linux")] + [TestClass] + public sealed class ElevationHelperTests + { + private string root = null!; + private string source = null!; + private string target = null!; + + [TestInitialize] + public void Setup() + { + Assert.AreNotEqual(0U, ProcessIdentityNative.CurrentUserId, "These tests must run without root privileges."); + root = Path.Combine(Path.GetTempPath(), "files-elevation-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + source = Path.Combine(root, "source"); target = Path.Combine(root, "target"); + Directory.CreateDirectory(source); Directory.CreateDirectory(target); + } + + [TestCleanup] + public void Cleanup() => Directory.Delete(root, recursive: true); + + private HelperResponse Run(string operation, string[] sources, string? destination = null, Action? hook = null, bool fallback = false) + => new HelperEngine(ProcessIdentityNative.CurrentUserId, hook, fallback, RootOwner()).Execute(new(1, operation, sources, destination)); + + private static uint RootOwner() + { + Assert.IsTrue(new StatxFileOwnershipInspector().TryGetInfo("/", out var info)); + return info.OwnerUserId; // Some CI sandboxes map root-owned directories to uid 65534. + } + + private string FileAt(string directory, string name, string contents = "confirmed bytes") + { + var path = Path.Combine(directory, name); File.WriteAllText(path, contents); return path; + } + + [TestMethod] + [DataRow(false)] + [DataRow(true)] + public void CopiesAndMovesNestedTreesThroughDescriptors(bool fallback) + { + var folder = Directory.CreateDirectory(Path.Combine(source, "tree")).FullName; + FileAt(folder, "a"); var child = Directory.CreateDirectory(Path.Combine(folder, "sub")).FullName; + FileAt(child, "empty", ""); + var copied = Run("copy", [folder], target, fallback: fallback); + Assert.IsTrue(copied.Items.Single().Succeeded, copied.Items.Single().Error); + Assert.AreEqual("confirmed bytes", File.ReadAllText(Path.Combine(target, "tree", "a"))); + Assert.IsTrue(Directory.Exists(folder)); + Assert.AreEqual(UnixFileMode.UserRead | UnixFileMode.UserWrite, File.GetUnixFileMode(Path.Combine(target, "tree", "a"))); + var second = Directory.CreateDirectory(Path.Combine(root, "second")).FullName; + var moved = Run("move", [folder], second, fallback: fallback); + Assert.IsTrue(moved.Items.Single().Succeeded, moved.Items.Single().Error); + Assert.IsFalse(Directory.Exists(folder)); + Assert.AreEqual("confirmed bytes", File.ReadAllText(Path.Combine(second, "tree", "a"))); + } + + [TestMethod] + [DataRow(false)] + [DataRow(true)] + public void AuthenticationTimeAncestorSymlinkSwapIsRefused(bool fallback) + { + var file = FileAt(source, "victim"); + var protectedTree = Directory.CreateDirectory(Path.Combine(root, "protected")).FullName; + var sentinel = FileAt(protectedTree, "victim", "must survive"); + Directory.Move(source, source + "-old"); Directory.CreateSymbolicLink(source, protectedTree); + var result = Run("delete", [file], fallback: fallback); + Assert.IsFalse(result.Items.Single().Succeeded); + Assert.AreEqual("must survive", File.ReadAllText(sentinel)); + Assert.IsTrue(File.Exists(Path.Combine(source + "-old", "victim"))); + } + + [TestMethod] + [DataRow(false)] + [DataRow(true)] + public void SwappedAncestorAfterPinCannotRedirectDeletion(bool fallback) + { + var file = FileAt(source, "victim"); + var protectedTree = Directory.CreateDirectory(Path.Combine(root, "protected")).FullName; + var sentinel = FileAt(protectedTree, "victim", "must survive"); + var result = Run("delete", [file], hook: (stage, _) => + { + if (stage != "plan-pinned") return; + Directory.Move(source, source + "-old"); Directory.CreateSymbolicLink(source, protectedTree); + }, fallback: fallback); + Assert.IsTrue(result.Items.Single().Succeeded, result.Items.Single().Error); + Assert.AreEqual("must survive", File.ReadAllText(sentinel)); + Assert.IsFalse(File.Exists(Path.Combine(source + "-old", "victim"))); + } + + [TestMethod] + public void ComponentAndLeafRacesNeverFollowLinks() + { + var file = FileAt(source, "victim"); var sentinel = FileAt(target, "victim", "must survive"); + var swapped = false; + var result = Run("delete", [file], hook: (stage, name) => + { + if (stage != "before-entry-open" || name != "victim" || swapped) return; + swapped = true; File.Delete(file); File.CreateSymbolicLink(file, sentinel); + }); + Assert.IsFalse(result.Items.Single().Succeeded); + Assert.AreEqual("must survive", File.ReadAllText(sentinel)); + } + + [TestMethod] + [DataRow("copy")] + [DataRow("move")] + public void DestinationAppearingDuringAuthorizationNeverReplacesOrDeletesOriginal(string operation) + { + var file = FileAt(source, "a"); + var result = Run(operation, [file], target, (stage, _) => + { + if (stage == "before-copy") FileAt(target, "a", "unrelated"); + }); + Assert.IsFalse(result.Items.Single().Succeeded); + Assert.AreEqual("unrelated", File.ReadAllText(Path.Combine(target, "a"))); + Assert.AreEqual("confirmed bytes", File.ReadAllText(file)); + } + + [TestMethod] + public void RenameUsesAtomicNoReplaceEvenForDanglingSymlinkCollision() + { + var file = FileAt(source, "a"); var renamed = Path.Combine(source, "b"); + var result = Run("rename", [file], renamed, (stage, _) => + { + if (stage == "before-rename") File.CreateSymbolicLink(renamed, Path.Combine(root, "missing")); + }); + Assert.IsFalse(result.Items.Single().Succeeded); + Assert.IsTrue(File.Exists(file)); + File.Delete(renamed); + Assert.IsTrue(Run("rename", [file], renamed).Items.Single().Succeeded); + Assert.IsFalse(File.Exists(file)); + } + + [TestMethod] + public void PartialBatchFailureIsReportedPerItem() + { + var first = FileAt(source, "a"); var second = FileAt(source, "b"); FileAt(target, "b", "collision"); + var result = Run("move", [first, second], target); + Assert.IsTrue(result.Items[0].Succeeded, result.Items[0].Error); + Assert.IsFalse(result.Items[1].Succeeded); + Assert.IsFalse(File.Exists(first)); Assert.IsTrue(File.Exists(second)); + Assert.AreEqual("collision", File.ReadAllText(Path.Combine(target, "b"))); + } + + [TestMethod] + public void CopyCorruptionAndSourceChangePreventMoveDeletion() + { + var file = FileAt(source, "a"); + var result = Run("move", [file], target, (stage, _) => + { + if (stage == "after-copy") File.WriteAllText(Path.Combine(target, "a"), "corruption"); + }); + Assert.IsFalse(result.Items.Single().Succeeded); Assert.IsTrue(File.Exists(file)); + File.Delete(Path.Combine(target, "a")); + result = Run("move", [file], target, (stage, _) => + { + if (stage == "before-delete") File.WriteAllText(file, "modified original"); + }); + Assert.IsFalse(result.Items.Single().Succeeded); + Assert.AreEqual("modified original", File.ReadAllText(file)); + } + + [TestMethod] + public void RecursiveFailureRetainsOriginalsAndDoesNotCleanUpUnrelatedEntries() + { + var folder = Directory.CreateDirectory(Path.Combine(source, "tree")).FullName; + FileAt(folder, "a"); FileAt(folder, "b"); + var creations = 0; + var result = Run("move", [folder], target, (stage, name) => + { + if (stage == "before-output-create" && name != "tree" && ++creations == 2) + throw new IOException("Simulated disk write failure."); + }); + Assert.IsFalse(result.Items.Single().Succeeded); + Assert.IsTrue(File.Exists(Path.Combine(folder, "a"))); Assert.IsTrue(File.Exists(Path.Combine(folder, "b"))); + Assert.AreEqual(1, Directory.GetFiles(Path.Combine(target, "tree")).Length, "A partial output is reported and retained."); + } + + [TestMethod] + public void RejectsSymlinkTreesAndSetIdCopiesBeforeMutation() + { + var folder = Directory.CreateDirectory(Path.Combine(source, "tree")).FullName; + var sentinel = FileAt(target, "sentinel"); File.CreateSymbolicLink(Path.Combine(folder, "link"), sentinel); + Assert.IsFalse(Run("delete", [folder]).Items.Single().Succeeded); + Assert.AreEqual("confirmed bytes", File.ReadAllText(sentinel)); + var file = FileAt(source, "setid"); File.SetUnixFileMode(file, File.GetUnixFileMode(file) | UnixFileMode.SetUser); + Assert.IsFalse(Run("copy", [file], target).Items.Single().Succeeded); + Assert.IsFalse(File.Exists(Path.Combine(target, "setid"))); + } + + [TestMethod] + public void DestinationAncestorSwapAfterPinStaysInOriginalFolder() + { + var file = FileAt(source, "a"); var protectedTree = Directory.CreateDirectory(Path.Combine(root, "protected")).FullName; + var result = Run("copy", [file], target, (stage, _) => + { + if (stage != "plan-pinned") return; + Directory.Move(target, target + "-old"); Directory.CreateSymbolicLink(target, protectedTree); + }); + Assert.IsTrue(result.Items.Single().Succeeded, result.Items.Single().Error); + Assert.IsFalse(File.Exists(Path.Combine(protectedTree, "a"))); + Assert.AreEqual("confirmed bytes", File.ReadAllText(Path.Combine(target + "-old", "a"))); + } + + [TestMethod] + public void StrictProtocolRejectsMalformedForgedAndOverlappingPlans() + { + var valid = ElevationHelperProtocol.Serialize(new HelperRequest(1, "delete", [Path.Combine(source, "a")], null)); + Assert.AreEqual("delete", ElevationHelperProtocol.ParseRequest(valid).Operation); + foreach (var json in new[] { valid.Replace("\"version\":1", "\"version\":1,\"version\":1"), valid.Replace("\"version\":1", "\"version\":2"), + valid.Replace("\"version\":1", "\"extra\":1,\"version\":1"), valid.Replace("delete", "shell"), valid.Replace(",\"target\":null", ""), valid + "{}" }) + Assert.Throws(() => ElevationHelperProtocol.ParseRequest(json)); + foreach (var path in new[] { "/", "relative", "/tmp/../etc", "/tmp//a", "/tmp/a/", "/tmp/a\0b" }) + Assert.ThrowsExactly(() => ElevationHelperProtocol.ValidatePath(path)); + Assert.ThrowsExactly(() => Run("delete", [source, Path.Combine(source, "a")])); + } + } +} diff --git a/tests/Files.Platform.Tests/SystemIntegration/ElevationPlanPreviewTests.cs b/tests/Files.Platform.Tests/SystemIntegration/ElevationPlanPreviewTests.cs new file mode 100644 index 000000000000..e29f2a5bd8f5 --- /dev/null +++ b/tests/Files.Platform.Tests/SystemIntegration/ElevationPlanPreviewTests.cs @@ -0,0 +1,95 @@ +// Copyright (c) Files Community +// Licensed under the MIT License. + +using Files.Platform.Abstractions.Elevation; +using Files.Platform.Linux.Elevation; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System.Collections.Generic; +using System.Linq; + +namespace Files.Platform.Tests.SystemIntegration +{ + [TestClass] + public sealed class ElevationPlanPreviewTests + { + private static ElevatedPlanResult RenamePlan(string name) + => string.IsNullOrEmpty(name) || name.Contains('/') + ? ElevatedPlanResult.Refuse("bad name") + : ElevatedPlanResult.Ok(new ElevatedPlan(ElevatedOperation.Rename, ["/opt/a"], name, [new ElevatedCommand("/usr/bin/mv", ["-n", "-T", "--", "/opt/a", "/opt/" + name])])); + + [TestMethod] + public void ConfirmReturnsTheDisplayedPlanForTheTextOnScreen() + { + var preview = new ElevationPlanPreview(RenamePlan); + preview.Update("one"); + preview.Update("two"); + + Assert.IsTrue(preview.CanConfirm); + StringAssert.Contains(preview.DisplayText, "/opt/two"); + var plan = preview.Confirm(preview.DisplayText); + Assert.AreEqual("two", plan!.Target); + Assert.AreEqual("/opt/two", plan.Commands[0].Arguments[^1]); + } + + [TestMethod] + public void StaleTextInvalidAndSecondConfirmsNeverReturnAPlan() + { + var preview = new ElevationPlanPreview(RenamePlan); + preview.Update("one"); + var staleText = preview.DisplayText; + preview.Update("two"); + Assert.IsNull(preview.Confirm(staleText), "the text on screen is not the displayed plan's text"); + + preview.Update("a/b"); + Assert.IsFalse(preview.CanConfirm); + Assert.IsNull(preview.Confirm(preview.DisplayText), "an invalid plan"); + + preview.Update("three"); + Assert.IsNotNull(preview.Confirm(preview.DisplayText)); + Assert.IsNull(preview.Confirm(preview.DisplayText), "double confirm"); + preview.Update("four"); + Assert.IsFalse(preview.CanConfirm, "late updates after confirming are ignored"); + StringAssert.Contains(preview.DisplayText, "/opt/three"); + } + + [TestMethod] + public void ClosedPreviewAndOversizedPlansNeverConfirm() + { + var preview = new ElevationPlanPreview(RenamePlan); + preview.Update("one"); + preview.Close(); + Assert.IsNull(preview.Confirm(preview.DisplayText)); + Assert.IsFalse(preview.CanConfirm); + + var many = Enumerable.Range(0, 1500).Select(i => "/opt/f" + i).ToList(); + var huge = new ElevationPlanPreview(_ => ElevatedPlanResult.Ok(new ElevatedPlan(ElevatedOperation.Delete, many, null, [new ElevatedCommand("/usr/bin/rm", ["-rf", "--", .. many])]))); + huge.Update(string.Empty); + Assert.IsFalse(huge.CanConfirm); + Assert.IsNull(huge.Confirm(huge.DisplayText)); + } + + [TestMethod] + public void PreviewFreezesMutableCollectionsBeforeConfirmation() + { + var sources = new List { "/opt/a" }; + var arguments = new List { "delete", "/opt/a" }; + var commands = new List { new(ElevationHelperProtocol.HelperPath, arguments) }; + var preview = new ElevationPlanPreview(_ => ElevatedPlanResult.Ok(new(ElevatedOperation.Delete, sources, null, commands))); + preview.Update(string.Empty); + var displayed = preview.DisplayText; + sources[0] = "/etc/passwd"; arguments[1] = "/etc/passwd"; commands.Clear(); + var confirmed = preview.Confirm(displayed)!; + Assert.AreEqual("/opt/a", confirmed.Sources.Single()); + Assert.AreEqual("/opt/a", confirmed.Commands.Single().Arguments[1]); + } + + [TestMethod] + public void DisplayEscapesTrickyNames() + { + var preview = new ElevationPlanPreview(RenamePlan); + preview.Update("x‮y\nrm"); + Assert.IsFalse(preview.DisplayText.Contains('‮')); + Assert.IsFalse(preview.DisplayText.Contains("y\nrm")); + } + } +} diff --git a/tests/Files.Platform.Tests/SystemIntegration/ElevationServiceTests.cs b/tests/Files.Platform.Tests/SystemIntegration/ElevationServiceTests.cs index 264093524614..b597a221bce2 100644 --- a/tests/Files.Platform.Tests/SystemIntegration/ElevationServiceTests.cs +++ b/tests/Files.Platform.Tests/SystemIntegration/ElevationServiceTests.cs @@ -1,10 +1,13 @@ // Copyright (c) Files Community // Licensed under the MIT License. +using Files.Platform.Abstractions.Elevation; using Files.Platform.Linux.Elevation; -using Files.Platform.Tests.Mime; +using Files.Platform.Linux.Native; using Microsoft.VisualStudio.TestTools.UnitTesting; +using System; using System.Collections.Generic; +using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -14,70 +17,108 @@ namespace Files.Platform.Tests.SystemIntegration [TestClass] public sealed class ElevationServiceTests { - private sealed class FakeRunner(int exitCode = 0, string error = "") : IElevatedProcessRunner + private sealed class FakeFs : IFileOwnershipInspector { - public List<(string File, string[] Args)> Calls { get; } = []; - - public Task<(int ExitCode, string StandardError)> RunAsync(string fileName, IReadOnlyList arguments, CancellationToken cancellationToken) + public Dictionary Entries { get; } = new(); + public bool TryGetInfo(string path, out FileEntryInfo info) => Entries.TryGetValue(path, out info!); + public void Dir(string path, uint owner = 0) => Entries[path] = new(true, false, owner, (UnixFileMode)0x1ED); + public void File(string path, uint owner = 0) => Entries[path] = new(false, false, owner, (UnixFileMode)0x1ED); + public static FakeFs Standard() { - Calls.Add((fileName, arguments.ToArray())); - return Task.FromResult((exitCode, error)); + var fs = new FakeFs(); + foreach (var directory in new[] { "/", "/usr", "/usr/bin", "/usr/lib", "/usr/lib/linuxfiles", "/usr/share", "/usr/share/polkit-1", "/usr/share/polkit-1/actions", "/home", "/home/u", "/root" }) fs.Dir(directory); + fs.File("/usr/bin/pkexec"); fs.File(ElevationHelperProtocol.HelperPath); fs.File(ElevationHelperProtocol.PolicyPath); + return fs; } } - [TestMethod] - public async Task DeleteRunsRmThroughPkexecWithoutShell() + private sealed class Runner : IRootHelperProcessRunner { - var runner = new FakeRunner(); - var service = new PkexecElevationService(new FakeLocator("pkexec", "rm", "cp"), runner); + public int Calls; + public int ExitCode; + public string? OverrideOutput; + public string? Payload; + public Task<(int ExitCode, string Output, string Error)> RunHelperAsync(string pkexec, string json, CancellationToken cancellationToken) + { + Assert.AreEqual("/usr/bin/pkexec", pkexec); + Calls++; Payload = json; + var request = ElevationHelperProtocol.ParseRequest(json); + return Task.FromResult((ExitCode, OverrideOutput ?? ElevationHelperProtocol.Serialize(new HelperResponse(1, request.Sources.Select(source => new HelperItemResult(source, true, "")).ToArray(), "")), "auth error")); + } + } - var result = await service.DeleteAsync("/root-owned/dir; rm -rf ~"); + private static PkexecElevationService Service(FakeFs fs, Runner runner, bool sandbox = false) + => new(new ElevationPathChecker(fs, 1000), null, runner, () => sandbox); - Assert.IsTrue(result.Succeeded); - Assert.AreEqual(1, runner.Calls.Count); - Assert.AreEqual("/usr/bin/pkexec", runner.Calls[0].File); - CollectionAssert.AreEqual(new[] { "/usr/bin/rm", "-rf", "--", "/root-owned/dir; rm -rf ~" }, runner.Calls[0].Args); + [TestMethod] + public async Task OneHelperInvocationReceivesExactFrozenPlanAsData() + { + var fs = FakeFs.Standard(); var runner = new Runner(); var service = Service(fs, runner); + var plan = service.PlanMove(["/home/u/dir; rm -rf ~"], "/root").Plan!; + Assert.IsNotNull(plan); Assert.AreEqual(1, plan.Commands.Count); + Assert.AreEqual(ElevationHelperProtocol.HelperPath, plan.Commands[0].Program); + var result = await service.RunAsync(plan); + Assert.IsTrue(result.Succeeded, result.Error); Assert.AreEqual(1, runner.Calls); + Assert.AreEqual("/root", ElevationHelperProtocol.ParseRequest(runner.Payload!).Target); + // The UI cannot inspect /root's children; success comes exclusively from helper verification. + Assert.IsFalse(fs.TryGetInfo("/root/dir; rm -rf ~", out _)); } [TestMethod] - public async Task CopyTargetsDestinationFolder() + public async Task ForgedCommandsAndMutatedPlanInputsAreRefused() { - var runner = new FakeRunner(); - var service = new PkexecElevationService(new FakeLocator("pkexec", "rm", "cp"), runner); - - await service.CopyAsync("/home/u/a.txt", "/opt/app"); - - CollectionAssert.AreEqual(new[] { "/usr/bin/cp", "-a", "--", "/home/u/a.txt", "/opt/app/" }, runner.Calls[0].Args); + var runner = new Runner(); var service = Service(FakeFs.Standard(), runner); + var plan = service.PlanDelete(["/home/u/a"]).Plan!; + Assert.IsFalse((await service.RunAsync(plan with { Sources = ["/etc/passwd"] })).Succeeded); + Assert.IsFalse((await service.RunAsync(plan with { Commands = [new("/usr/bin/rm", ["-rf", "/etc"])] })).Succeeded); + Assert.AreEqual(0, runner.Calls); } [TestMethod] - public async Task RefusesRootRelativeAndEmptyPaths() + public async Task MissingMalformedMismatchedAndPartialResultsFailClosed() { - var runner = new FakeRunner(); - var service = new PkexecElevationService(new FakeLocator("pkexec", "rm", "cp"), runner); - - Assert.IsFalse((await service.DeleteAsync("/")).Succeeded); - Assert.IsFalse((await service.DeleteAsync("/a/..")).Succeeded); - Assert.IsFalse((await service.DeleteAsync("relative")).Succeeded); - Assert.IsFalse((await service.DeleteAsync("")).Succeeded); - Assert.IsFalse((await service.CopyAsync("rel", "/opt")).Succeeded); - Assert.AreEqual(0, runner.Calls.Count); + var runner = new Runner(); var service = Service(FakeFs.Standard(), runner); + var plan = service.PlanDelete(["/home/u/a"]).Plan!; + foreach (var output in new[] { "", "{}", "null", "{\"version\":1,\"items\":[],\"error\":\"\"}", + ElevationHelperProtocol.Serialize(new HelperResponse(1, [new("/etc/passwd", true, "")], "")), + ElevationHelperProtocol.Serialize(new HelperResponse(1, [new("/home/u/a", false, "collision")], "")) }) + { + runner.OverrideOutput = output; + Assert.IsFalse((await service.RunAsync(plan)).Succeeded, output); + } } [TestMethod] - public async Task ReportsDismissedPromptAndMissingPkexec() + public async Task DismissedAuthorizationIsDistinctFromStartupFailure() { - var dismissed = await new PkexecElevationService(new FakeLocator("pkexec", "rm"), new FakeRunner(126)).DeleteAsync("/x/y"); - Assert.IsFalse(dismissed.Succeeded); - Assert.IsTrue(dismissed.WasDismissed); + var runner = new Runner { ExitCode = 126 }; var service = Service(FakeFs.Standard(), runner); + var plan = service.PlanDelete(["/home/u/a"]).Plan!; + Assert.IsTrue((await service.RunAsync(plan)).WasDismissed); + runner.ExitCode = 127; Assert.IsFalse((await service.RunAsync(plan)).WasDismissed); + } - var failed = await new PkexecElevationService(new FakeLocator("pkexec", "rm"), new FakeRunner(1, "boom")).DeleteAsync("/x/y"); - Assert.IsFalse(failed.WasDismissed); - Assert.AreEqual("boom", failed.Error); + [TestMethod] + public void RootActionsRequireTrustedInstalledHelperPolicyAndNativePackage() + { + var fs = FakeFs.Standard(); var runner = new Runner(); + fs.Entries["/usr/bin/pkexec"] = fs.Entries["/usr/bin/pkexec"] with { Mode = (UnixFileMode)0x9ED }; // pkexec is normally setuid root. + Assert.IsTrue(Service(fs, runner).IsAvailable); + Assert.IsFalse(Service(fs, runner, sandbox: true).IsAvailable); + fs.Entries.Remove(ElevationHelperProtocol.PolicyPath); Assert.IsFalse(Service(fs, runner).IsAvailable); + fs.File(ElevationHelperProtocol.PolicyPath); fs.Dir("/usr/lib/linuxfiles", 1000); + Assert.IsFalse(Service(fs, runner).IsAvailable); + fs.Dir("/usr/lib/linuxfiles"); fs.File(ElevationHelperProtocol.HelperPath, 1000); + Assert.IsFalse(Service(fs, runner).IsAvailable); + } - var missing = new PkexecElevationService(new FakeLocator("rm"), new FakeRunner()); - Assert.IsFalse(missing.IsAvailable); - Assert.IsFalse((await missing.DeleteAsync("/x/y")).Succeeded); + [TestMethod] + public void RenamePreviewUsesAbsoluteTargetAndRejectsUnsafeNames() + { + var service = Service(FakeFs.Standard(), new Runner()); + foreach (var name in new[] { "", ".", "..", "../b", "b/c", "a\0b" }) Assert.IsNull(service.PlanRename("/home/u/a", name).Plan); + var plan = service.PlanRename("/home/u/a", "new; name").Plan!; + Assert.AreEqual("/home/u/new; name", ElevationHelperProtocol.ParseRequest(plan.Commands[0].Arguments.Single()).Target); + Assert.IsNull(service.PlanDelete(["/"]).Plan); } } } From 1cfc2686fb4bf4375878bc56520d92534bc574b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kov=C3=A1cs=20B=C3=A1lint=20Hunor?= Date: Tue, 6 Oct 2026 09:50:53 +0300 Subject: [PATCH 2/4] Bind root-action authorization to the exact operation - Pass a sanitized summary and the SHA-256 of the stdin request to the helper; the polkit message shows $(command_line) and the helper rejects any digest or summary mismatch (verified against polkit 0.105 and 127) - Refuse protected top-level sources and /proc, /sys, /dev targets - Wait for the real helper result when cancellation cannot kill root work - Embed the policy and install from a verified private helper snapshot - Hand verified copies in caller-owned destinations to the caller - Drop symlinkat from the helper, use invariant globalization, treat pkexec 126/127 as dismissed, fail closed on unexpected exceptions and show non-ASCII paths readably in the preview Co-Authored-By: GPT-6.1-sol (OpenAI Codex) Co-Authored-By: Claude Opus 5.5 Reviewed-by: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LeKXJc3DnK3PzAqwgA72NY --- docs/linux-port/packaging.md | 4 +- docs/linux-port/threat-model-elevation.md | 18 ++- ....memergamer.LinuxFiles.root-actions.policy | 2 +- scripts/linux/install-local.sh | 5 +- scripts/linux/install-root-helper.py | 110 +++++++++++---- .../linux/tests/test_install_root_helper.py | 126 ++++++++++++++++++ .../Elevation/ElevationHelperProtocol.cs | 13 +- ...iles.Platform.Linux.ElevationHelper.csproj | 3 + .../HelperEngine.cs | 31 ++++- .../Program.cs | 31 +++-- .../Elevation/ElevationPlanPreview.cs | 14 +- .../Elevation/HelperAuthorization.cs | 52 ++++++++ .../Elevation/PkexecElevationService.cs | 63 ++++++--- .../Native/ElevationNative.cs | 46 ++++++- .../Native/PosixNative.cs | 6 +- .../SystemIntegration/ElevationHelperTests.cs | 86 +++++++++++- .../ElevationProcessRunnerTests.cs | 81 +++++++++++ .../ElevationServiceTests.cs | 5 +- .../HelperAuthorizationTests.cs | 71 ++++++++++ 19 files changed, 684 insertions(+), 83 deletions(-) create mode 100644 scripts/linux/tests/test_install_root_helper.py create mode 100644 src/Files.Platform.Linux/Elevation/HelperAuthorization.cs create mode 100644 tests/Files.Platform.Tests/SystemIntegration/ElevationProcessRunnerTests.cs create mode 100644 tests/Files.Platform.Tests/SystemIntegration/HelperAuthorizationTests.cs diff --git a/docs/linux-port/packaging.md b/docs/linux-port/packaging.md index ca7ad31dd41f..58be63ed9077 100644 --- a/docs/linux-port/packaging.md +++ b/docs/linux-port/packaging.md @@ -144,8 +144,8 @@ Try it: `curl -LO https://github.com/MemerGamer/LinuxFiles/releases/download/nig ## Root actions -Native AUR packages install a root-owned Native AOT helper at `/usr/lib/linuxfiles/files-elevation-helper` and `packaging/linux/io.github.memergamer.LinuxFiles.root-actions.policy` under `/usr/share/polkit-1/actions/`. Install polkit to enable the menu. The helper is never setuid; the action uses `auth_admin` without retaining authorization. AOT publishing requires clang and zlib development files. +Native AUR packages install a root-owned Native AOT helper at `/usr/lib/linuxfiles/files-elevation-helper` and `packaging/linux/io.github.memergamer.LinuxFiles.root-actions.policy` under `/usr/share/polkit-1/actions/`. Install polkit 0.105 or newer with distributor security fixes to enable the menu. The helper is never setuid; the action uses `auth_admin` without retaining authorization. AOT publishing requires clang and zlib development files. -For local installation, publish normally and opt in with `scripts/linux/install-local.sh --install-root-helper`. This separately runs `sudo python3 scripts/linux/install-root-helper.py --from artifacts/linux-x64/elevation-helper`; the app stays in the user prefix. To inspect the package layout without privileges, run `python3 scripts/linux/install-root-helper.py --from --destdir `. Remove the system helper and policy as administrator when uninstalling; the user-local uninstaller intentionally cannot remove system files. +For local installation, publish normally and opt in with `scripts/linux/install-local.sh --install-root-helper`. This separately runs `sudo python3 scripts/linux/install-root-helper.py --from artifacts/linux-x64/elevation-helper --sha256 `; the app stays in the user prefix. This is equivalent to `sudo make install`: trust the installer checkout and build output. The policy is embedded; a private root-owned helper snapshot is hashed and checked before installation. To inspect the package layout without privileges, run `python3 scripts/linux/install-root-helper.py --from --sha256 --destdir `. Remove the system helper and policy as administrator when uninstalling; the user-local uninstaller intentionally cannot remove system files. AppImage and Flatpak omit the helper, install no policy, and disable Root actions. They cannot supply host elevation, even when the native helper is installed separately. See [the elevation threat model](threat-model-elevation.md) for protocol, failure semantics and limitations. diff --git a/docs/linux-port/threat-model-elevation.md b/docs/linux-port/threat-model-elevation.md index 46403a59afe7..387173d35846 100644 --- a/docs/linux-port/threat-model-elevation.md +++ b/docs/linux-port/threat-model-elevation.md @@ -6,15 +6,21 @@ Root actions offers delete, rename and paste (copy or move). It stays hidden in The only elevated executable is `/usr/lib/linuxfiles/files-elevation-helper`, a small .NET 10 Native AOT console program. Native packages install it root-owned, mode 0755, with root-owned ancestors that are not writable by group/others. It has no setuid bit. The matching `packaging/linux/io.github.memergamer.LinuxFiles.root-actions.policy` requires `auth_admin` for an active session; other sessions are denied. Authorization is not retained (`auth_admin_keep` is never used). -Files executes the trusted absolute pkexec path with exactly one argument: the fixed helper path. It sends the confirmed plan on stdin, closes stdin, and reads JSON results capped at 512 KiB. It never executes rm, cp, mv, a shell, commands from JSON, or programs found through PATH. The subprocess starts with a cleared environment and LANG=C. pkexec removes dangerous loader variables before launching the AOT executable; the helper requires effective uid 0 and a non-root PKEXEC_UID, then clears its environment, sets umask 0077 and changes cwd to `/`. It takes no command-line switches and has no daemon, plugin loading, configuration files or test mode. +Files executes the trusted absolute pkexec path with the fixed helper path followed by two arguments: a sanitized human-readable summary (operation, source count and first source, absolute target) and the SHA-256 hex digest of the exact UTF-8 stdin JSON. It sends the confirmed plan on stdin, closes stdin, and reads JSON results capped at 512 KiB. It never executes rm, cp, mv, a shell, commands from JSON, or programs found through PATH. The subprocess starts with a cleared environment and LANG=C. pkexec removes dangerous loader variables before launching the AOT executable; the helper requires effective uid 0 and a non-root PKEXEC_UID, then clears its environment, sets umask 0077 and changes cwd to `/`. Before opening paths, it recomputes the digest from the raw stdin bytes, strictly parses the request and checks the summary against the parsed request. Missing/mismatched arguments are refused. It takes no command-line switches and has no daemon, plugin loading, configuration files or test mode. + +The polkit prompt is the authorization boundary against a compromised session. An in-app confirmation is useful UI, but malware in the same session can invoke pkexec directly, so that confirmation cannot authorize privileged execution. The policy explicitly states that authentication grants root file access to the listed paths and their contents, and includes `$(command_line)` and `$(program)`. The summary is checked rather than trusted, and the digest binds all request bytes (including additional batch sources) to that invocation. Only the first source is named in the prompt; approving a batch authorizes the count and digest too. Administrators should approve only expected operations and targets; authentication agents must display the full policy message. Bidi, control and zero-width characters become visible escapes; paths are quoted as JSON without truncation. The summary is display data, never a shell command or authorization rule expression. + +The supported minimum is **polkit 0.105** (with distributor security fixes). Upstream's [0.105 pkexec manual source](https://github.com/polkit-org/polkit/blob/0.105/docs/man/pkexec.xml) explicitly documents expansion of all three message variables for an annotated action. Its [pkexec source](https://github.com/polkit-org/polkit/blob/0.105/src/programs/pkexec.c) joins argv with spaces, sets `program` and `command_line` details, and overrides `polkit.message` only for the default action, leaving our custom policy message intact. The [current manual](https://polkit.pages.freedesktop.org/polkit/pkexec.1.html) also documents expansion and exit codes 126 (dismissed) / 127 (not authorized or authorization error); both produce a dismissed/not-authorized result in Files. This is a supported minimum verified in source, not a claim about when expansion first appeared. The same holds in polkit 127: polkitd's `get_localized_data_for_challenge` runs `expand_properties` over the action message, and `$(command_line)` is the untruncated argv (unlike pkexec's own `$(cmdline_short)`). + +Cancellation tries to stop pkexec, but a root child can reject killing with EPERM. Files drains bounded output and waits for exit without cancellation, then reports the actual helper result; it never labels running root work cancelled or failed. Even oversized output is drained after a denied kill before failure is reported. Unexpected top-level helper exceptions fail closed with exit 1 and a generic protocol error. ## Frozen confirmation and protocol -ElevationPlanPreview copies the plan into read-only collections and shows the exact operation, absolute sources and target encoded as JSON, together with the executable and the stdin marker. DisplaySanitizer escapes control, bidi and zero-width characters; oversized previews cannot be confirmed. Confirmation hands out that displayed plan once. Cancel, close and Escape do nothing; Cancel is the default button. Only one dialog can be open. +ElevationPlanPreview copies the plan into read-only collections and shows the exact operation, absolute sources and target encoded as readable Unicode JSON (display only; the strict wire serializer is unchanged), together with the executable and the stdin marker. DisplaySanitizer escapes control, bidi and zero-width characters; oversized previews cannot be confirmed. Confirmation hands out that displayed plan once. Cancel, close and Escape do nothing; Cancel is the default button. Only one dialog can be open. The application compares the complete serialized request with the displayed command data before sending it. Its path checks are UI preflight only. It never trusts unprivileged post-operation filesystem checks, including for destinations under `/root`. -The helper independently requires version 1, exactly the known JSON fields, unique property names, a known operation, 1–64 unique, non-overlapping sources, and normalized absolute paths with no NUL, empty components, dot components or root `/`. Rename carries an absolute target in the same parent; copy/move cannot target the source or its descendants or introduce duplicate destination names. Input is strict UTF-8, at most 64 KiB, with bounded JSON depth. Unknown/missing fields, trailing data and unsupported versions fail closed. Results have one ordered entry per source; malformed, missing, duplicate or mismatched results can never become success in the app. +The helper independently requires version 1, exactly the known JSON fields, unique property names, a known operation, 1–64 unique, non-overlapping sources, and normalized absolute paths with no NUL, empty components, dot components or root `/`. Delete/move/rename refuse `/` and top-level FHS sources `/usr /etc /boot /bin /lib /lib32 /lib64 /libx32 /sbin /var /home /root /proc /sys /dev /run /srv /opt /mnt /media /tmp` before opening paths. Copy/move refuse `/proc`, `/sys` and `/dev` targets and their descendants. Rename carries an absolute target in the same parent; copy/move cannot target the source or its descendants or introduce duplicate destination names. Input is strict UTF-8, at most 64 KiB, with bounded JSON depth. Unknown/missing fields, trailing data and unsupported versions fail closed. Results have one ordered entry per source; malformed, missing, duplicate or mismatched results can never become success in the app. ## Descriptor-relative execution @@ -24,7 +30,7 @@ The complete source trees and destination directory are pinned before mutation. - Delete uses unlinkat relative to pinned parents, recursing only into pinned source directories. Identity and tree membership are checked again before deleting; absence is confirmed with a descriptor-relative statx lookup. Only ENOENT proves absence; permission/stat failures never do. - Rename uses renameat2(RENAME_NOREPLACE) with the pinned parent and plain leaf names. Collisions, including dangling symlinks, fail atomically. No unsafe rename fallback exists. The renamed identity and source absence are checked inside the helper. -- Copy opens source files without following links, creates destination files with O_CREAT|O_EXCL and directories with mkdirat (existing names fail), and transfers bytes through descriptors. Copies are root-owned mode 0600, directories mode 0700, and carry no original ownership, setuid/setgid bits, timestamps, xattrs or hardlink relationships. Setuid/setgid source trees are refused. Size and SHA-256 of source and the helper's own created output are verified; completed output and its destination parent are fsynced and its directory membership and metadata are checked again. +- Copy opens source files without following links, creates destination files with O_CREAT|O_EXCL and directories with mkdirat (existing names fail), and transfers bytes through descriptors. Copies start root-owned mode 0600, directories mode 0700. When the pinned destination directory is owned by PKEXEC_UID, only after byte/tree verification and before any move removes its originals, the helper applies fchown through each pinned output fd to PKEXEC_UID and its primary gid from getpwuid_r. Files get source permissions masked by 0644 (no execute/setuid/setgid bits), directories 0755; metadata is rechecked and fsynced. Otherwise root ownership and 0600/0700 modes remain. No original ownership, timestamps, xattrs or hardlink relationships are preserved. Setuid/setgid source trees are refused. Size and SHA-256 of source and the helper's own created output are verified; completed output and its destination parent are fsynced and its directory membership and metadata are checked again. - Move follows the same verified-copy path in the same authenticated helper process. It removes only the pinned originals, checking identity and file metadata again before removal. A skipped/colliding/failed/unverified copy never triggers source deletion. Each item reports its own result; failure does not erase another item's verified success. Partial outputs remain in place and are reported as failures. The helper never performs recursive cleanup through an untrusted destination name. Partial deletion/move can leave a partially changed source tree; surviving originals remain, and the UI receives failure rather than success. @@ -39,9 +45,9 @@ Root authentication intentionally permits destructive actions on root-owned data ## Packaging and verification -AUR source packages AOT-publish the helper and install it and the policy at the fixed paths. Binary AUR packages use the helper in the release tarball. `publish.sh` includes it in `elevation-helper/`; AppImage/Flatpak explicitly remove it and set FILES_DISABLE_ROOT_ACTIONS=1 and add a .root-actions-disabled marker (the app also detects their runtime). `install-local.sh --install-root-helper` is explicit opt-in to a separate system installation through sudo; a default user-local install cannot supply a trusted helper. Policy/helper installation is never performed by the running app. +AUR source packages AOT-publish the helper and install it and the policy at the fixed paths. Binary AUR packages use the helper in the release tarball. `publish.sh` includes it in `elevation-helper/`; AppImage/Flatpak explicitly remove it and set FILES_DISABLE_ROOT_ACTIONS=1 and add a .root-actions-disabled marker (the app also detects their runtime). `install-local.sh --install-root-helper` is explicit opt-in to a separate system installation through sudo; a default user-local install cannot supply a trusted helper. Policy/helper installation is never performed by the running app. The opt-in installer is equivalent to `sudo make install`: it executes administrator-trusted installer code and installs administrator-trusted build output, not a security boundary against a malicious checkout or build. It embeds the fixed policy, resolves destdir before checking for live `/`, and copies the helper through a no-follow regular-file descriptor to a private installer-owned temporary directory (root-owned for live installs) before checking its SHA-256 and ELF header and installing from that snapshot. The user-side wrapper supplies the pre-sudo helper digest; no helper is executed during installation. Non-root staging uses the same snapshot checks and never invokes sudo. -Non-root tests run HelperEngine directly against private temp trees, substituting the current uid for the elevated identity and the observed root-directory owner for root in uid-mapped sandboxes. Hooks exist only in that in-process API; stdin cannot enable them. Tests cover both openat2 and forced fallback, authentication-time and post-pin symlink swaps, source leaf races, nested copy/move, no-replace collisions, corrupt/modified copies, changed originals and partial batches. App tests cover protocol failures, forged plans, helper-only verification and packaging/trust gates. Real polkit authentication, root-only directories, mount races and distro package installation require a separate administrator review; automated tests never invoke pkexec or use the real display. +Non-root tests run HelperEngine directly against private temp trees, substituting the current uid for the elevated identity and the observed root-directory owner for root in uid-mapped sandboxes. Hooks exist only in that in-process API; stdin cannot enable them. Tests also cover request-byte/summary substitution, prompt sanitization and Unicode preview, protected directories, EPERM cancellation with an unprivileged fake process, caller/root destination modes, descriptor-based ownership during leaf replacement, generic exception handling and non-root installer staging/tampering/root aliases. Tests cover both openat2 and forced fallback, authentication-time and post-pin symlink swaps, source leaf races, nested copy/move, no-replace collisions, corrupt/modified copies, changed originals and partial batches. App tests cover protocol failures, forged plans, helper-only verification and packaging/trust gates. Real polkit authentication, root-only directories, mount races and distro package installation require a separate administrator review; automated tests never invoke pkexec or use the real display. ## Not implemented diff --git a/packaging/linux/io.github.memergamer.LinuxFiles.root-actions.policy b/packaging/linux/io.github.memergamer.LinuxFiles.root-actions.policy index 9a51c8d92987..cfbe18b14c26 100644 --- a/packaging/linux/io.github.memergamer.LinuxFiles.root-actions.policy +++ b/packaging/linux/io.github.memergamer.LinuxFiles.root-actions.policy @@ -5,7 +5,7 @@ https://github.com/MemerGamer/LinuxFiles Perform the confirmed Files root operation - Authentication is required to perform this file operation as root + Authentication grants root file access to the listed source and target paths (including their contents). Review the operation and request SHA-256: $(command_line). Program: $(program) no no diff --git a/scripts/linux/install-local.sh b/scripts/linux/install-local.sh index f2de24f34b89..afb310ced584 100755 --- a/scripts/linux/install-local.sh +++ b/scripts/linux/install-local.sh @@ -48,8 +48,9 @@ if command -v gtk-update-icon-cache >/dev/null; then gtk-update-icon-cache -q -t if [[ "$install_helper" == true ]]; then [[ -x "$from/elevation-helper/files-elevation-helper" ]] || { echo "Missing AOT helper in publish output" >&2; exit 1; } - # Explicit opt-in: this installs only the fixed helper and policy as root, not the app. - /usr/bin/sudo /usr/bin/python3 "$root/scripts/linux/install-root-helper.py" --from "$from/elevation-helper" + # Equivalent to sudo make install: trust this installer and the published build. + read -r helper_hash _ < <(/usr/bin/sha256sum -- "$from/elevation-helper/files-elevation-helper") + /usr/bin/sudo /usr/bin/python3 "$root/scripts/linux/install-root-helper.py" --from "$from/elevation-helper" --sha256 "$helper_hash" fi echo "Installed to $libdir; launcher at $prefix/bin/files" diff --git a/scripts/linux/install-root-helper.py b/scripts/linux/install-root-helper.py index b486a6511852..dc3209999944 100644 --- a/scripts/linux/install-root-helper.py +++ b/scripts/linux/install-root-helper.py @@ -1,35 +1,89 @@ # Copyright (c) Files Community. Licensed under the MIT License. -"""Install the AOT root helper and polkit policy; invoked explicitly with sudo by install-local.sh.""" +"""Install trusted build output, equivalent to sudo make install (trust the installer and build).""" import argparse +import hashlib import os from pathlib import Path import stat +import struct import subprocess +import tempfile -parser = argparse.ArgumentParser(description=__doc__) -parser.add_argument("--from", dest="source", type=Path, required=True) -parser.add_argument("--destdir", type=Path, default=Path("/"), help="package staging root (no live system changes)") -args = parser.parse_args() -if args.destdir == Path("/") and os.geteuid() != 0: - parser.error("live installation requires root") -helper = args.source / "files-elevation-helper" -policy = Path(__file__).resolve().parents[2] / "packaging/linux/io.github.memergamer.LinuxFiles.root-actions.policy" -if helper.is_symlink() or not helper.is_file(): - parser.error("expected a published AOT helper, not a symlink") -for source, relative, mode in ((helper, "usr/lib/linuxfiles/files-elevation-helper", "755"), - (policy, "usr/share/polkit-1/actions/io.github.memergamer.LinuxFiles.root-actions.policy", "644")): - destination = args.destdir / relative - if args.destdir == Path("/"): - for parent in reversed(destination.parents): - if not parent.exists(): - parent.mkdir(mode=0o755) - info = parent.lstat() - if not stat.S_ISDIR(info.st_mode) or info.st_uid != 0 or info.st_mode & 0o022: - parser.error(f"untrusted installation directory: {parent}") - if destination.is_symlink(): - parser.error(f"refusing symlink destination: {destination}") - command = ["/usr/bin/install", "-D", "-m", mode] - if args.destdir == Path("/"): - command += ["-o", "root", "-g", "root"] - subprocess.run(command + ["--", str(source), str(destination)], check=True) -print(f"Installed helper and policy under {args.destdir}") +POLICY = '\n\n\n LinuxFiles\n https://github.com/MemerGamer/LinuxFiles\n \n Perform the confirmed Files root operation\n Authentication grants root file access to the listed source and target paths (including their contents). Review the operation and request SHA-256: $(command_line). Program: $(program)\n \n no\n no\n auth_admin\n \n /usr/lib/linuxfiles/files-elevation-helper\n \n\n' +MAX_HELPER_BYTES = 256 * 1024 * 1024 + + +def snapshot_helper(source, destination, expected_hash): + # Verify only the private snapshot; a user-writable build tree can change during sudo. + fd = os.open(source, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK) + with os.fdopen(fd, "rb") as input_file, destination.open("xb") as output_file: + if not stat.S_ISREG(os.fstat(input_file.fileno()).st_mode): + raise ValueError("expected a regular published AOT helper") + digest = hashlib.sha256() + total = 0 + while chunk := input_file.read(65536): + total += len(chunk) + if total > MAX_HELPER_BYTES: + raise ValueError("helper exceeds size limit") + digest.update(chunk) + output_file.write(chunk) + output_file.flush() + os.fsync(output_file.fileno()) + if digest.hexdigest() != expected_hash.lower(): + raise ValueError("helper snapshot SHA-256 mismatch") + with destination.open("rb") as binary: + header = binary.read(64) + if (len(header) != 64 or header[:7] != b"\x7fELF\x02\x01\x01" + or struct.unpack_from(" MaximumItems) throw new InvalidDataException("Invalid plan."); - foreach (var source in request.Sources) ValidatePath(source); + foreach (var source in request.Sources) + { + ValidatePath(source); + if (request.Operation is "delete" or "move" or "rename" && source is + "/usr" or "/etc" or "/boot" or "/bin" or "/lib" or "/lib32" or "/lib64" or "/libx32" or "/sbin" or + "/var" or "/home" or "/root" or "/proc" or "/sys" or "/dev" or "/run" or "/srv" or "/opt" or "/mnt" or "/media" or "/tmp") + throw new InvalidDataException("Protected system directory."); + } if (request.Sources.Distinct(StringComparer.Ordinal).Count() != request.Sources.Length) throw new InvalidDataException("Duplicate source."); foreach (var source in request.Sources) @@ -75,6 +83,9 @@ public static void Validate(HelperRequest request) else { ValidatePath(request.Target); + if (request.Operation is "copy" or "move" && new[] { "/proc", "/sys", "/dev" }.Any(path => + request.Target == path || request.Target!.StartsWith(path + "/", StringComparison.Ordinal))) + throw new InvalidDataException("Virtual filesystem destination refused."); if (request.Operation == "rename") { if (request.Sources.Length != 1 || Path.GetDirectoryName(request.Sources[0]) != Path.GetDirectoryName(request.Target)) diff --git a/src/Files.Platform.Linux.ElevationHelper/Files.Platform.Linux.ElevationHelper.csproj b/src/Files.Platform.Linux.ElevationHelper/Files.Platform.Linux.ElevationHelper.csproj index e80156374449..d8cdea57f61d 100644 --- a/src/Files.Platform.Linux.ElevationHelper/Files.Platform.Linux.ElevationHelper.csproj +++ b/src/Files.Platform.Linux.ElevationHelper/Files.Platform.Linux.ElevationHelper.csproj @@ -8,6 +8,7 @@ true true true + true true $(DefineConstants);ELEVATION_HELPER false @@ -18,5 +19,7 @@ + + diff --git a/src/Files.Platform.Linux.ElevationHelper/HelperEngine.cs b/src/Files.Platform.Linux.ElevationHelper/HelperEngine.cs index 89e0509166ac..811a160b92c5 100644 --- a/src/Files.Platform.Linux.ElevationHelper/HelperEngine.cs +++ b/src/Files.Platform.Linux.ElevationHelper/HelperEngine.cs @@ -13,7 +13,7 @@ namespace Files.Platform.Linux.ElevationHelper { - // Hooks are only an in-process test seam; the executable accepts no switches or hook configuration. + // Hooks are only an in-process test seam; argv and stdin cannot enable them. public sealed class HelperEngine(uint callerUid, Action? hook = null, bool forceFallback = false, uint trustedRootUid = 0) { private const int PathOnly = 0x200000 | 0x80000; @@ -43,10 +43,15 @@ public HelperResponse Execute(HelperRequest request) ElevationHelperProtocol.Validate(request); visited = 0; Descriptor? destination = null; + uint? destinationGroup = null; var opened = new List<(string Path, Descriptor? Parent, Node? Source, string Error)>(); try { - if (request.Operation is "copy" or "move") destination = OpenDirectoryPath(request.Target!); + if (request.Operation is "copy" or "move") + { + destination = OpenDirectoryPath(request.Target!); + if (Inspect(destination.Fd)!.Value.Owner == callerUid) destinationGroup = PrimaryGroup(callerUid); + } // Pin the complete plan before starting any mutation. foreach (var path in request.Sources) { @@ -104,6 +109,14 @@ public HelperResponse Execute(HelperRequest request) SyncParent(destination.Fd); ValidateTree(parent.Fd, source, true); ValidateTree(destination.Fd, copy, true); + // Hand over before removing move originals, so an ownership failure preserves them. + if (destinationGroup is { } group) + { + ValidateTree(destination.Fd, copy, true); + AssignCopyOwnership(source, copy, group); + ValidateTree(destination.Fd, copy, true); + SyncParent(destination.Fd); + } if (request.Operation == "move") { hook?.Invoke("before-delete", item.Path); @@ -256,6 +269,20 @@ private Node Copy(Node source, int parentFd) catch { copy.Dispose(); throw; } } + private void AssignCopyOwnership(Node source, Node copy, uint group) + { + for (var i = 0; i < source.Children.Count; i++) AssignCopyOwnership(source.Children[i], copy.Children[i], group); + hook?.Invoke("before-output-ownership", copy.Name); + var mode = source.Stamp.Directory ? 0x1EDU : source.Stamp.Mode & 0x1A4U; // 0755 / source masked by 0644 + SetOwnership(copy.Descriptor.Fd, callerUid, group, mode); + Sync(copy.Descriptor.Fd); + var current = Inspect(copy.Descriptor.Fd)!.Value; + RequireSame(copy.Stamp, current); + if (current.Owner != callerUid || current.Group != group || (current.Mode & 0xFFF) != mode) + throw new IOException("Copy ownership or permissions could not be verified."); + copy.Stamp = current; + } + private static void VerifyCopy(Node source, Node copy) { if (source.Stamp.Directory != copy.Stamp.Directory || source.Children.Count != copy.Children.Count) diff --git a/src/Files.Platform.Linux.ElevationHelper/Program.cs b/src/Files.Platform.Linux.ElevationHelper/Program.cs index 18d2e77b0e6c..1fbe93d47af2 100644 --- a/src/Files.Platform.Linux.ElevationHelper/Program.cs +++ b/src/Files.Platform.Linux.ElevationHelper/Program.cs @@ -3,21 +3,19 @@ using Files.Platform.Abstractions.Elevation; using Files.Platform.Linux.Native; +using Files.Platform.Linux.Elevation; using System; using System.Globalization; using System.IO; -using System.Text; namespace Files.Platform.Linux.ElevationHelper { internal static class Program { - private static int Main(string[] args) - { - try + private static int Main(string[] args) => HelperEntryPoint.Run(() => { if (!OperatingSystem.IsLinux() || System.Runtime.InteropServices.RuntimeInformation.ProcessArchitecture is not (System.Runtime.InteropServices.Architecture.X64 or System.Runtime.InteropServices.Architecture.Arm64) - || args.Length != 0 || ElevationNative.GetEffectiveUid() != 0 + || args.Length != 2 || ElevationNative.GetEffectiveUid() != 0 || !uint.TryParse(Environment.GetEnvironmentVariable("PKEXEC_UID"), NumberStyles.None, CultureInfo.InvariantCulture, out var caller) || caller == 0) throw new InvalidDataException("This helper requires pkexec authorization."); foreach (System.Collections.DictionaryEntry entry in Environment.GetEnvironmentVariables()) @@ -33,14 +31,25 @@ private static int Main(string[] args) if (data.Length + count > ElevationHelperProtocol.MaximumBytes) throw new InvalidDataException("Plan too large."); data.Write(buffer, 0, count); } - var request = ElevationHelperProtocol.ParseRequest(new UTF8Encoding(false, true).GetString(data.ToArray())); - var response = new HelperEngine(caller).Execute(request); - Console.WriteLine(ElevationHelperProtocol.Serialize(response)); - return Array.TrueForAll(response.Items, item => item.Succeeded) ? 0 : 1; + var request = HelperAuthorization.Verify(data.ToArray(), args); + return new HelperEngine(caller).Execute(request); + }, Console.Out); + } + + public static class HelperEntryPoint + { + public static int Run(Func execute, TextWriter output) + { + try + { + var response = execute(); + output.WriteLine(ElevationHelperProtocol.Serialize(response)); + return response.Error.Length == 0 && Array.TrueForAll(response.Items, item => item.Succeeded) ? 0 : 1; } - catch (Exception ex) when (ex is InvalidDataException or IOException or UnauthorizedAccessException or System.Text.DecoderFallbackException or System.Text.Json.JsonException or ArgumentException) + catch (Exception) { - Console.WriteLine(ElevationHelperProtocol.Serialize(new HelperResponse(1, [], ex.Message.Length > 512 ? ex.Message[..512] : ex.Message))); + try { output.WriteLine("{\"version\":1,\"items\":[],\"error\":\"Privileged operation failed.\"}"); } + catch (Exception) { } return 1; } } diff --git a/src/Files.Platform.Linux/Elevation/ElevationPlanPreview.cs b/src/Files.Platform.Linux/Elevation/ElevationPlanPreview.cs index bb0bfdc52033..084c3e42475d 100644 --- a/src/Files.Platform.Linux/Elevation/ElevationPlanPreview.cs +++ b/src/Files.Platform.Linux/Elevation/ElevationPlanPreview.cs @@ -76,9 +76,17 @@ public void Update(string input) var blocks = new List(); foreach (var command in plan.Commands) { - var lines = DisplaySanitizer.FullArguments(command.Program == ElevationHelperProtocol.HelperPath - ? ["pkexec", command.Program, "stdin (JSON)", .. command.Arguments] - : ["pkexec", command.Program, .. command.Arguments]); + IReadOnlyList arguments = command.Arguments; + if (command.Program == ElevationHelperProtocol.HelperPath && arguments.Count == 1) + { + try + { + var request = ElevationHelperProtocol.ParseRequest(arguments[0]); + arguments = [.. HelperAuthorization.Arguments(arguments[0]), "stdin (JSON)", HelperAuthorization.DisplayJson(request)]; + } + catch (Exception) { return null; } + } + var lines = DisplaySanitizer.FullArguments(["pkexec", command.Program, .. arguments]); if (lines is null) return null; diff --git a/src/Files.Platform.Linux/Elevation/HelperAuthorization.cs b/src/Files.Platform.Linux/Elevation/HelperAuthorization.cs new file mode 100644 index 000000000000..7631ab417961 --- /dev/null +++ b/src/Files.Platform.Linux/Elevation/HelperAuthorization.cs @@ -0,0 +1,52 @@ +// Copyright (c) Files Community +// Licensed under the MIT License. + +using Files.Platform.Abstractions.Elevation; +using Files.Platform.Linux.Launching; +using System; +using System.Globalization; +using System.IO; +using System.Security.Cryptography; +using System.Text; +using System.Text.Encodings.Web; +using System.Text.Json; + +namespace Files.Platform.Linux.Elevation +{ + public static class HelperAuthorization + { + private static readonly ElevationJsonContext DisplayContext = new(new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + }); + + public static string DisplayJson(HelperRequest request) + => DisplaySanitizer.Escape(JsonSerializer.Serialize(request, DisplayContext.HelperRequest)); + + public static string Summary(HelperRequest request) + { + ElevationHelperProtocol.Validate(request); + return request.Operation + "; sources=" + request.Sources.Length.ToString(CultureInfo.InvariantCulture) + + "; first source=" + DisplayPath(request.Sources[0]) + "; target=" + + (request.Target is null ? "none" : DisplayPath(request.Target)); + } + + private static string DisplayPath(string path) + => DisplaySanitizer.Escape(JsonSerializer.Serialize(path, DisplayContext.String)); + + public static string[] Arguments(string json) + => [Summary(ElevationHelperProtocol.ParseRequest(json)), Digest(new UTF8Encoding(false, true).GetBytes(json))]; + + public static string Digest(ReadOnlySpan data) => Convert.ToHexString(SHA256.HashData(data)); + + public static HelperRequest Verify(ReadOnlySpan data, string[] arguments) + { + if (data.Length > ElevationHelperProtocol.MaximumBytes || arguments.Length != 2 || arguments[1] != Digest(data)) + throw new InvalidDataException("Authorization does not match the request."); + var request = ElevationHelperProtocol.ParseRequest(new UTF8Encoding(false, true).GetString(data)); + if (arguments[0] != Summary(request)) throw new InvalidDataException("Authorization summary does not match the request."); + return request; + } + } +} diff --git a/src/Files.Platform.Linux/Elevation/PkexecElevationService.cs b/src/Files.Platform.Linux/Elevation/PkexecElevationService.cs index bf05c415d9b2..92b6e0544b4b 100644 --- a/src/Files.Platform.Linux/Elevation/PkexecElevationService.cs +++ b/src/Files.Platform.Linux/Elevation/PkexecElevationService.cs @@ -30,7 +30,7 @@ public interface IElevatedProcessRunner /// /// Starts real processes. /// - public sealed class ProcessElevatedRunner : IElevatedProcessRunner, IRootHelperProcessRunner + public sealed class ProcessElevatedRunner(Action? terminate = null) : IElevatedProcessRunner, IRootHelperProcessRunner { /// public async Task<(int ExitCode, string StandardError)> RunAsync(string fileName, IReadOnlyList arguments, CancellationToken cancellationToken) @@ -58,45 +58,66 @@ public sealed class ProcessElevatedRunner : IElevatedProcessRunner, IRootHelperP } catch (OperationCanceledException) { - try { process.Kill(entireProcessTree: true); } catch (InvalidOperationException) { } + TryKill(process, null); throw; } await stdout.ConfigureAwait(false); return (process.ExitCode, await stderr.ConfigureAwait(false)); } + + // Killing a child that already became root fails with EPERM; callers then wait for its real exit. + private static void TryKill(Process process, Action? terminate) + { + try + { + if (terminate is null) process.Kill(entireProcessTree: true); + else terminate(process); + } + catch (InvalidOperationException) { } + catch (System.ComponentModel.Win32Exception) { } + catch (AggregateException ex) when (ex.InnerExceptions.All(inner => inner is System.ComponentModel.Win32Exception or InvalidOperationException)) { } + } + public async Task<(int ExitCode, string Output, string Error)> RunHelperAsync(string pkexec, string json, CancellationToken cancellationToken) { + cancellationToken.ThrowIfCancellationRequested(); var info = new ProcessStartInfo(pkexec) { UseShellExecute = false, RedirectStandardInput = true, RedirectStandardOutput = true, - RedirectStandardError = true, CreateNoWindow = true, + RedirectStandardError = true, CreateNoWindow = true, StandardInputEncoding = new UTF8Encoding(false, true), }; info.Environment.Clear(); info.Environment["LANG"] = "C"; info.ArgumentList.Add(ElevationHelperProtocol.HelperPath); + foreach (var argument in HelperAuthorization.Arguments(json)) info.ArgumentList.Add(argument); using var process = Process.Start(info) ?? throw new IOException("Unable to start authorization."); + var output = ReadBoundedAsync(process.StandardOutput, CancellationToken.None, Stop); + var error = ReadBoundedAsync(process.StandardError, CancellationToken.None, Stop); + var send = SendAsync(); try { - var output = ReadBoundedAsync(process.StandardOutput, cancellationToken, () => process.Kill(entireProcessTree: true)); - var error = ReadBoundedAsync(process.StandardError, cancellationToken, () => process.Kill(entireProcessTree: true)); - var send = SendAsync(); - await Task.WhenAll(send, output, error, process.WaitForExitAsync(cancellationToken)).ConfigureAwait(false); - return (process.ExitCode, await output.ConfigureAwait(false), await error.ConfigureAwait(false)); - async Task SendAsync() + try { await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false); } + catch (OperationCanceledException) { - try { await process.StandardInput.WriteAsync(json.AsMemory(), cancellationToken).ConfigureAwait(false); } - catch (IOException) { } // A dismissed prompt can close its input without reading the plan. - finally { process.StandardInput.Close(); } + Stop(); + // A root child may reject SIGKILL with EPERM. Its actual result remains authoritative. + await process.WaitForExitAsync(CancellationToken.None).ConfigureAwait(false); } + await Task.WhenAll(send, output, error).ConfigureAwait(false); + return (process.ExitCode, await output.ConfigureAwait(false), await error.ConfigureAwait(false)); } finally { - if (!process.HasExited) - { - try { process.Kill(entireProcessTree: true); } catch (InvalidOperationException) { } - await process.WaitForExitAsync(CancellationToken.None).ConfigureAwait(false); - } + if (!process.HasExited) Stop(); + await process.WaitForExitAsync(CancellationToken.None).ConfigureAwait(false); + } + void Stop() => TryKill(process, terminate); + async Task SendAsync() + { + try { await process.StandardInput.WriteAsync(json.AsMemory(), CancellationToken.None).ConfigureAwait(false); } + catch (IOException) { } // A dismissed prompt can close its input without reading the plan. + finally { process.StandardInput.Close(); } } } @@ -105,15 +126,19 @@ private static async Task ReadBoundedAsync(StreamReader reader, Cancella var text = new StringBuilder(); var buffer = new char[4096]; int count; + var exceeded = false; while ((count = await reader.ReadAsync(buffer.AsMemory(), cancellationToken).ConfigureAwait(false)) != 0) { + if (exceeded) continue; if (text.Length + count > ElevationHelperProtocol.MaximumResultBytes) { try { stop(); } catch (InvalidOperationException) { } - throw new IOException("Helper output exceeds protocol limit."); + exceeded = true; + continue; } text.Append(buffer, 0, count); } + if (exceeded) throw new IOException("Helper output exceeds protocol limit."); return text.ToString(); } @@ -206,7 +231,7 @@ public async Task RunAsync(ElevatedPlan plan, CancellationToken if (plan.Commands.Count != 1 || plan.Commands[0].Program != ElevationHelperProtocol.HelperPath || !plan.Commands[0].Arguments.SequenceEqual(new[] { json })) return Failure("The operation no longer matches what was confirmed."); var (exitCode, output, error) = await runner.RunHelperAsync(pkexec, json, cancellationToken).ConfigureAwait(false); - if (exitCode == 126) return new(false, true, exitCode, error); + if (exitCode is 126 or 127) return new(false, true, exitCode, error); if (exitCode is not (0 or 1)) return new(false, false, exitCode, error); var response = ElevationHelperProtocol.ParseResponse(output); if (response.Error.Length != 0) return new(false, false, exitCode, response.Error); diff --git a/src/Files.Platform.Linux/Native/ElevationNative.cs b/src/Files.Platform.Linux/Native/ElevationNative.cs index 4b55a28595b3..eefb5b7c4bb2 100644 --- a/src/Files.Platform.Linux/Native/ElevationNative.cs +++ b/src/Files.Platform.Linux/Native/ElevationNative.cs @@ -28,7 +28,47 @@ private struct OpenHow { public ulong Flags; public ulong Mode; public ulong Res internal static partial uint Umask(uint mode); - internal readonly record struct Stamp(uint Mode, uint Owner, ulong Inode, ulong Size, ulong Mount, uint Major, uint Minor, + [StructLayout(LayoutKind.Sequential)] + private struct Passwd + { + public nint Name, Password; + public uint Uid, Gid; + public nint Gecos, Home, Shell; + } + + [LibraryImport("libc", EntryPoint = "getpwuid_r")] + private static partial int GetPasswd(uint uid, ref Passwd entry, byte* buffer, nuint length, out nint result); + + [LibraryImport("libc", EntryPoint = "fchown", SetLastError = true)] + private static partial int Fchown(int fd, uint uid, uint gid); + + [LibraryImport("libc", EntryPoint = "fchmod", SetLastError = true)] + private static partial int Fchmod(int fd, uint mode); + + internal static uint PrimaryGroup(uint uid) + { + for (var length = 16384; length <= 1048576; length *= 2) + { + var buffer = new byte[length]; + var entry = new Passwd(); + fixed (byte* pointer = buffer) + { + var error = GetPasswd(uid, ref entry, pointer, (nuint)length, out var result); + if (error == 34) continue; // ERANGE + if (error != 0 || result == 0 || entry.Uid != uid) throw new IOException("Caller primary group unavailable."); + return entry.Gid; + } + } + throw new IOException("Caller account exceeds safety limits."); + } + + internal static void SetOwnership(int fd, uint uid, uint gid, uint mode) + { + if (Fchown(fd, uid, gid) != 0 || Fchmod(fd, mode) != 0) + throw new IOException("Unable to set verified copy ownership and permissions."); + } + + internal readonly record struct Stamp(uint Mode, uint Owner, uint Group, ulong Inode, ulong Size, ulong Mount, uint Major, uint Minor, long ModifiedSeconds, uint ModifiedNanos, long ChangedSeconds, uint ChangedNanos) { public bool Directory => (Mode & 0xF000) == 0x4000; @@ -42,7 +82,7 @@ public bool SameObject(Stamp other) => Inode == other.Inode && Major == other.Ma internal static Stamp? Inspect(int fd, string name = "", bool allowMissing = false) { - const uint required = 0x1 | 0x2 | 0x8 | 0x40 | 0x80 | 0x100 | 0x200 | 0x1000; + const uint required = 0x1 | 0x2 | 0x8 | 0x10 | 0x40 | 0x80 | 0x100 | 0x200 | 0x1000; var buffer = new byte[256]; fixed (byte* pointer = buffer) { @@ -54,7 +94,7 @@ public bool SameObject(Stamp other) => Inode == other.Inode && Major == other.Ma } } if ((BitConverter.ToUInt32(buffer, 0) & required) != required) throw new IOException("Required statx identity fields unavailable."); - return new Stamp(BitConverter.ToUInt16(buffer, 28), BitConverter.ToUInt32(buffer, 20), BitConverter.ToUInt64(buffer, 32), + return new Stamp(BitConverter.ToUInt16(buffer, 28), BitConverter.ToUInt32(buffer, 20), BitConverter.ToUInt32(buffer, 24), BitConverter.ToUInt64(buffer, 32), BitConverter.ToUInt64(buffer, 40), BitConverter.ToUInt64(buffer, 144), BitConverter.ToUInt32(buffer, 136), BitConverter.ToUInt32(buffer, 140), BitConverter.ToInt64(buffer, 112), BitConverter.ToUInt32(buffer, 120), BitConverter.ToInt64(buffer, 96), BitConverter.ToUInt32(buffer, 104)); } diff --git a/src/Files.Platform.Linux/Native/PosixNative.cs b/src/Files.Platform.Linux/Native/PosixNative.cs index 97ee09817561..8c68524c7214 100644 --- a/src/Files.Platform.Linux/Native/PosixNative.cs +++ b/src/Files.Platform.Linux/Native/PosixNative.cs @@ -189,6 +189,8 @@ public static bool MakeDirectoryAt(int dirfd, string name, out int errno) return ok; } + +#if !ELEVATION_HELPER /// Creates a symbolic link to relative to . public static bool SymlinkAt(string target, int dirfd, string name, out int errno) { @@ -197,6 +199,8 @@ public static bool SymlinkAt(string target, int dirfd, string name, out int errn return ok; } + +#endif /// Removes a file, link or (with ) empty directory relative to . public static void UnlinkAt(int dirfd, string name, int flags, string displayPath) { @@ -277,8 +281,6 @@ public static Exception CreateException(int errno, string path) #if ELEVATION_HELPER [LibraryImport("libc", EntryPoint = "mkdirat", StringMarshalling = StringMarshalling.Utf8, SetLastError = true)] private static partial int mkdirat(int fd, string name, uint mode); - [LibraryImport("libc", EntryPoint = "symlinkat", StringMarshalling = StringMarshalling.Utf8, SetLastError = true)] - private static partial int symlinkat(string target, int fd, string name); #endif [LibraryImport("libc", EntryPoint = "fcntl", SetLastError = true)] diff --git a/tests/Files.Platform.Tests/SystemIntegration/ElevationHelperTests.cs b/tests/Files.Platform.Tests/SystemIntegration/ElevationHelperTests.cs index 769742fdf79a..9fec5156ed92 100644 --- a/tests/Files.Platform.Tests/SystemIntegration/ElevationHelperTests.cs +++ b/tests/Files.Platform.Tests/SystemIntegration/ElevationHelperTests.cs @@ -61,7 +61,7 @@ public void CopiesAndMovesNestedTreesThroughDescriptors(bool fallback) Assert.IsTrue(copied.Items.Single().Succeeded, copied.Items.Single().Error); Assert.AreEqual("confirmed bytes", File.ReadAllText(Path.Combine(target, "tree", "a"))); Assert.IsTrue(Directory.Exists(folder)); - Assert.AreEqual(UnixFileMode.UserRead | UnixFileMode.UserWrite, File.GetUnixFileMode(Path.Combine(target, "tree", "a"))); + Assert.AreEqual((UnixFileMode)0x1A4, File.GetUnixFileMode(Path.Combine(target, "tree", "a"))); var second = Directory.CreateDirectory(Path.Combine(root, "second")).FullName; var moved = Run("move", [folder], second, fallback: fallback); Assert.IsTrue(moved.Items.Single().Succeeded, moved.Items.Single().Error); @@ -217,6 +217,90 @@ public void DestinationAncestorSwapAfterPinStaysInOriginalFolder() Assert.AreEqual("confirmed bytes", File.ReadAllText(Path.Combine(target + "-old", "a"))); } + [TestMethod] + public void ProtectedSourcesAreRefusedBeforeFilesystemAccess() + { + foreach (var path in new[] { "/", "/usr", "/etc", "/boot", "/bin", "/lib", "/lib32", "/lib64", "/libx32", "/sbin", + "/var", "/home", "/root", "/proc", "/sys", "/dev", "/run", "/srv", "/opt", "/mnt", "/media", "/tmp" }) + foreach (var operation in new[] { "delete", "move", "rename" }) + Assert.ThrowsExactly(() => Run(operation, [path], operation == "delete" ? null : + operation == "rename" ? path + "-renamed" : target, + (_, _) => Assert.Fail("A protected plan reached filesystem access.")), path + " " + operation); + } + + [TestMethod] + public void VirtualFilesystemDestinationsAreRefusedBeforeOpeningSources() + { + foreach (var path in new[] { "/proc", "/proc/1", "/sys", "/sys/kernel", "/dev", "/dev/shm" }) + foreach (var operation in new[] { "copy", "move" }) + Assert.ThrowsExactly(() => Run(operation, [Path.Combine(source, "a")], path, + (_, _) => Assert.Fail("A virtual destination reached filesystem access."))); + ElevationHelperProtocol.Validate(new(1, "copy", ["/tmp/a"], "/device")); + } + + [TestMethod] + [DataRow("copy", false)] + [DataRow("move", true)] + public void CallerOwnedDestinationGetsUsableVerifiedCopies(string operation, bool fallback) + { + var folder = Directory.CreateDirectory(Path.Combine(source, "tree")).FullName; + var file = FileAt(folder, "read-only"); + File.SetUnixFileMode(file, (UnixFileMode)0x100); + var stages = 0; + var result = Run(operation, [folder], target, (stage, name) => + { + if (stage != "before-output-ownership") return; + stages++; + if (name == "read-only") Assert.AreEqual((UnixFileMode)0x180, File.GetUnixFileMode(Path.Combine(target, "tree", name))); + }, fallback); + Assert.IsTrue(result.Items.Single().Succeeded, result.Items.Single().Error); + Assert.AreEqual(2, stages); + Assert.AreEqual((UnixFileMode)0x100, File.GetUnixFileMode(Path.Combine(target, "tree", "read-only"))); + Assert.AreEqual((UnixFileMode)0x1ED, File.GetUnixFileMode(Path.Combine(target, "tree"))); + Assert.IsTrue(new StatxFileOwnershipInspector().TryGetInfo(Path.Combine(target, "tree", "read-only"), out var info)); + Assert.AreEqual(ProcessIdentityNative.CurrentUserId, info.OwnerUserId); + Assert.AreEqual("confirmed bytes", File.ReadAllText(Path.Combine(target, "tree", "read-only"))); + } + + [TestMethod] + public void RootOwnedDestinationKeepsPrivateOutputModes() + { + var destination = Path.GetTempPath().TrimEnd('/'); + Assert.IsTrue(new StatxFileOwnershipInspector().TryGetInfo(destination, out var info)); + if (info.OwnerUserId == ProcessIdentityNative.CurrentUserId) Assert.Inconclusive("Requires root-owned temporary directory."); + var name = "files-private-copy-" + Guid.NewGuid().ToString("N"); + var folder = Directory.CreateDirectory(Path.Combine(source, name)).FullName; + FileAt(folder, "a"); + var output = Path.Combine(destination, name); + try + { + var result = Run("copy", [folder], destination); + Assert.IsTrue(result.Items.Single().Succeeded, result.Items.Single().Error); + Assert.AreEqual((UnixFileMode)0x1C0, File.GetUnixFileMode(output)); + Assert.AreEqual((UnixFileMode)0x180, File.GetUnixFileMode(Path.Combine(output, "a"))); + } + finally { if (Directory.Exists(output)) Directory.Delete(output, true); } + } + + [TestMethod] + public void OwnershipIsAppliedToVerifiedDescriptorDespiteLeafReplacement() + { + var file = FileAt(source, "a"); + var output = Path.Combine(target, "a"); + var displaced = Path.Combine(target, "pinned"); + var result = Run("copy", [file], target, (stage, _) => + { + if (stage != "before-output-ownership") return; + File.Move(output, displaced); + FileAt(target, "a", "unrelated"); + File.SetUnixFileMode(output, (UnixFileMode)0x180); + }); + Assert.IsFalse(result.Items.Single().Succeeded); + Assert.AreEqual("unrelated", File.ReadAllText(output)); + Assert.AreEqual((UnixFileMode)0x180, File.GetUnixFileMode(output)); + Assert.AreEqual((UnixFileMode)0x1A4, File.GetUnixFileMode(displaced)); + } + [TestMethod] public void StrictProtocolRejectsMalformedForgedAndOverlappingPlans() { diff --git a/tests/Files.Platform.Tests/SystemIntegration/ElevationProcessRunnerTests.cs b/tests/Files.Platform.Tests/SystemIntegration/ElevationProcessRunnerTests.cs new file mode 100644 index 000000000000..90efa5f84da0 --- /dev/null +++ b/tests/Files.Platform.Tests/SystemIntegration/ElevationProcessRunnerTests.cs @@ -0,0 +1,81 @@ +// Copyright (c) Files Community +// Licensed under the MIT License. + +using Files.Platform.Abstractions.Elevation; +using Files.Platform.Linux.Elevation; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System; +using System.ComponentModel; +using System.IO; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace Files.Platform.Tests.SystemIntegration +{ + [TestClass] + [System.Runtime.Versioning.SupportedOSPlatform("linux")] + public sealed class ElevationProcessRunnerTests + { + [TestMethod] + [DataRow(0, false)] + [DataRow(1, true)] + [DataRow(2, false)] + public async Task CancellationWithEpermWaitsAndReturnsActualExitAndOutput(int exitCode, bool processTree) + { + var directory = Path.Combine(Path.GetTempPath(), "files-auth-process-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(directory); + var started = Path.Combine(directory, "started"); + var release = Path.Combine(directory, "release"); + var executable = Path.Combine(directory, "fake-pkexec"); + var json = ElevationHelperProtocol.Serialize(new HelperRequest(1, "delete", ["/home/u/café"], null)); + var script = "#!/usr/bin/python3\nimport sys, json, hashlib, pathlib, time\n" + + "directory = pathlib.Path(__file__).resolve().parent\n" + + "data = sys.stdin.buffer.read()\nassert len(sys.argv) == 4\n" + + "assert hashlib.sha256(data).hexdigest().upper() == sys.argv[3]\n" + + "request = json.loads(data)\n" + + "(directory / 'started').touch()\n" + + "deadline = time.monotonic() + 10\n" + + "while not (directory / 'release').exists() and time.monotonic() < deadline: time.sleep(0.01)\n" + + $"print(json.dumps({{'version':1,'items':[{{'source':request['sources'][0],'succeeded':{(exitCode == 0 ? "True" : "False")},'error':{(exitCode == 0 ? "''" : "'real failure'")}}}],'error':''}}))\n" + + (exitCode == 2 ? "sys.stdout.write('x' * 600000)\n" : "") + + $"sys.exit({exitCode})\n"; + File.WriteAllText(executable, script, new UTF8Encoding(false)); + File.SetUnixFileMode(executable, (UnixFileMode)0x1C0); + var killAttempted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var runner = new ProcessElevatedRunner(_ => + { + killAttempted.TrySetResult(); + // Process.Kill(entireProcessTree: true) reports EPERM wrapped in an AggregateException. + var denied = new Win32Exception(1, "Operation not permitted"); + throw processTree ? new AggregateException(denied) : denied; + }); + using var cancellation = new CancellationTokenSource(); + var execution = runner.RunHelperAsync(executable, json, cancellation.Token); + try + { + var deadline = DateTime.UtcNow.AddSeconds(5); + while (!File.Exists(started) && DateTime.UtcNow < deadline && !execution.IsCompleted) await Task.Delay(10); + Assert.IsTrue(File.Exists(started), "Fake process did not read its bound input."); + cancellation.Cancel(); + await killAttempted.Task.WaitAsync(TimeSpan.FromSeconds(5)); + Assert.IsFalse(execution.IsCompleted, "Root work is still running and must not be reported as finished."); + File.WriteAllText(release, ""); + if (exitCode == 2) + { + await Assert.ThrowsExactlyAsync(() => execution.WaitAsync(TimeSpan.FromSeconds(5))); + return; + } + var result = await execution.WaitAsync(TimeSpan.FromSeconds(5)); + Assert.AreEqual(exitCode, result.ExitCode); + Assert.AreEqual(exitCode == 0, ElevationHelperProtocol.ParseResponse(result.Output).Items[0].Succeeded); + } + finally + { + File.WriteAllText(release, ""); + try { await execution; } catch (IOException) when (exitCode == 2) { } + Directory.Delete(directory, true); + } + } + } +} diff --git a/tests/Files.Platform.Tests/SystemIntegration/ElevationServiceTests.cs b/tests/Files.Platform.Tests/SystemIntegration/ElevationServiceTests.cs index b597a221bce2..7fa8c3c96d59 100644 --- a/tests/Files.Platform.Tests/SystemIntegration/ElevationServiceTests.cs +++ b/tests/Files.Platform.Tests/SystemIntegration/ElevationServiceTests.cs @@ -89,12 +89,13 @@ public async Task MissingMalformedMismatchedAndPartialResultsFailClosed() } [TestMethod] - public async Task DismissedAuthorizationIsDistinctFromStartupFailure() + public async Task DismissedOrDeniedAuthorizationIsDistinctFromHelperFailure() { var runner = new Runner { ExitCode = 126 }; var service = Service(FakeFs.Standard(), runner); var plan = service.PlanDelete(["/home/u/a"]).Plan!; Assert.IsTrue((await service.RunAsync(plan)).WasDismissed); - runner.ExitCode = 127; Assert.IsFalse((await service.RunAsync(plan)).WasDismissed); + runner.ExitCode = 127; Assert.IsTrue((await service.RunAsync(plan)).WasDismissed); + runner.ExitCode = 2; Assert.IsFalse((await service.RunAsync(plan)).WasDismissed); } [TestMethod] diff --git a/tests/Files.Platform.Tests/SystemIntegration/HelperAuthorizationTests.cs b/tests/Files.Platform.Tests/SystemIntegration/HelperAuthorizationTests.cs new file mode 100644 index 000000000000..1ed9d6a3886c --- /dev/null +++ b/tests/Files.Platform.Tests/SystemIntegration/HelperAuthorizationTests.cs @@ -0,0 +1,71 @@ +// Copyright (c) Files Community +// Licensed under the MIT License. + +extern alias ElevationHelper; + +using Files.Platform.Abstractions.Elevation; +using Files.Platform.Linux.Elevation; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System; +using System.IO; +using System.Linq; +using System.Text; +using System.Security.Cryptography; +using HelperAuthorizationVerifier = ElevationHelper::Files.Platform.Linux.Elevation.HelperAuthorization; +using HelperEntryPoint = ElevationHelper::Files.Platform.Linux.ElevationHelper.HelperEntryPoint; + +namespace Files.Platform.Tests.SystemIntegration +{ + [TestClass] + public sealed class HelperAuthorizationTests + { + [TestMethod] + public void PromptArgumentsBindTheExactUtf8PayloadAndEverySummaryField() + { + var request = new HelperRequest(1, "copy", ["/home/u/café", "/home/u/b"], "/etc/sudoers.d"); + var json = ElevationHelperProtocol.Serialize(request); + var data = Encoding.UTF8.GetBytes(json); + var args = HelperAuthorization.Arguments(json); + Assert.AreEqual(Convert.ToHexString(SHA256.HashData(data)), args[1]); + StringAssert.Contains(args[0], "copy; sources=2; first source=\"/home/u/café\"; target=\"/etc/sudoers.d\""); + Assert.AreEqual(request.Target, HelperAuthorizationVerifier.Verify(data, args).Target); + foreach (var changed in new[] { json + " ", json.Replace("/home/u/b", "/root/secret"), json.Replace("sudoers.d", "other") }) + Assert.ThrowsExactly(() => HelperAuthorizationVerifier.Verify(Encoding.UTF8.GetBytes(changed), args)); + foreach (var changed in new[] { args[0].Replace("copy", "move"), args[0].Replace("sources=2", "sources=1"), + args[0].Replace("café", "other"), args[0].Replace("sudoers.d", "other") }) + Assert.ThrowsExactly(() => HelperAuthorizationVerifier.Verify(data, [changed, args[1]])); + Assert.ThrowsExactly(() => HelperAuthorizationVerifier.Verify(data, [])); + Assert.ThrowsExactly(() => HelperAuthorizationVerifier.Verify(data, [args[0], "0"])); + } + + [TestMethod] + public void SummaryAndPreviewKeepUnicodeReadableAndEscapePromptSpoofingCharacters() + { + var request = new HelperRequest(1, "delete", ["/home/u/日本語-café\n\u202E\u2066\u200B"], null); + var json = ElevationHelperProtocol.Serialize(request); + var summary = HelperAuthorization.Summary(request); + StringAssert.Contains(summary, "日本語-café"); + Assert.IsFalse(summary.Any(c => c is '\n' or '\r' or '\u202E' or '\u2066' or '\u200B')); + var preview = ElevationPlanPreview.Format(new(ElevatedOperation.Delete, request.Sources, null, + [new(ElevationHelperProtocol.HelperPath, [json])]))!; + StringAssert.Contains(preview, "日本語-café"); + StringAssert.Contains(preview, "\\u202E"); + StringAssert.Contains(json, "\\u65E5"); // Display encoding must never change the wire encoding. + Assert.AreEqual(request.Sources[0], HelperAuthorizationVerifier.Verify(Encoding.UTF8.GetBytes(json), HelperAuthorization.Arguments(json)).Sources[0]); + } + + [TestMethod] + public void InvalidUtf8AndUnexpectedTopLevelExceptionsFailClosed() + { + byte[] invalid = [0xFF]; + Assert.ThrowsExactly(() => HelperAuthorizationVerifier.Verify(invalid, ["delete", HelperAuthorization.Digest(invalid)])); + foreach (var exception in new Exception[] { new NullReferenceException("secret"), new InvalidOperationException("secret"), new DllNotFoundException("secret") }) + { + using var output = new StringWriter(); + Assert.AreEqual(1, HelperEntryPoint.Run(() => throw exception, output)); + Assert.AreEqual("Privileged operation failed.", ElevationHelperProtocol.ParseResponse(output.ToString()).Error); + Assert.IsFalse(output.ToString().Contains("secret")); + } + } + } +} From 1b7ff065a2dccdf91f2225c27f6c1aa500c5a2db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kov=C3=A1cs=20B=C3=A1lint=20Hunor?= Date: Tue, 6 Oct 2026 10:05:24 +0300 Subject: [PATCH 3/4] Never widen access when handing root copies to the caller - Mask handed-over folders by the source mode & 0755 and files by & 0644; keep group bits only for the caller's own group and limit other bits to what a foreign source group had - Clear group/other bits when the source has an access ACL or sat below a directory others could not search - Refuse multiply-linked files before handover, since a link can alias data outside the approved tree - Return name-free error text (no nested entry names or raw name bytes) - Duplicate listing descriptors with F_DUPFD_CLOEXEC - Document the handover rules and cover them with tests Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LeKXJc3DnK3PzAqwgA72NY --- docs/linux-port/threat-model-elevation.md | 6 +- .../HelperEngine.cs | 57 ++++++++++--- .../Native/ElevationNative.cs | 25 ++++-- .../SystemIntegration/ElevationHelperTests.cs | 79 ++++++++++++++++++- 4 files changed, 142 insertions(+), 25 deletions(-) diff --git a/docs/linux-port/threat-model-elevation.md b/docs/linux-port/threat-model-elevation.md index 387173d35846..67bc0903528e 100644 --- a/docs/linux-port/threat-model-elevation.md +++ b/docs/linux-port/threat-model-elevation.md @@ -30,10 +30,10 @@ The complete source trees and destination directory are pinned before mutation. - Delete uses unlinkat relative to pinned parents, recursing only into pinned source directories. Identity and tree membership are checked again before deleting; absence is confirmed with a descriptor-relative statx lookup. Only ENOENT proves absence; permission/stat failures never do. - Rename uses renameat2(RENAME_NOREPLACE) with the pinned parent and plain leaf names. Collisions, including dangling symlinks, fail atomically. No unsafe rename fallback exists. The renamed identity and source absence are checked inside the helper. -- Copy opens source files without following links, creates destination files with O_CREAT|O_EXCL and directories with mkdirat (existing names fail), and transfers bytes through descriptors. Copies start root-owned mode 0600, directories mode 0700. When the pinned destination directory is owned by PKEXEC_UID, only after byte/tree verification and before any move removes its originals, the helper applies fchown through each pinned output fd to PKEXEC_UID and its primary gid from getpwuid_r. Files get source permissions masked by 0644 (no execute/setuid/setgid bits), directories 0755; metadata is rechecked and fsynced. Otherwise root ownership and 0600/0700 modes remain. No original ownership, timestamps, xattrs or hardlink relationships are preserved. Setuid/setgid source trees are refused. Size and SHA-256 of source and the helper's own created output are verified; completed output and its destination parent are fsynced and its directory membership and metadata are checked again. +- Copy opens source files without following links, creates destination files with O_CREAT|O_EXCL and directories with mkdirat (existing names fail), and transfers bytes through descriptors. Copies start root-owned mode 0600, directories mode 0700. When the pinned destination directory is owned by PKEXEC_UID, only after byte/tree verification and before any move removes its originals, the helper applies fchown through each pinned output fd to PKEXEC_UID and its primary gid from getpwuid_r. Handover never widens access: files get at most the source mode & 0644 and directories the source mode & 0755 (a 0700 folder stays 0700). Group bits survive only when the source group equals the caller's primary group; otherwise they are cleared and the other bits are also limited to what the source group had, because former group members become "others". Group and other bits are cleared entirely when the source carries a POSIX access ACL (its mode group bits are only a mask) or when any directory from `/` to the source's parent lacks search permission for others, since nobody but the owner could reach the source there. This is deliberately narrower than strictly necessary for group-only paths. Multiply-linked files are refused before any output is created when handing over, because another link may alias data outside the approved tree. Metadata is rechecked and fsynced. Otherwise root ownership and 0600/0700 modes remain. Until handover, outputs are root-owned 0600/0700 even inside a caller-owned destination (and an inherited default ACL gets an empty mask), so the caller cannot read partial or unverified copies; a failed handover leaves a root-owned 0700 parent around any already handed-over children. No original ownership, timestamps, xattrs (including file capabilities and security labels) or hardlink relationships are preserved. Setuid/setgid source trees are refused. Size and SHA-256 of source and the helper's own created output are verified; completed output and its destination parent are fsynced and its directory membership and metadata are checked again. - Move follows the same verified-copy path in the same authenticated helper process. It removes only the pinned originals, checking identity and file metadata again before removal. A skipped/colliding/failed/unverified copy never triggers source deletion. Each item reports its own result; failure does not erase another item's verified success. -Partial outputs remain in place and are reported as failures. The helper never performs recursive cleanup through an untrusted destination name. Partial deletion/move can leave a partially changed source tree; surviving originals remain, and the UI receives failure rather than success. +Partial outputs remain in place and are reported as failures. Per-item errors carry only a fixed helper message, a generic not-found/access-denied text or an errno; they never echo entry names or raw name bytes from inside a tree, so a failure cannot list nested root-only names the caller was not shown. Every helper descriptor, including directory-listing duplicates (F_DUPFD_CLOEXEC), is close-on-exec, and the helper starts no child processes. The helper never performs recursive cleanup through an untrusted destination name. Partial deletion/move can leave a partially changed source tree; surviving originals remain, and the UI receives failure rather than success. ## Races and residual limits @@ -47,7 +47,7 @@ Root authentication intentionally permits destructive actions on root-owned data AUR source packages AOT-publish the helper and install it and the policy at the fixed paths. Binary AUR packages use the helper in the release tarball. `publish.sh` includes it in `elevation-helper/`; AppImage/Flatpak explicitly remove it and set FILES_DISABLE_ROOT_ACTIONS=1 and add a .root-actions-disabled marker (the app also detects their runtime). `install-local.sh --install-root-helper` is explicit opt-in to a separate system installation through sudo; a default user-local install cannot supply a trusted helper. Policy/helper installation is never performed by the running app. The opt-in installer is equivalent to `sudo make install`: it executes administrator-trusted installer code and installs administrator-trusted build output, not a security boundary against a malicious checkout or build. It embeds the fixed policy, resolves destdir before checking for live `/`, and copies the helper through a no-follow regular-file descriptor to a private installer-owned temporary directory (root-owned for live installs) before checking its SHA-256 and ELF header and installing from that snapshot. The user-side wrapper supplies the pre-sudo helper digest; no helper is executed during installation. Non-root staging uses the same snapshot checks and never invokes sudo. -Non-root tests run HelperEngine directly against private temp trees, substituting the current uid for the elevated identity and the observed root-directory owner for root in uid-mapped sandboxes. Hooks exist only in that in-process API; stdin cannot enable them. Tests also cover request-byte/summary substitution, prompt sanitization and Unicode preview, protected directories, EPERM cancellation with an unprivileged fake process, caller/root destination modes, descriptor-based ownership during leaf replacement, generic exception handling and non-root installer staging/tampering/root aliases. Tests cover both openat2 and forced fallback, authentication-time and post-pin symlink swaps, source leaf races, nested copy/move, no-replace collisions, corrupt/modified copies, changed originals and partial batches. App tests cover protocol failures, forged plans, helper-only verification and packaging/trust gates. Real polkit authentication, root-only directories, mount races and distro package installation require a separate administrator review; automated tests never invoke pkexec or use the real display. +Non-root tests run HelperEngine directly against private temp trees, substituting the current uid for the elevated identity and the observed root-directory owner for root in uid-mapped sandboxes. Hooks exist only in that in-process API; stdin cannot enable them. Tests also cover request-byte/summary substitution, prompt sanitization and Unicode preview, protected directories, EPERM cancellation with an unprivileged fake process, caller/root destination modes, permission masking for every class (private folders, foreign groups, ACLs, unreachable sources), hard-link refusal, name-free error text, descriptor-based ownership during leaf replacement, generic exception handling and non-root installer staging/tampering/root aliases. Tests cover both openat2 and forced fallback, authentication-time and post-pin symlink swaps, source leaf races, nested copy/move, no-replace collisions, corrupt/modified copies, changed originals and partial batches. App tests cover protocol failures, forged plans, helper-only verification and packaging/trust gates. Real polkit authentication, root-only directories, mount races and distro package installation require a separate administrator review; automated tests never invoke pkexec or use the real display. ## Not implemented diff --git a/src/Files.Platform.Linux.ElevationHelper/HelperEngine.cs b/src/Files.Platform.Linux.ElevationHelper/HelperEngine.cs index 811a160b92c5..18b32441718a 100644 --- a/src/Files.Platform.Linux.ElevationHelper/HelperEngine.cs +++ b/src/Files.Platform.Linux.ElevationHelper/HelperEngine.cs @@ -44,7 +44,7 @@ public HelperResponse Execute(HelperRequest request) visited = 0; Descriptor? destination = null; uint? destinationGroup = null; - var opened = new List<(string Path, Descriptor? Parent, Node? Source, string Error)>(); + var opened = new List<(string Path, Descriptor? Parent, Node? Source, bool OthersReach, string Error)>(); try { if (request.Operation is "copy" or "move") @@ -59,17 +59,18 @@ public HelperResponse Execute(HelperRequest request) Node? source = null; try { - parent = OpenDirectoryPath(Path.GetDirectoryName(path)!); + parent = OpenDirectoryPath(Path.GetDirectoryName(path)!, out var othersReach); hook?.Invoke("after-parent-open", path); source = Snapshot(parent.Fd, Path.GetFileName(path), Inspect(parent.Fd)!.Value.Mount, 0, request.Operation is "copy" or "move"); if (destination is not null) RefuseContainedDestination(source, destination.Fd); - opened.Add((path, parent, source, "")); + if (destinationGroup is not null) RefuseLinkedFiles(source); + opened.Add((path, parent, source, othersReach, "")); } catch (Exception ex) when (IsOperationError(ex)) { source?.Dispose(); parent?.Dispose(); - opened.Add((path, null, null, Error(ex))); + opened.Add((path, null, null, false, Error(ex))); } } hook?.Invoke("plan-pinned", ""); @@ -113,7 +114,7 @@ public HelperResponse Execute(HelperRequest request) if (destinationGroup is { } group) { ValidateTree(destination.Fd, copy, true); - AssignCopyOwnership(source, copy, group); + AssignCopyOwnership(source, copy, group, item.OthersReach); ValidateTree(destination.Fd, copy, true); SyncParent(destination.Fd); } @@ -147,7 +148,10 @@ public HelperResponse Execute(HelperRequest request) } } - private Descriptor OpenDirectoryPath(string path) + private Descriptor OpenDirectoryPath(string path) => OpenDirectoryPath(path, out _); + + // othersReach: every directory on the path grants search to others, so non-members could reach its entries. + private Descriptor OpenDirectoryPath(string path, out bool othersReach) { // The filesystem root is the only absolute native open. All subsequent names are single components. var rootFd = PosixNative.OpenAt(PosixNative.AtFdCwd, "/", PathOnly | PosixNative.ODirectory | PosixNative.ONofollow, out var errno); @@ -155,13 +159,16 @@ private Descriptor OpenDirectoryPath(string path) var current = new Descriptor(rootFd); try { - TrustDirectory(Inspect(current.Fd)!.Value); + var stamp = Inspect(current.Fd)!.Value; + TrustDirectory(stamp); + othersReach = (stamp.Mode & 0x1) != 0; foreach (var name in path.Split('/', StringSplitOptions.RemoveEmptyEntries)) { hook?.Invoke("before-component-open", name); var next = new Descriptor(Open(current.Fd, name, PathOnly | PosixNative.ODirectory, forceFallback)); - try { TrustDirectory(Inspect(next.Fd)!.Value); } + try { stamp = Inspect(next.Fd)!.Value; TrustDirectory(stamp); } catch { next.Dispose(); throw; } + othersReach &= (stamp.Mode & 0x1) != 0; current.Dispose(); current = next; } return current; @@ -207,6 +214,13 @@ private static void RefuseContainedDestination(Node source, int targetFd) static bool Contains(Node node, Stamp target) => node.Stamp.SameObject(target) || node.Children.Any(child => Contains(child, target)); } + // A hard link can alias data outside the approved tree; never hand such content to the caller. + private static void RefuseLinkedFiles(Node node) + { + if (!node.Stamp.Directory && node.Stamp.Links != 1) throw new IOException("Hard-linked files cannot be handed to the caller."); + foreach (var child in node.Children) RefuseLinkedFiles(child); + } + private static void RequireSame(Stamp expected, Stamp actual) { if (!expected.SameObject(actual)) throw new IOException("An entry changed identity."); @@ -269,11 +283,11 @@ private Node Copy(Node source, int parentFd) catch { copy.Dispose(); throw; } } - private void AssignCopyOwnership(Node source, Node copy, uint group) + private void AssignCopyOwnership(Node source, Node copy, uint group, bool othersReach) { - for (var i = 0; i < source.Children.Count; i++) AssignCopyOwnership(source.Children[i], copy.Children[i], group); + for (var i = 0; i < source.Children.Count; i++) AssignCopyOwnership(source.Children[i], copy.Children[i], group, othersReach); hook?.Invoke("before-output-ownership", copy.Name); - var mode = source.Stamp.Directory ? 0x1EDU : source.Stamp.Mode & 0x1A4U; // 0755 / source masked by 0644 + var mode = HandoverMode(source.Stamp.Mode, source.Stamp.Directory, source.Stamp.Group == group, othersReach, HasAccessAcl(source.Descriptor.Fd)); SetOwnership(copy.Descriptor.Fd, callerUid, group, mode); Sync(copy.Descriptor.Fd); var current = Inspect(copy.Descriptor.Fd)!.Value; @@ -298,6 +312,17 @@ private static void VerifyCopy(Node source, Node copy) } } + /// Permissions for a copy handed to the caller: never wider for anyone than the source granted. + public static uint HandoverMode(uint sourceMode, bool directory, bool sameGroup, bool othersReach, bool hasAccessAcl) + { + var mode = sourceMode & (directory ? 0x1EDU : 0x1A4U); // at most 0755 / 0644; no setid, sticky or group/other write + // ACL mode bits are a mask, not real grants; group/other access survives only where others could reach the source. + if (hasAccessAcl || !othersReach) return mode & 0x1C0U; + if (sameGroup) return mode; + // Former group members become "others" in the copy, so others keep only what both classes had. + return (mode & 0x1C0U) | (mode & (mode >> 3) & 0x7U); + } + private static byte[] Hash(Node node) { using var handle = new SafeFileHandle((nint)node.Descriptor.Fd, ownsHandle: false); @@ -336,6 +361,14 @@ private static void RequireAbsent(int fd, string name) } private static bool IsOperationError(Exception ex) => ex is IOException or UnauthorizedAccessException or System.Text.DecoderFallbackException; - private static string Error(Exception ex) => ex.Message.Length <= 512 ? ex.Message : ex.Message[..512]; + // Errno messages embed entry names the caller may never have been shown; report only the failure class. + private static string Error(Exception ex) => ex switch + { + FileNotFoundException or DirectoryNotFoundException => "An item does not exist.", + UnauthorizedAccessException => "Access is denied.", + IOException { HResult: > 0 and < 4096 } => $"The operation failed with errno {ex.HResult}.", + IOException when ex.GetType() == typeof(IOException) && ex.Message.Length <= 512 => ex.Message, // helper's fixed texts + _ => "The operation failed.", + }; } } diff --git a/src/Files.Platform.Linux/Native/ElevationNative.cs b/src/Files.Platform.Linux/Native/ElevationNative.cs index eefb5b7c4bb2..1ef7d612adc8 100644 --- a/src/Files.Platform.Linux/Native/ElevationNative.cs +++ b/src/Files.Platform.Linux/Native/ElevationNative.cs @@ -45,6 +45,13 @@ private struct Passwd [LibraryImport("libc", EntryPoint = "fchmod", SetLastError = true)] private static partial int Fchmod(int fd, uint mode); + [LibraryImport("libc", EntryPoint = "fgetxattr", StringMarshalling = StringMarshalling.Utf8, SetLastError = true)] + private static partial nint FGetXattr(int fd, string name, byte* value, nuint size); + + // Mode group bits are only the ACL mask when an access ACL exists; unknown errors count as an ACL. + internal static bool HasAccessAcl(int fd) + => FGetXattr(fd, "system.posix_acl_access", null, 0) >= 0 || Marshal.GetLastPInvokeError() is not (61 or 95); // ENODATA, EOPNOTSUPP + internal static uint PrimaryGroup(uint uid) { for (var length = 16384; length <= 1048576; length *= 2) @@ -69,7 +76,7 @@ internal static void SetOwnership(int fd, uint uid, uint gid, uint mode) } internal readonly record struct Stamp(uint Mode, uint Owner, uint Group, ulong Inode, ulong Size, ulong Mount, uint Major, uint Minor, - long ModifiedSeconds, uint ModifiedNanos, long ChangedSeconds, uint ChangedNanos) + long ModifiedSeconds, uint ModifiedNanos, long ChangedSeconds, uint ChangedNanos, uint Links) { public bool Directory => (Mode & 0xF000) == 0x4000; public bool Regular => (Mode & 0xF000) == 0x8000; @@ -82,7 +89,7 @@ public bool SameObject(Stamp other) => Inode == other.Inode && Major == other.Ma internal static Stamp? Inspect(int fd, string name = "", bool allowMissing = false) { - const uint required = 0x1 | 0x2 | 0x8 | 0x10 | 0x40 | 0x80 | 0x100 | 0x200 | 0x1000; + const uint required = 0x1 | 0x2 | 0x4 | 0x8 | 0x10 | 0x40 | 0x80 | 0x100 | 0x200 | 0x1000; var buffer = new byte[256]; fixed (byte* pointer = buffer) { @@ -96,11 +103,12 @@ public bool SameObject(Stamp other) => Inode == other.Inode && Major == other.Ma if ((BitConverter.ToUInt32(buffer, 0) & required) != required) throw new IOException("Required statx identity fields unavailable."); return new Stamp(BitConverter.ToUInt16(buffer, 28), BitConverter.ToUInt32(buffer, 20), BitConverter.ToUInt32(buffer, 24), BitConverter.ToUInt64(buffer, 32), BitConverter.ToUInt64(buffer, 40), BitConverter.ToUInt64(buffer, 144), BitConverter.ToUInt32(buffer, 136), BitConverter.ToUInt32(buffer, 140), - BitConverter.ToInt64(buffer, 112), BitConverter.ToUInt32(buffer, 120), BitConverter.ToInt64(buffer, 96), BitConverter.ToUInt32(buffer, 104)); + BitConverter.ToInt64(buffer, 112), BitConverter.ToUInt32(buffer, 120), BitConverter.ToInt64(buffer, 96), BitConverter.ToUInt32(buffer, 104), + BitConverter.ToUInt32(buffer, 16)); } - [LibraryImport("libc", EntryPoint = "dup", SetLastError = true)] - private static partial int Duplicate(int fd); + [LibraryImport("libc", EntryPoint = "fcntl", SetLastError = true)] + private static partial int Fcntl(int fd, int command, int argument); [LibraryImport("libc", EntryPoint = "fdopendir", SetLastError = true)] private static partial nint OpenDirectory(int fd); [LibraryImport("libc", EntryPoint = "readdir", SetLastError = true)] @@ -112,7 +120,7 @@ public bool SameObject(Stamp other) => Inode == other.Inode && Major == other.Ma internal static System.Collections.Generic.List Names(int fd) { - var duplicate = Duplicate(fd); + var duplicate = Fcntl(fd, 1030, 0); // F_DUPFD_CLOEXEC: no descriptor survives an exec if (duplicate < 0) throw new IOException("Cannot duplicate directory descriptor."); var directory = OpenDirectory(duplicate); if (directory == 0) { PosixNative.Close(duplicate); throw new IOException("Cannot enumerate directory."); } @@ -131,7 +139,10 @@ internal static System.Collections.Generic.List Names(int fd) var length = 0; while (length < 256 && entry[19 + length] != 0) length++; if (length == 256) throw new IOException("Invalid directory entry."); - var name = new System.Text.UTF8Encoding(false, true).GetString(new ReadOnlySpan(entry + 19, length)); + string name; + // The decoder message would echo raw name bytes back to the caller. + try { name = new System.Text.UTF8Encoding(false, true).GetString(new ReadOnlySpan(entry + 19, length)); } + catch (System.Text.DecoderFallbackException) { throw new IOException("Names that are not valid UTF-8 are refused."); } if (name is "." or "..") continue; if (name.Length == 0 || name.Contains('/')) throw new IOException("Invalid directory entry."); if (names.Count >= 4096) throw new IOException("Tree exceeds entry limit."); diff --git a/tests/Files.Platform.Tests/SystemIntegration/ElevationHelperTests.cs b/tests/Files.Platform.Tests/SystemIntegration/ElevationHelperTests.cs index 9fec5156ed92..31352e77d429 100644 --- a/tests/Files.Platform.Tests/SystemIntegration/ElevationHelperTests.cs +++ b/tests/Files.Platform.Tests/SystemIntegration/ElevationHelperTests.cs @@ -61,7 +61,8 @@ public void CopiesAndMovesNestedTreesThroughDescriptors(bool fallback) Assert.IsTrue(copied.Items.Single().Succeeded, copied.Items.Single().Error); Assert.AreEqual("confirmed bytes", File.ReadAllText(Path.Combine(target, "tree", "a"))); Assert.IsTrue(Directory.Exists(folder)); - Assert.AreEqual((UnixFileMode)0x1A4, File.GetUnixFileMode(Path.Combine(target, "tree", "a"))); + // The 0700 test root hides the source from other users, so the copy stays owner-only. + Assert.AreEqual((UnixFileMode)0x180, File.GetUnixFileMode(Path.Combine(target, "tree", "a"))); var second = Directory.CreateDirectory(Path.Combine(root, "second")).FullName; var moved = Run("move", [folder], second, fallback: fallback); Assert.IsTrue(moved.Items.Single().Succeeded, moved.Items.Single().Error); @@ -256,7 +257,7 @@ public void CallerOwnedDestinationGetsUsableVerifiedCopies(string operation, boo Assert.IsTrue(result.Items.Single().Succeeded, result.Items.Single().Error); Assert.AreEqual(2, stages); Assert.AreEqual((UnixFileMode)0x100, File.GetUnixFileMode(Path.Combine(target, "tree", "read-only"))); - Assert.AreEqual((UnixFileMode)0x1ED, File.GetUnixFileMode(Path.Combine(target, "tree"))); + Assert.AreEqual((UnixFileMode)0x1C0, File.GetUnixFileMode(Path.Combine(target, "tree"))); Assert.IsTrue(new StatxFileOwnershipInspector().TryGetInfo(Path.Combine(target, "tree", "read-only"), out var info)); Assert.AreEqual(ProcessIdentityNative.CurrentUserId, info.OwnerUserId); Assert.AreEqual("confirmed bytes", File.ReadAllText(Path.Combine(target, "tree", "read-only"))); @@ -298,7 +299,79 @@ public void OwnershipIsAppliedToVerifiedDescriptorDespiteLeafReplacement() Assert.IsFalse(result.Items.Single().Succeeded); Assert.AreEqual("unrelated", File.ReadAllText(output)); Assert.AreEqual((UnixFileMode)0x180, File.GetUnixFileMode(output)); - Assert.AreEqual((UnixFileMode)0x1A4, File.GetUnixFileMode(displaced)); + Assert.AreEqual((UnixFileMode)0x180, File.GetUnixFileMode(displaced)); + } + + [TestMethod] + public void HandoverNeverWidensSourcePermissions() + { + // A shared parent lets other users reach the source, so same-group group/other bits may survive. + var shared = Path.Combine(Path.GetTempPath(), "files-elevation-shared-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(shared, (UnixFileMode)0x1ED); + try + { + File.SetUnixFileMode(shared, (UnixFileMode)0x1ED); + var origin = Directory.CreateDirectory(Path.Combine(shared, "origin")).FullName; + File.SetUnixFileMode(origin, (UnixFileMode)0x1ED); + var hidden = Directory.CreateDirectory(Path.Combine(origin, "hidden")).FullName; + File.SetUnixFileMode(hidden, (UnixFileMode)0x1C0); + var key = FileAt(hidden, "key"); File.SetUnixFileMode(key, (UnixFileMode)0x1A4); + var team = FileAt(origin, "team"); File.SetUnixFileMode(team, (UnixFileMode)0x1A0); + var result = Run("copy", [hidden, team], target); + Assert.IsTrue(result.Items.All(item => item.Succeeded), string.Join("; ", result.Items.Select(item => item.Error))); + Assert.AreEqual((UnixFileMode)0x1C0, File.GetUnixFileMode(Path.Combine(target, "hidden")), "A 0700 folder must not become 0755."); + Assert.AreEqual((UnixFileMode)0x1A4, File.GetUnixFileMode(Path.Combine(target, "hidden", "key"))); + Assert.AreEqual((UnixFileMode)0x1A0, File.GetUnixFileMode(Path.Combine(target, "team"))); + } + finally { Directory.Delete(shared, true); } + } + + [TestMethod] + [DataRow(0x1C0U, true, true, true, false, 0x1C0U)] // private folder stays private + [DataRow(0x3FFU, true, true, true, false, 0x1EDU)] // sticky and group/other write are dropped + [DataRow(0x1A0U, false, false, true, false, 0x180U)] // 0640 root:shadow never reaches the caller's group + [DataRow(0x1A4U, false, false, true, false, 0x184U)] // others keep read; the foreign group's read is not transferred + [DataRow(0x1A4U, false, true, false, false, 0x180U)] // the source lived where others could not reach it + [DataRow(0x1A4U, false, true, true, true, 0x180U)] // ACL masks are not real group grants + [DataRow(0x1C4U, false, false, true, false, 0x180U)] // others never gain what the source group lacked + [DataRow(0x1A4U, false, true, true, false, 0x1A4U)] + [DataRow(0x9EDU, false, true, true, false, 0x1A4U)] + public void HandoverModeMasksEveryClass(uint source, bool directory, bool sameGroup, bool othersReach, bool acl, uint expected) + => Assert.AreEqual(expected, HelperEngine.HandoverMode(source | (directory ? 0x4000U : 0x8000U), directory, sameGroup, othersReach, acl)); + + [TestMethod] + public void HardLinkedFilesAreNotHandedToCaller() + { + var folder = Directory.CreateDirectory(Path.Combine(source, "tree")).FullName; + var file = FileAt(folder, "a"); + using (var link = System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo("/usr/bin/ln", ["--", file, Path.Combine(root, "alias")]) { UseShellExecute = false })!) + { + link.WaitForExit(); + Assert.AreEqual(0, link.ExitCode); + } + var result = Run("move", [folder], target); + Assert.IsFalse(result.Items.Single().Succeeded); + Assert.IsFalse(Directory.Exists(Path.Combine(target, "tree")), "The refusal happens before any output is created."); + Assert.IsTrue(File.Exists(file)); + } + + [TestMethod] + public void ErrorsDoNotNameNestedEntries() + { + var folder = Directory.CreateDirectory(Path.Combine(source, "tree")).FullName; + FileAt(folder, "nested-secret-name"); + var result = Run("copy", [folder], target, (stage, name) => + { + if (stage == "before-output-create" && name == "nested-secret-name") FileAt(Path.Combine(target, "tree"), name, "collision"); + }); + Assert.IsFalse(result.Items.Single().Succeeded); + StringAssert.Contains(result.Items.Single().Error, "errno 17"); + Assert.IsFalse(result.Items.Single().Error.Contains("nested-secret-name"), result.Items.Single().Error); + result = Run("delete", [folder], hook: (stage, name) => + { + if (stage == "before-entry-open" && name == "nested-secret-name") throw new UnauthorizedAccessException($"Access to '{name}' is denied."); + }); + Assert.AreEqual("Access is denied.", result.Items.Single().Error); } [TestMethod] From be9711594bb9c24361f6b2b228d3d29117c306cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kov=C3=A1cs=20B=C3=A1lint=20Hunor?= Date: Tue, 6 Oct 2026 10:14:25 +0300 Subject: [PATCH 4/4] Drop inherited ACLs at handover and check ancestor ACLs - Remove the output's inherited access ACL before fchmod so the new mask cannot activate default-ACL named entries, and verify none remains - Treat a source as reachable only when every ancestor grants owner, group and other search and has no access ACL, read through a verified read-only reopen of the pinned descriptor - Pass fcntl's third argument as nint Co-Authored-By: Claude Opus 5.5 Reviewed-by: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LeKXJc3DnK3PzAqwgA72NY --- docs/linux-port/threat-model-elevation.md | 4 +- .../HelperEngine.cs | 24 ++++++-- .../Native/ElevationNative.cs | 9 ++- .../SystemIntegration/ElevationHelperTests.cs | 55 +++++++++++++++++++ 4 files changed, 83 insertions(+), 9 deletions(-) diff --git a/docs/linux-port/threat-model-elevation.md b/docs/linux-port/threat-model-elevation.md index 67bc0903528e..75e2362d0584 100644 --- a/docs/linux-port/threat-model-elevation.md +++ b/docs/linux-port/threat-model-elevation.md @@ -30,7 +30,7 @@ The complete source trees and destination directory are pinned before mutation. - Delete uses unlinkat relative to pinned parents, recursing only into pinned source directories. Identity and tree membership are checked again before deleting; absence is confirmed with a descriptor-relative statx lookup. Only ENOENT proves absence; permission/stat failures never do. - Rename uses renameat2(RENAME_NOREPLACE) with the pinned parent and plain leaf names. Collisions, including dangling symlinks, fail atomically. No unsafe rename fallback exists. The renamed identity and source absence are checked inside the helper. -- Copy opens source files without following links, creates destination files with O_CREAT|O_EXCL and directories with mkdirat (existing names fail), and transfers bytes through descriptors. Copies start root-owned mode 0600, directories mode 0700. When the pinned destination directory is owned by PKEXEC_UID, only after byte/tree verification and before any move removes its originals, the helper applies fchown through each pinned output fd to PKEXEC_UID and its primary gid from getpwuid_r. Handover never widens access: files get at most the source mode & 0644 and directories the source mode & 0755 (a 0700 folder stays 0700). Group bits survive only when the source group equals the caller's primary group; otherwise they are cleared and the other bits are also limited to what the source group had, because former group members become "others". Group and other bits are cleared entirely when the source carries a POSIX access ACL (its mode group bits are only a mask) or when any directory from `/` to the source's parent lacks search permission for others, since nobody but the owner could reach the source there. This is deliberately narrower than strictly necessary for group-only paths. Multiply-linked files are refused before any output is created when handing over, because another link may alias data outside the approved tree. Metadata is rechecked and fsynced. Otherwise root ownership and 0600/0700 modes remain. Until handover, outputs are root-owned 0600/0700 even inside a caller-owned destination (and an inherited default ACL gets an empty mask), so the caller cannot read partial or unverified copies; a failed handover leaves a root-owned 0700 parent around any already handed-over children. No original ownership, timestamps, xattrs (including file capabilities and security labels) or hardlink relationships are preserved. Setuid/setgid source trees are refused. Size and SHA-256 of source and the helper's own created output are verified; completed output and its destination parent are fsynced and its directory membership and metadata are checked again. +- Copy opens source files without following links, creates destination files with O_CREAT|O_EXCL and directories with mkdirat (existing names fail), and transfers bytes through descriptors. Copies start root-owned mode 0600, directories mode 0700. When the pinned destination directory is owned by PKEXEC_UID, only after byte/tree verification and before any move removes its originals, the helper applies fchown through each pinned output fd to PKEXEC_UID and its primary gid from getpwuid_r. Handover never widens access: files get at most the source mode & 0644 and directories the source mode & 0755 (a 0700 folder stays 0700). Group bits survive only when the source group equals the caller's primary group; otherwise they are cleared and the other bits are also limited to what the source group had, because former group members become "others". Group and other bits are cleared entirely when the source carries a POSIX access ACL (its mode group bits are only a mask) or when any directory from `/` to the source's parent does not let every non-root user search it: that requires owner, group and other search bits (a matching class never falls through to "other") and no access ACL. Ancestor ACLs are read through a read-only reopen of each pinned O_PATH descriptor, checked against its pinned identity; a failed reopen or ACL read counts as unreachable. This is deliberately narrower than strictly necessary for group-only paths. Multiply-linked files are refused before any output is created when handing over, because another link may alias data outside the approved tree. Metadata is rechecked and fsynced. Otherwise root ownership and 0600/0700 modes remain. Until handover, outputs are root-owned 0600/0700 even inside a caller-owned destination (and an inherited default ACL gets an empty mask), so the caller cannot read partial or unverified copies. At handover the inherited access ACL is removed before fchmod (ENODATA/EOPNOTSUPP mean none) and its absence is verified, so the new mode cannot activate inherited named entries; a handed-over folder keeps its inherited default ACL, which only shapes files the caller later creates; a failed handover leaves a root-owned 0700 parent around any already handed-over children. No original ownership, timestamps, xattrs (including file capabilities and security labels) or hardlink relationships are preserved. Setuid/setgid source trees are refused. Size and SHA-256 of source and the helper's own created output are verified; completed output and its destination parent are fsynced and its directory membership and metadata are checked again. - Move follows the same verified-copy path in the same authenticated helper process. It removes only the pinned originals, checking identity and file metadata again before removal. A skipped/colliding/failed/unverified copy never triggers source deletion. Each item reports its own result; failure does not erase another item's verified success. Partial outputs remain in place and are reported as failures. Per-item errors carry only a fixed helper message, a generic not-found/access-denied text or an errno; they never echo entry names or raw name bytes from inside a tree, so a failure cannot list nested root-only names the caller was not shown. Every helper descriptor, including directory-listing duplicates (F_DUPFD_CLOEXEC), is close-on-exec, and the helper starts no child processes. The helper never performs recursive cleanup through an untrusted destination name. Partial deletion/move can leave a partially changed source tree; surviving originals remain, and the UI receives failure rather than success. @@ -47,7 +47,7 @@ Root authentication intentionally permits destructive actions on root-owned data AUR source packages AOT-publish the helper and install it and the policy at the fixed paths. Binary AUR packages use the helper in the release tarball. `publish.sh` includes it in `elevation-helper/`; AppImage/Flatpak explicitly remove it and set FILES_DISABLE_ROOT_ACTIONS=1 and add a .root-actions-disabled marker (the app also detects their runtime). `install-local.sh --install-root-helper` is explicit opt-in to a separate system installation through sudo; a default user-local install cannot supply a trusted helper. Policy/helper installation is never performed by the running app. The opt-in installer is equivalent to `sudo make install`: it executes administrator-trusted installer code and installs administrator-trusted build output, not a security boundary against a malicious checkout or build. It embeds the fixed policy, resolves destdir before checking for live `/`, and copies the helper through a no-follow regular-file descriptor to a private installer-owned temporary directory (root-owned for live installs) before checking its SHA-256 and ELF header and installing from that snapshot. The user-side wrapper supplies the pre-sudo helper digest; no helper is executed during installation. Non-root staging uses the same snapshot checks and never invokes sudo. -Non-root tests run HelperEngine directly against private temp trees, substituting the current uid for the elevated identity and the observed root-directory owner for root in uid-mapped sandboxes. Hooks exist only in that in-process API; stdin cannot enable them. Tests also cover request-byte/summary substitution, prompt sanitization and Unicode preview, protected directories, EPERM cancellation with an unprivileged fake process, caller/root destination modes, permission masking for every class (private folders, foreign groups, ACLs, unreachable sources), hard-link refusal, name-free error text, descriptor-based ownership during leaf replacement, generic exception handling and non-root installer staging/tampering/root aliases. Tests cover both openat2 and forced fallback, authentication-time and post-pin symlink swaps, source leaf races, nested copy/move, no-replace collisions, corrupt/modified copies, changed originals and partial batches. App tests cover protocol failures, forged plans, helper-only verification and packaging/trust gates. Real polkit authentication, root-only directories, mount races and distro package installation require a separate administrator review; automated tests never invoke pkexec or use the real display. +Non-root tests run HelperEngine directly against private temp trees, substituting the current uid for the elevated identity and the observed root-directory owner for root in uid-mapped sandboxes. Hooks exist only in that in-process API; stdin cannot enable them. Tests also cover request-byte/summary substitution, prompt sanitization and Unicode preview, protected directories, EPERM cancellation with an unprivileged fake process, caller/root destination modes, permission masking for every class (private folders, foreign groups, ACLs, unreachable sources and ACL-bearing ancestors), inherited default ACL removal when setfacl and filesystem ACLs are available, hard-link refusal, name-free error text, descriptor-based ownership during leaf replacement, generic exception handling and non-root installer staging/tampering/root aliases. Tests cover both openat2 and forced fallback, authentication-time and post-pin symlink swaps, source leaf races, nested copy/move, no-replace collisions, corrupt/modified copies, changed originals and partial batches. App tests cover protocol failures, forged plans, helper-only verification and packaging/trust gates. Real polkit authentication, root-only directories, mount races and distro package installation require a separate administrator review; automated tests never invoke pkexec or use the real display. ## Not implemented diff --git a/src/Files.Platform.Linux.ElevationHelper/HelperEngine.cs b/src/Files.Platform.Linux.ElevationHelper/HelperEngine.cs index 18b32441718a..ca24bfb05112 100644 --- a/src/Files.Platform.Linux.ElevationHelper/HelperEngine.cs +++ b/src/Files.Platform.Linux.ElevationHelper/HelperEngine.cs @@ -148,10 +148,10 @@ public HelperResponse Execute(HelperRequest request) } } - private Descriptor OpenDirectoryPath(string path) => OpenDirectoryPath(path, out _); + private Descriptor OpenDirectoryPath(string path) => OpenDirectoryPath(path, out _, checkReach: false); - // othersReach: every directory on the path grants search to others, so non-members could reach its entries. - private Descriptor OpenDirectoryPath(string path, out bool othersReach) + // othersReach: every directory on the path lets every non-root user search it, so anyone could reach its entries. + private Descriptor OpenDirectoryPath(string path, out bool othersReach, bool checkReach = true) { // The filesystem root is the only absolute native open. All subsequent names are single components. var rootFd = PosixNative.OpenAt(PosixNative.AtFdCwd, "/", PathOnly | PosixNative.ODirectory | PosixNative.ONofollow, out var errno); @@ -161,14 +161,14 @@ private Descriptor OpenDirectoryPath(string path, out bool othersReach) { var stamp = Inspect(current.Fd)!.Value; TrustDirectory(stamp); - othersReach = (stamp.Mode & 0x1) != 0; + othersReach = checkReach && EveryoneCanSearch(current.Fd, stamp); foreach (var name in path.Split('/', StringSplitOptions.RemoveEmptyEntries)) { hook?.Invoke("before-component-open", name); var next = new Descriptor(Open(current.Fd, name, PathOnly | PosixNative.ODirectory, forceFallback)); try { stamp = Inspect(next.Fd)!.Value; TrustDirectory(stamp); } catch { next.Dispose(); throw; } - othersReach &= (stamp.Mode & 0x1) != 0; + othersReach = othersReach && EveryoneCanSearch(next.Fd, stamp); current.Dispose(); current = next; } return current; @@ -176,6 +176,20 @@ private Descriptor OpenDirectoryPath(string path, out bool othersReach) catch { current.Dispose(); throw; } } + // Owner, group and other search bits must all be set: a matching class never falls through to "other". + // Ancestor ACLs are read through a readable reopen of the pinned O_PATH descriptor; any doubt means unreachable. + private bool EveryoneCanSearch(int pinnedFd, Stamp stamp) + { + if ((stamp.Mode & 0x49) != 0x49) return false; + try + { + using var readable = new Descriptor(Open(pinnedFd, ".", PosixNative.ReadOnlyFlags | PosixNative.ODirectory, forceFallback)); + RequireSame(stamp, Inspect(readable.Fd)!.Value); + return !HasAccessAcl(readable.Fd); + } + catch (Exception ex) when (IsOperationError(ex)) { return false; } + } + private void TrustDirectory(Stamp stamp) { if (!stamp.Directory || (stamp.Owner != trustedRootUid && stamp.Owner != callerUid) diff --git a/src/Files.Platform.Linux/Native/ElevationNative.cs b/src/Files.Platform.Linux/Native/ElevationNative.cs index 1ef7d612adc8..0160edc13922 100644 --- a/src/Files.Platform.Linux/Native/ElevationNative.cs +++ b/src/Files.Platform.Linux/Native/ElevationNative.cs @@ -48,6 +48,9 @@ private struct Passwd [LibraryImport("libc", EntryPoint = "fgetxattr", StringMarshalling = StringMarshalling.Utf8, SetLastError = true)] private static partial nint FGetXattr(int fd, string name, byte* value, nuint size); + [LibraryImport("libc", EntryPoint = "fremovexattr", StringMarshalling = StringMarshalling.Utf8, SetLastError = true)] + private static partial int FRemoveXattr(int fd, string name); + // Mode group bits are only the ACL mask when an access ACL exists; unknown errors count as an ACL. internal static bool HasAccessAcl(int fd) => FGetXattr(fd, "system.posix_acl_access", null, 0) >= 0 || Marshal.GetLastPInvokeError() is not (61 or 95); // ENODATA, EOPNOTSUPP @@ -71,7 +74,9 @@ internal static uint PrimaryGroup(uint uid) internal static void SetOwnership(int fd, uint uid, uint gid, uint mode) { - if (Fchown(fd, uid, gid) != 0 || Fchmod(fd, mode) != 0) + // An ACL inherited from the destination's default ACL would gain an effective mask from fchmod. + if ((FRemoveXattr(fd, "system.posix_acl_access") != 0 && Marshal.GetLastPInvokeError() is not (61 or 95)) // ENODATA, EOPNOTSUPP + || Fchown(fd, uid, gid) != 0 || Fchmod(fd, mode) != 0 || HasAccessAcl(fd)) throw new IOException("Unable to set verified copy ownership and permissions."); } @@ -108,7 +113,7 @@ public bool SameObject(Stamp other) => Inode == other.Inode && Major == other.Ma } [LibraryImport("libc", EntryPoint = "fcntl", SetLastError = true)] - private static partial int Fcntl(int fd, int command, int argument); + private static partial int Fcntl(int fd, int command, nint argument); [LibraryImport("libc", EntryPoint = "fdopendir", SetLastError = true)] private static partial nint OpenDirectory(int fd); [LibraryImport("libc", EntryPoint = "readdir", SetLastError = true)] diff --git a/tests/Files.Platform.Tests/SystemIntegration/ElevationHelperTests.cs b/tests/Files.Platform.Tests/SystemIntegration/ElevationHelperTests.cs index 31352e77d429..bef66417cf60 100644 --- a/tests/Files.Platform.Tests/SystemIntegration/ElevationHelperTests.cs +++ b/tests/Files.Platform.Tests/SystemIntegration/ElevationHelperTests.cs @@ -339,6 +339,61 @@ public void HandoverNeverWidensSourcePermissions() public void HandoverModeMasksEveryClass(uint source, bool directory, bool sameGroup, bool othersReach, bool acl, uint expected) => Assert.AreEqual(expected, HelperEngine.HandoverMode(source | (directory ? 0x4000U : 0x8000U), directory, sameGroup, othersReach, acl)); + private static (int ExitCode, string Output) Tool(string file, params string[] arguments) + { + try + { + using var process = System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(file, arguments) + { UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true })!; + var output = process.StandardOutput.ReadToEnd(); process.StandardError.ReadToEnd(); + process.WaitForExit(); + return (process.ExitCode, output); + } + catch (System.ComponentModel.Win32Exception) { return (-1, ""); } + } + + private static void RequireAcl(params string[] arguments) + { + if (Tool("/usr/bin/setfacl", arguments).ExitCode != 0) Assert.Inconclusive("setfacl or ACL support on the temporary filesystem is unavailable."); + } + + [TestMethod] + public void InheritedDefaultAclIsDroppedAtHandover() + { + RequireAcl("-d", "-m", "u:nobody:rwx", target); + var folder = Directory.CreateDirectory(Path.Combine(source, "tree")).FullName; + FileAt(folder, "a"); + var result = Run("copy", [folder], target); + Assert.IsTrue(result.Items.Single().Succeeded, result.Items.Single().Error); + foreach (var output in new[] { Path.Combine(target, "tree"), Path.Combine(target, "tree", "a") }) + { + var acl = Tool("/usr/bin/getfacl", "-c", "-p", output); + Assert.AreEqual(0, acl.ExitCode); + Assert.IsFalse(acl.Output.Split('\n').Any(line => line.StartsWith("user:nobody", StringComparison.Ordinal)), output + ":\n" + acl.Output); + } + } + + [TestMethod] + [DataRow(0x1EDU, true)] // an access ACL on an ancestor may deny named users that the mode bits suggest can search + [DataRow(0x1E5U, false)] // group members never fall through to the other search bit + public void AncestorThatNotEveryoneCanSearchHidesSource(uint ancestorMode, bool acl) + { + var shared = Path.Combine(Path.GetTempPath(), "files-elevation-shared-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(shared, (UnixFileMode)0x1ED); + try + { + File.SetUnixFileMode(shared, (UnixFileMode)ancestorMode); + if (acl) RequireAcl("-m", "u:nobody:---", shared); + var origin = Directory.CreateDirectory(Path.Combine(shared, "origin")).FullName; + File.SetUnixFileMode(origin, (UnixFileMode)0x1ED); + var file = FileAt(origin, "a"); File.SetUnixFileMode(file, (UnixFileMode)0x1A4); + var result = Run("copy", [file], target); + Assert.IsTrue(result.Items.Single().Succeeded, result.Items.Single().Error); + Assert.AreEqual((UnixFileMode)0x180, File.GetUnixFileMode(Path.Combine(target, "a"))); + } + finally { Directory.Delete(shared, true); } + } + [TestMethod] public void HardLinkedFilesAreNotHandedToCaller() {