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
2.9 KiB
Bash
87 lines
2.9 KiB
Bash
#!/bin/bash
|
|
|
|
# Reading a secret the WebUI collected, without it ever touching a command line.
|
|
#
|
|
# The problem this exists for: the browser cannot run restic, so a repository
|
|
# password typed in the WebUI has to reach the host. The two channels that
|
|
# already existed both leak it —
|
|
#
|
|
# * as part of a task's command string, which lands in
|
|
# frontend/data/tasks/*.json (0644, in a world-readable directory) and is
|
|
# visible in `ps` while the task runs
|
|
# * as a plain file in that same directory, which is either 0644 and
|
|
# world-readable, or 0640 and unreadable by the manager
|
|
#
|
|
# and a backup repository password is the key to every backup the user has.
|
|
#
|
|
# So the container writes it into a directory root prepared for exactly this:
|
|
# owned <container>:<manager>, mode 2730. Setgid gives the file the manager's
|
|
# group, the container writes it 0640, and the directory is unlistable. The
|
|
# manager reads it once and unlinks it; the value never appears in argv.
|
|
#
|
|
# Callers pass a REFERENCE (the id) wherever they would have passed the secret,
|
|
# and resolve it here at the last moment.
|
|
|
|
# Where the drop lives. Created by `libreportal-ownership secret-dir`.
|
|
webuiSecretDir()
|
|
{
|
|
printf '%s' "$(webuiDir)/frontend/data/.secrets"
|
|
}
|
|
|
|
# Read a secret and consume it. Prints the value on stdout; nothing on failure.
|
|
#
|
|
# Single use by construction: a reference that has already been redeemed, or was
|
|
# never written, is indistinguishable from a wrong one, which is what we want.
|
|
webuiSecretConsume()
|
|
{
|
|
local id="$1"
|
|
# Names come from the browser, so nothing that could escape the directory.
|
|
if [[ ! "$id" =~ ^[A-Za-z0-9_-]{8,64}$ ]]; then
|
|
isError "Invalid secret reference."
|
|
return 1
|
|
fi
|
|
|
|
local f; f="$(webuiSecretDir)/$id"
|
|
if [[ ! -f "$f" ]]; then
|
|
isError "That secret is not available (already used, or expired)."
|
|
return 1
|
|
fi
|
|
|
|
cat -- "$f" 2>/dev/null
|
|
local rc=$?
|
|
# Unlink even if the read failed — a secret that could not be delivered must
|
|
# not sit around waiting to be delivered to someone else.
|
|
rm -f -- "$f" 2>/dev/null
|
|
return $rc
|
|
}
|
|
|
|
# Drop anything left behind. A secret is written immediately before the action
|
|
# that redeems it, so anything older than a few minutes belongs to a flow that
|
|
# was abandoned — a browser tab closed mid-form — and should not linger.
|
|
webuiSecretSweep()
|
|
{
|
|
local d; d="$(webuiSecretDir)"
|
|
[[ -d "$d" ]] || return 0
|
|
find "$d" -maxdepth 1 -type f -mmin +15 -delete 2>/dev/null
|
|
return 0
|
|
}
|
|
|
|
# True when a value is a reference rather than the secret itself. Lets a caller
|
|
# accept either during the transition without guessing.
|
|
webuiSecretIsRef()
|
|
{
|
|
[[ "$1" == secret:* ]]
|
|
}
|
|
|
|
# Resolve a value that may be a reference. Anything else is returned unchanged,
|
|
# so call sites stay readable.
|
|
webuiSecretResolve()
|
|
{
|
|
local v="$1"
|
|
if webuiSecretIsRef "$v"; then
|
|
webuiSecretConsume "${v#secret:}"
|
|
return $?
|
|
fi
|
|
printf '%s' "$v"
|
|
}
|