feat: validate the initial database name at create and in the guest

DBName was passed through unvalidated, so a name such as my-db was accepted
at CreateDBInstance and only quoted into safety by psql inside the guest.
Engine gains a validateDBName rule and ValidateDBName, and rds-init re-checks
the same rule in load_handoff before initdb, where a failure would clear the
datadir and spend the one-shot master password.

PostgreSQL caps the name at NAMEDATALEN-1.
This commit is contained in:
Julian Sommer
2026-08-11 11:48:29 +10:00
parent 59ca1b33e6
commit 4f5cde03bb
9 changed files with 131 additions and 4 deletions
+18
View File
@@ -71,6 +71,10 @@ MOUNTS_FILE="${RDS_MOUNTS_FILE:-/proc/mounts}"
# AWS, so a customer's `GRANT rds_superuser TO ...` works here too.
MASTER_GROUP_ROLE="rds_superuser"
# NAMEDATALEN-1: the server truncates a longer identifier, so the database
# created would not be the one the customer asked for.
MAX_DB_NAME_LEN=63
log() { echo "[rds-init] $*"; }
fail() { echo "[rds-init] ERROR: $*" >&2; exit 1; }
@@ -134,6 +138,20 @@ load_handoff() {
;;
esac
# The same rule the control plane applies, for the same reason the reserved
# name is re-checked: an unusable name reaches initdb, fails at CREATE
# DATABASE and spends the one-shot password on the way. psql quotes the
# identifier, so here this is defence in depth rather than the barrier.
case "${RDS_DB_NAME}" in
'') ;;
[!A-Za-z]* | *[!A-Za-z0-9_]*)
fail "RDS_DB_NAME ${RDS_DB_NAME} must begin with a letter and hold only letters, digits and underscores; the control plane should have rejected it"
;;
esac
if [ "${#RDS_DB_NAME}" -gt "${MAX_DB_NAME_LEN}" ]; then
fail "RDS_DB_NAME is longer than the ${MAX_DB_NAME_LEN} characters PostgreSQL allows; the control plane should have rejected it"
fi
case "${RDS_PORT}" in
'' | *[!0-9]*) fail "bootstrap config has a non-numeric RDS_PORT: ${RDS_PORT}" ;;
esac
+29 -1
View File
@@ -207,7 +207,10 @@ write_handoff() {
# password carrying shell metacharacters — or a newline — survive the
# handoff intact and reach the checks that exist for it.
[ -n "$2" ] && echo "RDS_MASTER_PASSWORD='$2'"
[ -n "$3" ] && echo "RDS_DB_NAME=$3"
# Quoted as rds-agent's shellQuote writes it, so a name carrying a space
# or a shell metacharacter survives the handoff and reaches the check
# that exists for it rather than being mangled on the way.
[ -n "$3" ] && echo "RDS_DB_NAME='$3'"
[ -n "${PENDING:-}" ] && echo "RDS_BOOTSTRAP_PENDING=${PENDING}"
[ -n "${PAYLOAD_ID:-}" ] && echo "RDS_PAYLOAD_ID=${PAYLOAD_ID}"
# Last, and unconditional: an optional line failing its test would make
@@ -811,6 +814,31 @@ for reserved in postgres rds_superuser rdsadmin pg_toast_owner PostGres; do
unset MASTER_USER
done
# --- Case 10: an initial database name the control plane would have refused ---
# The name is interpolated into a CREATE DATABASE, and a failure there is a
# failure after initdb: the datadir is cleared and the one-shot password is
# gone. Refusing before initdb is what keeps the create retryable.
for badname in 'my-db' 'my db' 'my/db' 'my.db' '1db' \
'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; do
reset_state
write_handoff initialize 's3cr3t' "${badname}"
run_fails "bad-dbname-${badname}"
[ -s "${INITDB_CALLS}" ] \
&& fail "bad-dbname-${badname}: initdb ran before the name was refused" \
|| pass "bad-dbname-${badname}: refused before initdb"
done
# The rule is a rejection of malformed names, not of every name: the boundary
# length and an underscore have to keep working.
reset_state
write_handoff initialize 's3cr3t' \
'a_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'
write_parameters
if run_ok "dbname-at-the-limit"; then
grep -q 'CREATE DATABASE' "${PSQL_CALLS}" \
&& pass "dbname-at-the-limit: accepted" || fail "dbname-at-the-limit: no CREATE DATABASE"
fi
if [ "${FAILS}" -eq 0 ]; then
echo "PASS: all rds-init cases"
exit 0