fix: prevent envsubst+Compose double-interpolation from truncating $ secrets #8

Merged
AVB merged 1 commits from fix-dollar-double-interpolation into main 2026-08-26 21:05:37 -07:00
Showing only changes of commit d209fc3222 - Show all commits
+46 -2
View File
@@ -22,14 +22,58 @@ def merge_envs(base_path, override_path):
merged = {**base, **override} merged = {**base, **override}
return list(merged.items()) return list(merged.items())
# ─────────────────────────────────────────────────────────────────────────────
# 2026-08-26 FIX — double-interpolation truncation bug (Pattern B stacks):
#
# stack-deploy.sh's single-file/no-extras path is:
# envsubst "$VARS" < ai.yaml | docker stack deploy -c - ai
#
# envsubst substitutes ${VAR} placeholders in the compose YAML with the
# literal, raw value of each shell-exported variable. If that raw value
# itself contains a literal '$' followed by word characters (e.g. a
# randomly-generated secret like "...i*Edu$RyAVYTqr4yzSS##..."), the
# resulting YAML text now contains what LOOKS like a second variable
# reference. `docker stack deploy -c -` runs Compose's own interpolation
# pass on that YAML text before creating the service — and Compose sees
# that leftover "$RyAVYTqr4yzSS", finds no such env var, and silently
# substitutes empty string. The secret gets truncated in the running
# container with NO error or warning.
#
# Confirmed impact (2026-08-26): LITELLM_MASTER_KEY and LITELLM_SALT_KEY
# in the `ai` stack were both truncated at their first literal '$' after
# a real deploy — 87-char secret arrived in the container as 73 chars.
#
# This affects every stack using Pattern B (host .env + envsubst, not
# native Docker secrets): ai, maintenance, media, unifi, guacamole,
# security, auth, traefik, meshcentral, ddm — any of them could have a
# '$'-containing value silently truncating right now without detection,
# since the failure is silent and only visible by diffing the source
# value against the live container env.
#
# Fix: escape every literal '$' in a value as '$$' at export time, BEFORE
# envsubst ever sees it. envsubst does not interpret '$' in the
# replacement text (only in the template), so the doubled dollar survives
# envsubst untouched. Compose's interpolation pass then consumes exactly
# one level of escaping ('$$' -> literal '$'), landing on the correct
# original single '$' with no leftover variable-reference lookalike.
#
# Only applied in export/export_merged (which feed `eval` to set the
# actual values envsubst reads) — NOT in vars/vars_merged, which just
# build envsubst's space-separated $VARNAME allowlist string and have
# nothing to do with actual values.
# ─────────────────────────────────────────────────────────────────────────────
def escape_dollar(v):
return v.replace('$', '$$')
mode = sys.argv[1] mode = sys.argv[1]
if mode == 'export': if mode == 'export':
for k, v in parse_env(sys.argv[2]): for k, v in parse_env(sys.argv[2]):
print('export {}={}'.format(k, repr(v))) print('export {}={}'.format(k, repr(escape_dollar(v))))
elif mode == 'export_merged': elif mode == 'export_merged':
# export_merged <global.env> <stack.env> # export_merged <global.env> <stack.env>
for k, v in merge_envs(sys.argv[2], sys.argv[3]): for k, v in merge_envs(sys.argv[2], sys.argv[3]):
print('export {}={}'.format(k, repr(v))) print('export {}={}'.format(k, repr(escape_dollar(v))))
elif mode == 'vars': elif mode == 'vars':
print(' '.join('$' + k for k, v in parse_env(sys.argv[2]))) print(' '.join('$' + k for k, v in parse_env(sys.argv[2])))
elif mode == 'vars_merged': elif mode == 'vars_merged':