Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions COVERAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,50 @@ Measured on 2026-09-24 against upstream 19.x (b60dd23), following the
project decision 0003 (90 percent floor per repository, 95 percent for every
file our changes touch).

## Branch fix/webmin-auth-hardening: 100 percent, five files (2026-09-29)

Every file this branch touches has a target in `tests/coverage.sh`, and the
two suites that measure no file of their own run after them. Measured with
kcov 43 on Debian 13, 85 bats after the merge of 19.x (#8 and #10), the
five files of this branch:

| File | Lines | Covered | Percent |
|------|-------|---------|---------|
| conf/samba-rootpass | 8 | 8 | 100 |
| conf/turnkey.d/postfix-local | 17 | 17 | 100 |
| conf/turnkey.d/rootpass | 6 | 6 | 100 |
| conf/turnkey.d/webmin-enable | 10 | 10 | 100 |
| conf/turnkey.d/webmin-pam | 7 | 7 | 100 |
| Total | 48 | 48 | 100 |

`conf/turnkey.d/rootpass`, `conf/turnkey.d/webmin-enable` and
`conf/samba-rootpass` changed shebang from `/bin/sh` to `/bin/bash`, because
kcov measures bash and not dash and decision 0003 gives no exemption for a
file a change touches.

The gate in `.github/workflows/tests.yml` stays at 100, the measured number.

The suite asserts behaviour and not configuration. The question "can this
password get in" is put to the real Linux-PAM: `tests/pam-authenticate`
calls `pam_authenticate` through libpam over the scratch image's own stack,
in a private mount namespace with the scratch shadow and passwd files bind
mounted over the real ones, so pam_unix and its unix_chkpwd helper are the
installed ones. An earlier version of the suite modelled pam_unix by hand
and reported an acceptance the module does not make; nothing is modelled
now. What pam_unix makes of the crypt() of the empty string differs between
libpam 1.5 and 1.7, so the five tests that depend on it run on 1.7, the
appliance's, and skip by name elsewhere: the CI runner has 1.5.3 and skips
them. The verdict on whether the web interface would start is
`systemd-analyze condition` over the packaged `webmin.service` and the
drop-in together, both handed to `systemd-analyze verify`. The shadow tools
and `smbpasswd` are the things stubbed, because they chroot into the root
they are given or talk to a daemon; each stub records the behaviour it
reproduces.

`tests/before-firstboot.bats` runs the three conf scripts over one scratch
image in the order a build runs them and asks the whole question of the
result, because no single script owns the answer.

## Measured baseline on the default branch: 100 percent (2026-09-28)

Pull request #2 merged on 2026-09-26 (merge commit 5a0a381) and brought
Expand Down
36 changes: 36 additions & 0 deletions changes/turnkey.changelog
Original file line number Diff line number Diff line change
@@ -1,5 +1,41 @@
turnkey-core-19.0 (1) turnkey; urgency=low

* An appliance no longer carries an account that can be authenticated
before its first boot has set a password. A build given no 'ROOT_PASS'
wrote 'U6aMy0wojraho', the crypt() of the empty string, into the root
account. On libpam 1.7 (Debian 13), through any PAM stack carrying
'nullok', Debian's common-auth among them, pam_unix authenticates that
field with any password without asking for one. It now writes '*',
which pam_unix refuses whatever is submitted and whatever the stack
says, and the first boot writes over it as before. This is the change
that holds the account shut; the two below narrow what reaches it.
The field is deliberately not '!', which shadow reads as a locked
account: 'passwd --unlock root', which turnkey-sudoadmin runs on every
first boot, refuses to unlock a field of exactly '!', exits 3 and
leaves the rest of that hook undone.

* A build time 'ROOT_PASS' holding a glob character is now set as written.
It was expanded against the build directory before reaching chpasswd.

* 'conf/samba-rootpass', which runs after rootpass on a Samba appliance,
wrote the old field back, expanded 'ROOT_PASS' the same way and traced
it into the build log. It now writes '*', quotes the password and does
not trace.

* 'nullok' is removed from the PAM stack the webmin package installs, in
the new 'webmin-pam' conf script, so that an account whose password is
not set cannot be authenticated through Webmin without one. Nothing in
an appliance asked for it. 'common-auth' keeps it for now (#13).

* Webmin waits for the first boot scripts before it starts. Its unit gains
a condition on the marker inithooks writes when its boot scripts have
run, and a path unit starts it when that marker appears and records
that it has, so every later boot starts Webmin with the rest of the
boot. The marker means the scripts ran, not that they succeeded. A
first boot that never finishes keeps Webmin shut; 'systemctl start
webmin-after-firstboot.service' opens it by hand, for good. An image
carrying no inithooks at all is unaffected.

* apt no longer announces the appliance to every archive it contacts. The
per-appliance '/etc/apt/apt.conf.d/01turnkey', which carried a User-Agent
of 'TurnKey APT-HTTP/1.3 (turnkey-<app>-<version>-<codename>-<arch>)', is
Expand Down
24 changes: 18 additions & 6 deletions conf/samba-rootpass
Original file line number Diff line number Diff line change
@@ -1,18 +1,30 @@
#!/bin/sh -ex
#!/bin/bash -e

# set samba root password
#
# The same account field as conf/turnkey.d/rootpass writes, for the same
# reason: '*' is a field no password matches, through any PAM stack, until
# the first boot sets a password over it. The old value, 'U6aMy0wojraho',
# is what crypt() returns for the empty string, and a stack carrying nullok
# (Debian's common-auth does) let any password in against it.
#
# No xtrace: it printed ROOT_PASS into the build log.
#
# 'smbpasswd -n' gives the Samba account a null password, which Samba
# refuses to authenticate unless 'null passwords = yes' is set in smb.conf;
# nothing in this tree sets it. That call is unchanged.
NO_PASSWORD='*'

passwd --unlock root # useful when testing in chroot

if [ -n "$ROOT_PASS" ]; then
echo root:$ROOT_PASS | chpasswd
echo "root:$ROOT_PASS" | chpasswd
else
# this is a blank password
usermod -p U6aMy0wojraho root
usermod -p "$NO_PASSWORD" root
fi

if [ "$ROOT_PASS" ]; then
if [ -n "$ROOT_PASS" ]; then
(echo "$ROOT_PASS" ; echo "$ROOT_PASS" ) | smbpasswd -a -s root
else
smbpasswd -a -n root
fi

18 changes: 14 additions & 4 deletions conf/turnkey.d/rootpass
Original file line number Diff line number Diff line change
@@ -1,12 +1,22 @@
#!/bin/sh -e
#!/bin/bash -e

# The password field written into an image the build gives no root password
# to. crypt() returns no such string for any input, so nothing a caller can
# submit matches it and the account cannot be authenticated until the first
# boot sets a password over it with chpasswd.
#
# Deliberately not '!': shadow reads a field of exactly '!' as a locked
# account, and 'passwd --unlock root', which turnkey-sudoadmin runs on every
# first boot, then refuses it ("unlocking the password would result in a
# passwordless account"), exits 3 and leaves the rest of that hook undone.
NO_PASSWORD='*'

# chroot only environments don't need a root password
if [ ! "$CHROOT_ONLY" ]; then
if [ -n "$ROOT_PASS" ]; then
echo root:$ROOT_PASS | chpasswd
echo "root:$ROOT_PASS" | chpasswd
else
# this is a blank password
usermod -p U6aMy0wojraho root
usermod -p "$NO_PASSWORD" root
fi
else
passwd --lock root
Expand Down
77 changes: 76 additions & 1 deletion conf/turnkey.d/webmin-enable
Original file line number Diff line number Diff line change
@@ -1,3 +1,78 @@
#!/bin/sh -e
#!/bin/bash -e

# An appliance is configured by its first boot scripts, and a web interface
# is not something it needs before then. So the unit does not start with the
# rest of the boot until the first boot scripts have run once.
#
# The three conditions are triggering conditions, which systemd reads as an
# OR:
#
# - WEBMIN_OPENED exists: a first boot got to the end of its scripts on
# this machine once, and this boot is not waiting for it. The one shot
# below writes it; the build does not, so no image carries it.
# - the marker inithooks writes when its boot scripts are done, on the
# boot that has not got that far yet.
# - the image carries no inithooks at all, and there is nothing to wait
# for.
#
# The path unit starts Webmin when the marker appears; it triggers a one
# shot that stays active afterwards, so it fires once per boot and does not
# restart Webmin behind an administrator who stopped it.
#
# The marker is written when the scripts have run, not when they succeeded:
# inithooks logs a failing script and carries on. What keeps an account
# shut on a first boot that failed to set a password is rootpass (a field
# no password matches), not this.
#
# A first boot that never finishes keeps the interface shut on every boot
# until one does. To open it by hand, from a console:
#
# systemctl start webmin-after-firstboot.service
#
# which starts Webmin and records that it has been opened, for every boot
# after. 'systemctl start webmin' alone exits 0 and starts nothing while the
# conditions are unmet.
#
# SYSTEMD_DIR, INITHOOKS_MARKER, INITHOOKS_RUN and WEBMIN_OPENED are test
# hooks; a build leaves them unset.

SYSTEMD_DIR=${SYSTEMD_DIR:-/etc/systemd/system}
INITHOOKS_MARKER=${INITHOOKS_MARKER:-/run/inithooks-complete}
INITHOOKS_RUN=${INITHOOKS_RUN:-/usr/lib/inithooks/run}
WEBMIN_OPENED=${WEBMIN_OPENED:-/var/lib/webmin-after-firstboot/opened}

mkdir -p "$SYSTEMD_DIR/webmin.service.d"

cat > "$SYSTEMD_DIR/webmin.service.d/after-firstboot.conf" <<CONF
[Unit]
ConditionPathExists=|$WEBMIN_OPENED
ConditionPathExists=|$INITHOOKS_MARKER
ConditionPathExists=|!$INITHOOKS_RUN
CONF

cat > "$SYSTEMD_DIR/webmin-after-firstboot.path" <<CONF
[Unit]
Description=Watch for the end of the first boot scripts, to start Webmin

[Path]
PathExists=$INITHOOKS_MARKER
Unit=webmin-after-firstboot.service

[Install]
WantedBy=multi-user.target
CONF

cat > "$SYSTEMD_DIR/webmin-after-firstboot.service" <<CONF
[Unit]
Description=Start Webmin now that the first boot scripts have run

[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/usr/bin/mkdir -p $(dirname "$WEBMIN_OPENED")
ExecStart=/usr/bin/touch $WEBMIN_OPENED
ExecStart=/usr/bin/systemctl --no-block start webmin.service
CONF

systemctl enable webmin
systemctl enable webmin-after-firstboot.path
26 changes: 26 additions & 0 deletions conf/turnkey.d/webmin-pam
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
#!/bin/bash -e

# Webmin authenticates through PAM, and the stack its own package ships
# carries 'nullok' on the pam_unix line, which tells pam_unix that an
# account whose password is not set may be authenticated without one. No
# appliance account is meant to be reachable that way, and nothing in an
# appliance asks for the option, so it goes. Debian's common-auth carries it
# too and keeps it for now: pam-auth-update regenerates that file, so it
# needs a profile rather than an edit (Keel-Linux/common#13).
#
# PAM_WEBMIN is a test hook; a build leaves it unset.

PAM_WEBMIN=${PAM_WEBMIN:-/etc/pam.d/webmin}

fatal() {
echo "FATAL [$(basename "$0")]: $*" >&2
exit 1
}

[[ -f "$PAM_WEBMIN" ]] || fatal "$PAM_WEBMIN does not exist"

sed -i -E 's/[[:space:]]+nullok(_secure)?\b//g' "$PAM_WEBMIN"

if grep -qE '\bnullok' "$PAM_WEBMIN"; then
fatal "nullok survived in $PAM_WEBMIN"
fi
96 changes: 96 additions & 0 deletions tests/before-firstboot.bats
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
#!/usr/bin/env bats
# What a freshly built image lets through between the end of the build and
# the end of the first boot, when no password has been set on it yet.
#
# The conf scripts that decide it are run over one scratch image in the
# order the build runs them, and the image is then asked the only question
# worth asking of it: can anybody get in.
#
# No single script owns the answer. rootpass decides what the account
# carries, webmin-pam decides what the stack makes of it and webmin-enable
# decides whether the interface is listening at all, so they are tested
# together as well as apart.

bats_require_minimum_version 1.5.0

load helpers

setup() {
TESTS_DIR="$(cd "$(dirname "$BATS_TEST_FILENAME")" && pwd)"
CONF_DIR="$TESTS_DIR/../conf/turnkey.d"
scratch_image

export INITHOOKS_MARKER=$IMAGE/run/inithooks-complete
export INITHOOKS_RUN=$IMAGE/usr/lib/inithooks/run
mkdir -p "$IMAGE/run" "$IMAGE/usr/lib/inithooks"
touch "$INITHOOKS_RUN"

DROPIN=$SYSTEMD_DIR/webmin.service.d/after-firstboot.conf
unset CHROOT_ONLY ROOT_PASS
}

# build_image
# Runs the conf scripts of this change in the order fab runs a conf
# directory, which is the order their names sort in.
build_image() {
local script
for script in $(printf '%s\n' rootpass webmin-enable webmin-pam | sort); do
"$CONF_DIR/$script"
done
}

@test "no password gets in before the first boot has set one" {
build_image
run nothing_authenticates
[ "$status" -eq 0 ]
}

# Webmin is one way in. The console login, su and everything else that
# includes common-auth are the others, and common-auth keeps nullok
# (Keel-Linux/common#13), so this is the question only the account field
# answers.
@test "and none gets in through the stack everything else uses either" {
build_image
run nothing_authenticates root "$PAM_COMMON_AUTH"
[ "$status" -eq 0 ]
}

@test "and the interface is not up to be asked" {
build_image
run ! unit_would_start "$DROPIN"
}

@test "and it stays that way for as long as the first boot takes" {
build_image
run ! unit_would_start "$DROPIN"
run nothing_authenticates
[ "$status" -eq 0 ]

passwd --unlock root
run nothing_authenticates
[ "$status" -eq 0 ]
run ! unit_would_start "$DROPIN"
}

@test "and opens when the first boot has set a password" {
build_image
echo 'root:hunter2' | chpasswd
touch "$INITHOOKS_MARKER"

run unit_would_start "$DROPIN"
[ "$status" -eq 0 ]
run authenticates "hunter2"
[ "$status" -eq 0 ]
run refuses ""
[ "$status" -eq 0 ]
}

@test "a build given a root password is held shut the same way" {
export ROOT_PASS=s3cret
build_image
run ! unit_would_start "$DROPIN"
run authenticates "s3cret"
[ "$status" -eq 0 ]
run refuses ""
[ "$status" -eq 0 ]
}
9 changes: 9 additions & 0 deletions tests/coverage.sh
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ targets=(
"conf/turnkey.d/postfix-local:tests/postfix-local.bats"
"conf/turnkey.d/dpkg-vendor:tests/dpkg-vendor.bats"
"conf/turnkey.d/apt-identity:tests/apt-identity.bats"
"conf/samba-rootpass:tests/samba-rootpass.bats"
"conf/turnkey.d/rootpass:tests/rootpass.bats"
"conf/turnkey.d/webmin-enable:tests/webmin-enable.bats"
"conf/turnkey.d/webmin-pam:tests/webmin-pam.bats"
)

for tool in kcov bats; do
Expand Down Expand Up @@ -53,6 +57,11 @@ for target in "${targets[@]}"; do
fi
done

# Suites that measure no file of their own still have to pass:
# before-firstboot.bats runs rootpass, webmin-enable and webmin-pam together,
# and pam-unix.bats pins the pam_unix behaviour the others rest on.
bats "$root/tests/before-firstboot.bats" "$root/tests/pam-unix.bats"

if [ "$failed" -ne 0 ]; then
exit 1
fi
3 changes: 3 additions & 0 deletions tests/fixtures/pam.d-common-auth
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
auth [success=1 default=ignore] pam_unix.so nullok
auth requisite pam_deny.so
auth required pam_permit.so
4 changes: 4 additions & 0 deletions tests/fixtures/pam.d-webmin
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
#%PAM-1.0
auth required pam_unix.so nullok
account required pam_unix.so
session required pam_unix.so
Loading
Loading