fix(config): unwrap quoted secrets that podman-compose passes through

bcrypt hashes and most OIDC client secrets contain `$`, so `.env.example`
tells operators to single-quote them or Docker Compose eats the `$`. Docker
Compose then strips those quotes itself when reading `env_file`, and so does
python-dotenv on the `env_file=` path — but podman-compose (1.0.6) forwards
them verbatim. The container receives `'$2b$12$...'`, quotes included, every
bcrypt verify fails, and the logged error blames the operator's hash.

Unwrap one matched pair of surrounding quotes from SECRET_KEY,
AUTH_PASSWORD_HASH and OIDC_CLIENT_SECRET before validating them, so the same
.env works under either container runtime. Only a matched pair is removed: a
value with a single stray quote still reaches the existing validation instead
of being silently patched up, and SECRET_KEY is unwrapped before the 32-byte
OIDC floor is checked so quotes cannot pad a short key past it.

Fixes #410

ha-relevant: no
This commit is contained in:
Pouzor
2026-09-04 13:40:25 +02:00
committed by Pouzor - Rémy Jardient
parent 4e8d841807
commit 6b11e7ed34
3 changed files with 82 additions and 0 deletions
+2
View File
@@ -19,6 +19,8 @@ AUTH_MODE=local
# ⚠️ Change before exposing on a network.
# Generate a new hash: python3 -c "import bcrypt; print(bcrypt.hashpw(b'yourpassword', bcrypt.gensalt()).decode())"
# ⚠️ Keep the single quotes around the hash — bcrypt hashes contain \$ which Docker misinterprets without them.
# podman-compose does not strip those quotes the way Docker Compose does, so the
# backend unwraps one matched pair itself — quoted or not, both work everywhere.
AUTH_USERNAME=admin
AUTH_PASSWORD_HASH='$2b$12$RtMbyw17l4N5UGzeXMNAWuzCaVV.XFBY7ZetWheQhxcBDcxahapkG'
+26
View File
@@ -37,6 +37,26 @@ def app_base_path_of(redirect_uri: str) -> str:
base = path[: -len(OIDC_CALLBACK_PATH)]
return f"{base}/" if base else "/"
def unquote_env(value: str) -> str:
"""
Drop one matched pair of surrounding quotes from an env value.
Secrets that contain `$` (bcrypt hashes, most OIDC client secrets) have to be
single-quoted in `.env`, or Docker Compose eats the `$`. Compose then strips
those quotes itself when it reads `env_file`, and so does python-dotenv on the
`env_file=` path — but `podman-compose` (1.0.6) passes them through verbatim,
so the container receives `'$2b$12$…'`, quotes included, and every bcrypt
verify fails. Unwrap here rather than asking operators to quote differently
per container runtime.
Only a *matched* pair is removed, so a genuinely malformed value still reaches
the validation below instead of being silently patched up.
"""
if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'":
return value[1:-1]
return value
def _read_version() -> str:
for candidate in [
Path(__file__).parent.parent.parent.parent / "VERSION", # repo root (dev)
@@ -81,6 +101,12 @@ class Settings(BaseSettings):
@model_validator(mode="after")
def validate_auth_settings(self) -> "Settings":
# Runtime-dependent quoting: see unquote_env. Applied to the three values
# that routinely contain a `$` and so get quoted in .env.
self.secret_key = unquote_env(self.secret_key)
self.auth_password_hash = unquote_env(self.auth_password_hash)
self.oidc_client_secret = unquote_env(self.oidc_client_secret)
h = self.auth_password_hash
if h and not h.startswith("$2"):
logger.error(
+54
View File
@@ -255,3 +255,57 @@ def test_valid_oidc_configuration_is_accepted():
configured = _valid_oidc_settings()
assert configured.auth_mode == "oidc"
assert configured.oidc_cookie_secure is True
# --- Env values arriving still quoted (podman-compose, issue #410) ---
def test_quoted_password_hash_is_unwrapped():
"""podman-compose passes .env quotes through verbatim; Docker Compose strips them."""
configured = Settings(
_env_file=None,
secret_key="test-secret",
auth_password_hash="'$2b$12$RtMbyw17l4N5UGzeXMNAWuzCaVV.XFBY7ZetWheQhxcBDcxahapkG'",
)
assert configured.auth_password_hash == "$2b$12$RtMbyw17l4N5UGzeXMNAWuzCaVV.XFBY7ZetWheQhxcBDcxahapkG"
def test_double_quoted_password_hash_is_unwrapped():
configured = Settings(
_env_file=None,
secret_key="test-secret",
auth_password_hash='"$2b$12$RtMbyw17l4N5UGzeXMNAWuzCaVV.XFBY7ZetWheQhxcBDcxahapkG"',
)
assert configured.auth_password_hash.startswith("$2b$12$")
def test_unquoted_password_hash_is_left_alone():
raw = "$2b$12$RtMbyw17l4N5UGzeXMNAWuzCaVV.XFBY7ZetWheQhxcBDcxahapkG"
configured = Settings(_env_file=None, secret_key="test-secret", auth_password_hash=raw)
assert configured.auth_password_hash == raw
def test_unmatched_quote_is_not_stripped():
"""Only a matched pair is unwrapped, so a truly broken value still fails validation."""
configured = Settings(
_env_file=None,
secret_key="test-secret",
auth_password_hash="'$2b$12$RtMbyw17l4N5UGzeXMNAWu",
)
assert configured.auth_password_hash.startswith("'")
def test_quoted_secret_key_is_unwrapped():
configured = _valid_oidc_settings(secret_key="'test-secret-key-that-is-at-least-32-bytes'")
assert configured.secret_key == "test-secret-key-that-is-at-least-32-bytes"
def test_secret_key_length_is_checked_after_unwrapping():
"""The quotes must not pad a too-short key past the 32-byte OIDC floor."""
with pytest.raises(ValueError, match="SECRET_KEY must be at least 32 bytes"):
_valid_oidc_settings(secret_key="'" + "a" * 31 + "'")
def test_quoted_oidc_client_secret_is_unwrapped():
configured = _valid_oidc_settings(oidc_client_secret="'sup3r$ecret'")
assert configured.oidc_client_secret == "sup3r$ecret"