From 4af12095654859910d04bf2d2efde521be587993 Mon Sep 17 00:00:00 2001 From: admin Date: Wed, 26 Aug 2026 23:25:46 -0700 Subject: [PATCH] Fix: second double-interpolation bug in envparse.py - add export_raw/export_raw_merged (no escape_dollar) for the docker-compose-config render path, which already does its own dollar-escaping. Fixes IMMICH_KIOSK_BASICAUTH bcrypt hash corruption. --- deploy/envparse.py | 54 ++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 50 insertions(+), 4 deletions(-) diff --git a/deploy/envparse.py b/deploy/envparse.py index c238063..de5e53a 100644 --- a/deploy/envparse.py +++ b/deploy/envparse.py @@ -57,15 +57,52 @@ def merge_envs(base_path, override_path): # 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. +# ONLY applies to the single-file path (no `docker compose config` step +# downstream). See export_raw/export_raw_merged below for why the +# folder+extras path must NOT use this. # ────────────────────────────────────────────────────────────────────────── def escape_dollar(v): return v.replace('$', '$$') +# ────────────────────────────────────────────────────────────────────────── +# 2026-08-26 FIX #2 — SECOND double-interpolation bug, folder+extras path +# (discovered fixing IMMICH_KIOSK_BASICAUTH, a bcrypt hash full of '$'): +# +# The folder+extras render path is: +# docker compose config | strip | envsubst "$VARS" +# -> docker stack deploy -c - +# +# `docker compose config` performs its OWN ${VAR} interpolation AND its +# own re-escaping of the output: any literal '$' character that ends up +# in the rendered YAML — escaped or not — gets doubled to '$$' by +# `docker compose config` itself, unconditionally, as part of producing +# spec-safe output. Confirmed by isolated test: +# raw MYVAR='a$b$c' -> docker compose config -> "a$$b$$c" (correct, +# one level added) +# escaped MYVAR='a$$b$$c' (i.e. pre-doubled by escape_dollar) -> +# docker compose config -> "a$$$$b$$$$c" (WRONG, doubled twice) +# +# `docker stack deploy -c -` still only removes exactly ONE level of +# escaping on its way in (confirmed: "a$$b$$c" -> container label +# "a$b$c", correct). So across the whole folder+extras pipeline there is +# exactly ONE implicit escaping step (`docker compose config`) and ONE +# implicit un-escaping step (`docker stack deploy`) already built in — +# pre-escaping the exported value on top of that leaves one extra, +# uncollapsed level of '$$' in the final container label/env value. +# +# Confirmed impact (2026-08-26): IMMICH_KIOSK_BASICAUTH +# ("BabyBryan:$2y$05$...") rendered as "BabyBryan:$$2y$$05$$..." in the +# final container label — Traefik basic auth would never match the real +# password hash, silently locking out the kiosk with no error. +# +# Fix: use export_raw / export_raw_merged (NO escape_dollar) whenever the +# render path goes through `docker compose config` — i.e. any stack with +# extension files. Use export / export_merged (WITH escape_dollar) only +# for the single-file path, which has no `docker compose config` step and +# therefore only Swarm's own interpolation pass to protect against. +# ────────────────────────────────────────────────────────────────────────── + # ────────────────────────────────────────────────────────────────────────── # 2026-08-26 FIX — depends_on long-form vs Swarm short-form: # @@ -201,6 +238,15 @@ elif mode == 'export_merged': # export_merged for k, v in merge_envs(sys.argv[2], sys.argv[3]): print('export {}={}'.format(k, repr(escape_dollar(v)))) +elif mode == 'export_raw': + # export_raw — NO escape_dollar. Use for the docker-compose- + # config render path (folder+extras), which does its own '$' escaping. + for k, v in parse_env(sys.argv[2]): + print('export {}={}'.format(k, repr(v))) +elif mode == 'export_raw_merged': + # export_raw_merged — NO escape_dollar. + for k, v in merge_envs(sys.argv[2], sys.argv[3]): + print('export {}={}'.format(k, repr(v))) elif mode == 'vars': print(' '.join('$' + k for k, v in parse_env(sys.argv[2]))) elif mode == 'vars_merged':