Skip to content

Commit 43a3d93

Browse files
committed
fix: stop provisioning exposing wp-config.php
Phase 6 grants the service user write access to the site with a recursive chmod -R g+w so it can edit themes and plugins. That grant also sweeps in wp-config.php, which holds the database credentials, salts, and auth keys, leaving it world-readable and group-writable by a user that is a member of www-data. Two consequences, neither of which the agent needs: any local account can read the database credentials, and the coding agent can rewrite the file defining the site's database connection. The credentials file is now set to 0640 owned by www-data after the site-wide grant. PHP-FPM and nginx both run as www-data on a standard provision, so they keep working; world read and group write are gone. Applied unconditionally rather than only to fresh sites, so re-running provisioning corrects a mode an earlier install left loosened. Without that, every already-provisioned host stays exposed. Closes #302
1 parent d6ce3a0 commit 43a3d93

2 files changed

Lines changed: 125 additions & 0 deletions

File tree

lib/infrastructure.sh

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,31 @@ setup_ssl() {
221221
fi
222222
}
223223

224+
# Restrict the credentials file after the site-wide grant (issue #302).
225+
#
226+
# The recursive `chmod -R g+w` above exists so the service user — a member of
227+
# www-data — can edit themes and plugins. Applied to the whole site path it
228+
# also sweeps in wp-config.php, which holds the database credentials, salts,
229+
# and auth keys. That leaves the file world-readable (any local account can
230+
# read the credentials) and agent-writable (the coding agent can rewrite the
231+
# site's database connection), neither of which the agent needs.
232+
#
233+
# 0640 owned by www-data keeps PHP-FPM and nginx working — both run as
234+
# www-data on a standard provision — while removing world read and group
235+
# write. Applied unconditionally so re-running provisioning corrects a mode
236+
# an earlier install left loosened, rather than only fixing fresh sites.
237+
harden_wp_config_permissions() {
238+
local site_path="$1"
239+
local config="$site_path/wp-config.php"
240+
241+
if [ "$DRY_RUN" != true ] && [ ! -f "$config" ]; then
242+
return 0
243+
fi
244+
245+
run_cmd chown www-data:www-data "$config"
246+
run_cmd chmod 640 "$config"
247+
}
248+
224249
setup_service_permissions() {
225250
if [ "$LOCAL_MODE" = true ]; then
226251
log "Phase 6: Local mode — skipping service user setup"
@@ -236,6 +261,7 @@ setup_service_permissions() {
236261

237262
run_cmd chmod -R g+w "$SITE_PATH"
238263
run_cmd chown -R www-data:www-data "$SITE_PATH"
264+
harden_wp_config_permissions "$SITE_PATH"
239265

240266
run_cmd mkdir -p "$KIMAKI_DATA_DIR"
241267
run_cmd chown -R "$SERVICE_USER:$SERVICE_USER" "$KIMAKI_DATA_DIR"

tests/wp-config-permissions.sh

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
#!/bin/bash
2+
# tests/wp-config-permissions.sh — Regression coverage for issue #302 in
3+
# lib/infrastructure.sh.
4+
#
5+
# Phase 6 grants the service user write access to the site with a recursive
6+
# `chmod -R g+w "$SITE_PATH"`, so it can edit themes and plugins. That grant
7+
# also sweeps in wp-config.php, which holds the database credentials, salts,
8+
# and auth keys. The result observed on two provisioned hosts:
9+
#
10+
# -rw-rw-r-- <service-user>:www-data wp-config.php
11+
#
12+
# World-readable, so any local account can read the database credentials, and
13+
# group-writable by a service user that is a member of www-data, so the coding
14+
# agent can rewrite the site's database connection. The agent gains nothing
15+
# from either.
16+
#
17+
# harden_wp_config_permissions must restore 0640 owned by www-data after the
18+
# site-wide grant. Asserts:
19+
# 1. A world-readable, group-writable config is tightened to 0640
20+
# 2. It is applied unconditionally, so re-provisioning corrects a mode an
21+
# earlier install left loosened (not only fresh sites)
22+
# 3. An already-correct config is left at 0640
23+
# 4. A missing config is not an error (site not yet installed)
24+
set -eu
25+
26+
SCRIPT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
27+
cd "$SCRIPT_DIR"
28+
29+
TMP=$(mktemp -d)
30+
trap 'rm -rf "$TMP"' EXIT
31+
32+
fail() {
33+
echo "FAIL: $1" >&2
34+
exit 1
35+
}
36+
37+
# Stub the provisioning surface harden_wp_config_permissions depends on, so
38+
# the function can be exercised without running a real install. chown is
39+
# recorded rather than performed: the test does not run as root and cannot
40+
# change file ownership.
41+
CHOWN_LOG="$TMP/chown.log"
42+
: > "$CHOWN_LOG"
43+
44+
DRY_RUN=false
45+
run_cmd() {
46+
if [ "${1:-}" = "chown" ]; then
47+
printf '%s\n' "$*" >> "$CHOWN_LOG"
48+
return 0
49+
fi
50+
"$@"
51+
}
52+
53+
# shellcheck disable=SC1091
54+
eval "$(sed -n '/^harden_wp_config_permissions() {/,/^}/p' lib/infrastructure.sh)"
55+
56+
mode_of() {
57+
stat -c '%a' "$1"
58+
}
59+
60+
# 1. The state provisioning actually leaves behind: world-readable and
61+
# group-writable.
62+
site="$TMP/site"
63+
mkdir -p "$site"
64+
printf '<?php // credentials\n' > "$site/wp-config.php"
65+
chmod 664 "$site/wp-config.php"
66+
67+
harden_wp_config_permissions "$site"
68+
69+
got=$(mode_of "$site/wp-config.php")
70+
[ "$got" = "640" ] || fail "expected 0640 after hardening, got 0$got"
71+
grep -q 'chown www-data:www-data' "$CHOWN_LOG" \
72+
|| fail "expected ownership to be set to www-data"
73+
74+
# 2. Re-running provisioning on a host an earlier install left loose must
75+
# correct it. Without this, every already-provisioned host stays exposed.
76+
chmod 664 "$site/wp-config.php"
77+
harden_wp_config_permissions "$site"
78+
got=$(mode_of "$site/wp-config.php")
79+
[ "$got" = "640" ] || fail "re-provisioning must correct a loosened mode, got 0$got"
80+
81+
# 3. An already-correct config stays correct.
82+
harden_wp_config_permissions "$site"
83+
got=$(mode_of "$site/wp-config.php")
84+
[ "$got" = "640" ] || fail "expected 0640 to be preserved, got 0$got"
85+
86+
# 4. A site path with no wp-config.php yet must not fail the phase.
87+
empty="$TMP/empty"
88+
mkdir -p "$empty"
89+
harden_wp_config_permissions "$empty" \
90+
|| fail "a missing wp-config.php must not fail provisioning"
91+
92+
# 5. World read is the specific bit that matters for a credentials file.
93+
chmod 644 "$site/wp-config.php"
94+
harden_wp_config_permissions "$site"
95+
if [ -r "$site/wp-config.php" ] && [ "$(stat -c '%A' "$site/wp-config.php" | cut -c8-10)" != "---" ]; then
96+
fail "world permissions must be cleared on the credentials file"
97+
fi
98+
99+
echo "wp-config permissions tests passed"

0 commit comments

Comments
 (0)