A password typed in the WebUI has to reach the host, and both existing routes
leak it. As part of a task's command string it lands in
frontend/data/tasks/*.json — 0644, inside a world-readable directory — and is
visible in `ps` while the task runs; as a plain file there it is either
world-readable at 0644 or unreadable by the manager at 0640. Verified still true
on a clean install. A backup repository password sent that way is the key to
every backup the user has, readable by any local account.
libreportal-ownership gains `secret-dir`: the mirror of _webui_bind_access.
That one makes manager-owned config readable by the container; this makes a
container-written file readable by the MANAGER. The directory is
<container>:<manager> mode 2730 — setgid so each file inherits the manager's
group, the container writes it 0640, and 0730 leaves the directory unlistable
because the manager is handed a filename rather than going looking. Group rwx
is what lets it unlink after reading.
The WebUI then sends a REFERENCE ("secret:<id>") wherever it used to send the
value, and configUpdateBatch redeems it at the last moment before the write.
That is the single point every config write from the WebUI passes through, so
this covers every password field rather than only the backup ones — which is
what docs/roadmap/first-run-restore.md §4.1 asked for. A reference that cannot
be redeemed leaves the field unchanged rather than blanking it.
Verified on a live install: the container drops a secret, the manager applies it
by reference, the file is unlinked, `nobody` can neither read nor list it, and a
second redemption of the same reference fails.
footprint_version 9 -> 10 (root-owned helper changed).
Also fixes a block of constructor initialisations I spliced into the middle of
renderStorageChoices in aa44e0b: on a single-drive box — the case in the
screenshot that prompted this — rendering the Storage step silently reset
backupDest and cleared the import selections.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
87 lines
4.3 KiB
Bash
Executable File
87 lines
4.3 KiB
Bash
Executable File
#!/bin/bash
|
|
# The one-shot channel for a secret the WebUI collected.
|
|
#
|
|
# scripts/dev/lp-secret-channel-test
|
|
#
|
|
# The browser cannot run restic, so a repository password typed in the WebUI has
|
|
# to reach the host. Both channels that already existed leak it: as part of a
|
|
# task's command string it lands in frontend/data/tasks/*.json — 0644, inside a
|
|
# world-readable directory, and visible in `ps` while the task runs — and as a
|
|
# plain file there it is either world-readable at 0644 or unreadable by the
|
|
# manager at 0640. That password is the key to every backup the user has.
|
|
#
|
|
# So the container writes it into a directory root prepares for this: owned
|
|
# <container>:<manager>, mode 2730. The setgid bit gives the file the manager's
|
|
# group, the container writes it 0640, and the directory is unlistable. The
|
|
# manager redeems it once and unlinks it, and the value never enters argv.
|
|
#
|
|
# The ownership half needs two real accounts and root, so it is checked only
|
|
# when those are present; the redemption rules are checked always.
|
|
|
|
REPO="$(cd "$(dirname "$0")/../.." && pwd)"
|
|
BASE="$(mktemp -d "${TMPDIR:-/tmp}/lp-secret-test-XXXXXX")"
|
|
trap 'rm -rf "$BASE"' EXIT
|
|
|
|
fail=0
|
|
chk(){ if [[ "$2" == "$3" ]]; then echo " ok $1"; else echo " FAIL $1: got '$2' want '$3'"; fail=1; fi; }
|
|
|
|
mkdir -p "$BASE/webui/frontend/data/.secrets"
|
|
webuiDir(){ printf '%s/webui' "$BASE"; }
|
|
isError(){ LAST_ERR="$*"; }
|
|
source "$REPO/scripts/webui/webui_secret.sh"
|
|
|
|
printf 'hunter2-repo-password' > "$BASE/webui/frontend/data/.secrets/abcd1234efgh"
|
|
|
|
echo "--- a reference is redeemed once ---"
|
|
chk "first read" "$(webuiSecretConsume abcd1234efgh)" "hunter2-repo-password"
|
|
chk "second read" "$(webuiSecretConsume abcd1234efgh 2>/dev/null)" ""
|
|
chk "file is gone" "$([[ -e "$BASE/webui/frontend/data/.secrets/abcd1234efgh" ]] && echo yes || echo no)" "no"
|
|
|
|
echo "--- ids that could escape the directory are refused ---"
|
|
for bad in '../../../etc/passwd' 'a/b' 'x' '' '../secrets2/leak' 'name with space' "$(printf 'a%.0s' {1..80})"; do
|
|
if webuiSecretConsume "$bad" >/dev/null 2>&1; then
|
|
echo " FAIL accepted '$bad'"; fail=1
|
|
fi
|
|
done
|
|
echo " ok all rejected"
|
|
# Nothing outside the drop may be touched even when the name resolves to a file.
|
|
printf 'do-not-read' > "$BASE/outside"
|
|
webuiSecretConsume "../outside" >/dev/null 2>&1
|
|
chk "file outside untouched" "$([[ -f "$BASE/outside" ]] && echo yes || echo no)" "yes"
|
|
|
|
echo "--- resolve: references redeemed, plain values passed through ---"
|
|
printf 'sekrit' > "$BASE/webui/frontend/data/.secrets/refref1234ab"
|
|
chk "reference" "$(webuiSecretResolve 'secret:refref1234ab')" "sekrit"
|
|
chk "plain value" "$(webuiSecretResolve 'just-a-password')" "just-a-password"
|
|
chk "empty stays" "$(webuiSecretResolve '')" ""
|
|
|
|
echo "--- abandoned drops are swept ---"
|
|
printf 'stale' > "$BASE/webui/frontend/data/.secrets/staleaaaa111"
|
|
touch -d '2 hours ago' "$BASE/webui/frontend/data/.secrets/staleaaaa111"
|
|
printf 'fresh' > "$BASE/webui/frontend/data/.secrets/freshbbbb222"
|
|
webuiSecretSweep
|
|
chk "old removed" "$([[ -e "$BASE/webui/frontend/data/.secrets/staleaaaa111" ]] && echo yes || echo no)" "no"
|
|
chk "fresh kept" "$([[ -e "$BASE/webui/frontend/data/.secrets/freshbbbb222" ]] && echo yes || echo no)" "yes"
|
|
|
|
echo "--- the ownership boundary (needs root + both accounts) ---"
|
|
if [[ $EUID -eq 0 ]] && id libreportal >/dev/null 2>&1 && id dockerinstall >/dev/null 2>&1 \
|
|
&& [[ -d /libreportal-containers/libreportal/frontend/data ]]; then
|
|
/usr/local/lib/libreportal/libreportal-ownership secret-dir
|
|
d=/libreportal-containers/libreportal/frontend/data/.secrets
|
|
chk "dir mode" "$(stat -c '%a' "$d")" "2730"
|
|
chk "dir owner" "$(stat -c '%U:%G' "$d")" "dockerinstall:libreportal"
|
|
su -s /bin/bash -c "umask 027; printf 'x' > $d/testtesttest" dockerinstall
|
|
chk "file group inherited" "$(stat -c '%G' "$d/testtesttest")" "libreportal"
|
|
su -s /bin/bash -c "test -r $d/testtesttest" libreportal \
|
|
&& echo " ok manager can read" || { echo " FAIL manager cannot read"; fail=1; }
|
|
su -s /bin/bash -c "test -r $d/testtesttest" nobody 2>/dev/null \
|
|
&& { echo " FAIL another account can read"; fail=1; } || echo " ok others cannot read"
|
|
rm -f "$d/testtesttest"
|
|
else
|
|
echo " SKIP not root, or the install is not present"
|
|
fi
|
|
|
|
echo ""
|
|
if (( fail )); then echo "FAILED"; exit 1; fi
|
|
echo "All secret-channel checks passed."
|