Skip to content

Commit 6b49ca5

Browse files
authored
Merge pull request #199 from ikaro1192/worktree-fix-184
Validate password file permissions at startup (#184)
2 parents 22e49fa + ac5278e commit 6b49ca5

13 files changed

Lines changed: 420 additions & 12 deletions

app/Daemon/Main.hs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import PureMyHA.Env (ClusterEnv (..), runApp)
2323
import PureMyHA.HTTP.Server (startHTTPServer)
2424
import PureMyHA.IPC.Server (startIPCServer, defaultSocketPath, DiscoveryAction (..), ClusterMap (..), DiscoveryMap (..))
2525
import PureMyHA.Logger (Logger, initLogger, closeLogger, reopenLogger, setLogLevel, logInfo, logWarn)
26+
import qualified PureMyHA.PasswordFile as PasswordFile
2627
import PureMyHA.Supervisor.StateManager (newEventQueue, stateManager)
2728
import PureMyHA.Supervisor.Worker (startMonitorWorkers, startTopologyRefreshWorker, WorkerRegistry (..), runWorker, emergencyReplicaCheck)
2829
import PureMyHA.Topology.Discovery (discoverTopology, buildInitialTopology)
@@ -263,9 +264,9 @@ loadClusterPasswords cc = do
263264

264265
loadPassword :: Credentials -> IO Text
265266
loadPassword creds = do
266-
result <- try @SomeException $ T.strip . T.pack <$> readFile (credPasswordFile creds)
267+
result <- PasswordFile.loadPassword (credPasswordFile creds)
267268
case result of
268-
Left err -> die $ "Failed to read password file: " <> show err
269+
Left err -> die $ T.unpack err
269270
Right pwd -> pure pwd
270271

271272
makeDiscoveryAction :: ClusterEnv -> WorkerRegistry -> DiscoveryAction

config/config.yaml.example

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ clusters:
77
port: 3306
88
credentials:
99
user: puremyha
10+
# password_file must be a regular file (not a symlink), owned by root or
11+
# the user running puremyhad, and readable only by its owner (mode 0600
12+
# or 0400). The daemon refuses to start otherwise.
1013
password_file: /etc/puremyha/mysql.pass
1114
replication_credentials: # optional; falls back to credentials if omitted
1215
user: repl

docs/configuration.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,16 @@ GRANT REPLICATION SLAVE ON *.* TO 'repl'@'%';
4646

4747
> **Note:** If you use the same account for both monitoring and replication, omit `replication_credentials` from the config. PureMyHA will fall back to `credentials` automatically.
4848
49+
### Password file security requirements
50+
51+
Each `password_file` is validated at startup. `puremyhad` refuses to start if any of these checks fail:
52+
53+
- **Regular file** — symlinks, directories, and device files are rejected (symlinks are explicitly disallowed to prevent TOCTOU swaps after config load).
54+
- **Trusted owner** — the file's owner UID must be 0 (root) or the UID running `puremyhad`.
55+
- **Owner-only access**`mode & 0o077` must be zero, i.e. no group- or other-permission bit is set. Acceptable modes are `0600` (owner read/write) or `0400` (owner read-only).
56+
57+
Recommended install: `chown root:root /etc/puremyha/*.pass && chmod 0600 /etc/puremyha/*.pass`.
58+
4959
## Full Configuration Reference
5060

5161
See [`config/config.yaml.example`](../config/config.yaml.example) for the complete annotated configuration file.

e2e/Dockerfile.e2e

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,4 +41,12 @@ COPY --from=selected /staging/puremyha /usr/bin/puremyha
4141
COPY --chown=root:root e2e/config/hooks /opt/puremyha/hooks
4242
RUN chmod 0755 /opt/puremyha/hooks/*.sh
4343

44+
# Password files are baked into the image rather than bind-mounted from the
45+
# host: the daemon's startup validator rejects password files that are not
46+
# owned by root/daemon or whose mode allows group/other access, which a host
47+
# bind-mount would otherwise violate (host UID + 0644 umask).
48+
COPY --chown=root:root e2e/config/mysql.pass /etc/puremyha-secrets/mysql.pass
49+
COPY --chown=root:root e2e/config/repl.pass /etc/puremyha-secrets/repl.pass
50+
RUN chmod 0600 /etc/puremyha-secrets/mysql.pass /etc/puremyha-secrets/repl.pass
51+
4452
CMD ["puremyhad"]

e2e/config/puremyha-failover-without-observed-healthy.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,10 @@ clusters:
99
port: 3306
1010
credentials:
1111
user: puremyha
12-
password_file: /etc/puremyha/mysql.pass
12+
password_file: /etc/puremyha-secrets/mysql.pass
1313
replication_credentials:
1414
user: repl
15-
password_file: /etc/puremyha/repl.pass
15+
password_file: /etc/puremyha-secrets/repl.pass
1616
failover:
1717
candidate_priority:
1818
- host: mysql-replica1

e2e/config/puremyha-source-only.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,10 @@ clusters:
55
port: 3306
66
credentials:
77
user: puremyha
8-
password_file: /etc/puremyha/mysql.pass
8+
password_file: /etc/puremyha-secrets/mysql.pass
99
replication_credentials:
1010
user: repl
11-
password_file: /etc/puremyha/repl.pass
11+
password_file: /etc/puremyha-secrets/repl.pass
1212

1313
global:
1414
monitoring:

e2e/config/puremyha-tls-skip-verify.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,10 @@ clusters:
99
port: 3306
1010
credentials:
1111
user: puremyha
12-
password_file: /etc/puremyha/mysql.pass
12+
password_file: /etc/puremyha-secrets/mysql.pass
1313
replication_credentials:
1414
user: repl
15-
password_file: /etc/puremyha/repl.pass
15+
password_file: /etc/puremyha-secrets/repl.pass
1616
tls:
1717
mode: skip-verify
1818
failover:

e2e/config/puremyha-tls-verify-ca.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,10 @@ clusters:
99
port: 3306
1010
credentials:
1111
user: puremyha
12-
password_file: /etc/puremyha/mysql.pass
12+
password_file: /etc/puremyha-secrets/mysql.pass
1313
replication_credentials:
1414
user: repl
15-
password_file: /etc/puremyha/repl.pass
15+
password_file: /etc/puremyha-secrets/repl.pass
1616
tls:
1717
mode: verify-ca
1818
ca_cert: /etc/puremyha/tls/ca-cert.pem

e2e/config/puremyha.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,10 @@ clusters:
99
port: 3306
1010
credentials:
1111
user: puremyha
12-
password_file: /etc/puremyha/mysql.pass
12+
password_file: /etc/puremyha-secrets/mysql.pass
1313
replication_credentials:
1414
user: repl
15-
password_file: /etc/puremyha/repl.pass
15+
password_file: /etc/puremyha-secrets/repl.pass
1616
failover:
1717
candidate_priority:
1818
- host: mysql-replica1
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
#!/usr/bin/env bash
2+
# Test: Password File Permissions
3+
#
4+
# Verifies that puremyhad refuses to start when a configured password_file
5+
# is world- or group-accessible, or owned by an untrusted user. A password
6+
# file holds MySQL credentials; the daemon must fail loudly at startup
7+
# rather than silently accept a misconfigured (e.g. 0644) deployment.
8+
set -euo pipefail
9+
source "$(dirname "$0")/../lib/helpers.sh"
10+
echo "=== Test 28: Password File Permissions ==="
11+
12+
# ---------------------------------------------------------------------------
13+
# 1. Happy path: the image-baked password files must have the strict mode
14+
# and ownership that the validator requires. This doubles as a regression
15+
# guard against future Dockerfile changes.
16+
# ---------------------------------------------------------------------------
17+
wait_for_health "Healthy" 60
18+
19+
for pass in mysql.pass repl.pass; do
20+
mode=$($COMPOSE exec -T puremyhad stat -c '%a' /etc/puremyha-secrets/$pass | tr -d '\r')
21+
assert_eq "$pass mode is 0600" "600" "$mode"
22+
23+
owner=$($COMPOSE exec -T puremyhad stat -c '%U:%G' /etc/puremyha-secrets/$pass | tr -d '\r')
24+
assert_eq "$pass owned by root:root" "root:root" "$owner"
25+
done
26+
27+
# ---------------------------------------------------------------------------
28+
# 2. Negative: daemon must refuse to start with a 0644 password file.
29+
# We spawn a one-shot puremyhad against a throwaway config rather than
30+
# restarting the long-running daemon container.
31+
# ---------------------------------------------------------------------------
32+
$COMPOSE exec -T puremyhad sh -c '
33+
set -e
34+
mkdir -p /tmp/pwtest
35+
echo "pw" > /tmp/pwtest/bad.pass
36+
chmod 0644 /tmp/pwtest/bad.pass
37+
cat > /tmp/pwtest/bad.yaml <<EOF
38+
clusters:
39+
- name: perm-test
40+
nodes:
41+
- host: mysql-source
42+
port: 3306
43+
credentials:
44+
user: puremyha
45+
password_file: /tmp/pwtest/bad.pass
46+
47+
global:
48+
monitoring:
49+
interval: 1s
50+
connect_timeout: 2s
51+
replication_lag_warning: 5s
52+
replication_lag_critical: 10s
53+
failure_detection:
54+
recovery_block_period: 30s
55+
failover:
56+
auto_failover: false
57+
EOF
58+
'
59+
60+
exit_code=0
61+
output=$($COMPOSE exec -T puremyhad puremyhad \
62+
--config /tmp/pwtest/bad.yaml \
63+
--socket /tmp/pwtest/sock 2>&1) || exit_code=$?
64+
65+
assert_neq "daemon exits non-zero with 0644 password file" "0" "$exit_code"
66+
assert_contains "daemon error mentions group/other access" "group or other" "$output"
67+
assert_contains "daemon error names the offending path" "/tmp/pwtest/bad.pass" "$output"
68+
69+
# ---------------------------------------------------------------------------
70+
# 3. Negative: daemon must also refuse a 0640 (group-readable) file.
71+
# ---------------------------------------------------------------------------
72+
$COMPOSE exec -T puremyhad chmod 0640 /tmp/pwtest/bad.pass
73+
74+
exit_code=0
75+
output=$($COMPOSE exec -T puremyhad puremyhad \
76+
--config /tmp/pwtest/bad.yaml \
77+
--socket /tmp/pwtest/sock 2>&1) || exit_code=$?
78+
79+
assert_neq "daemon exits non-zero with 0640 password file" "0" "$exit_code"
80+
assert_contains "daemon error mentions group/other access (0640)" "group or other" "$output"
81+
82+
# ---------------------------------------------------------------------------
83+
# 4. Positive control: flipping the same file to 0600 lets the daemon
84+
# advance past password loading. It will still fail afterwards (the yaml
85+
# has no such MySQL user), but the failure must NOT be the permission
86+
# rejection. We assert that "group or other" does not appear.
87+
# ---------------------------------------------------------------------------
88+
$COMPOSE exec -T puremyhad chmod 0600 /tmp/pwtest/bad.pass
89+
90+
exit_code=0
91+
output=$($COMPOSE exec -T puremyhad timeout 3 puremyhad \
92+
--config /tmp/pwtest/bad.yaml \
93+
--socket /tmp/pwtest/sock 2>&1) || exit_code=$?
94+
95+
if echo "$output" | grep -qF "group or other"; then
96+
echo " FAIL: 0600 password file still triggered the permission rejection"
97+
echo " ACTUAL: $output"
98+
((FAIL_COUNT++)) || true
99+
else
100+
echo " PASS: 0600 password file bypasses the permission check"
101+
((PASS_COUNT++)) || true
102+
fi
103+
104+
# Clean up test artefacts so re-runs start fresh
105+
$COMPOSE exec -T puremyhad rm -rf /tmp/pwtest 2>/dev/null || true
106+
107+
test_summary

0 commit comments

Comments
 (0)