fix(backup): refuse the whole LDAP family on restore, not just its password (#2656)

A settings restore could substitute the instance'"'"'s authentication source.
auth.py reads the LDAP config live from the settings table on every
login, and none of ldap_server_url, ldap_user_filter, ldap_auto_provision
or ldap_default_group is credential-shaped, so the secret-key hints never
saw them and only the four auth-policy keys were protected.

ldap_enabled was covered by the companion-credential rule instead, and
that rule asks the wrong question. It judges availability - "will the
integration still work?" - and an anonymous bind works, so a payload that
simply OMITS ldap_bind_password skips the refusal and has its toggle
written. Omitting the credential is exactly what an attacker authoring
the file would do: they own the directory being pointed at, so they need
no bind credential from us.

Left unrefused, a backup repository anyone can write to yields admin:
point ldap_server_url at your own directory, set ldap_auto_provision and
ldap_default_group=Administrators, and the next login on a fresh username
is provisioned into the admin group. Overwrite-off is enough on an
instance that never configured LDAP - there are no rows to skip.

Refused by prefix so a key added to the LDAP schema later is refused by
default, and matched case-insensitively because the key comes from the
backup JSON rather than from our own writer. ldap_enabled leaves
_COMPANION_CREDENTIALS rather than sitting there as dead code, since
_is_protected_setting_key runs first.

The two tests asserting an anonymous bind was a false positive are
inverted - they encoded the hole - and the refusal reuses the existing
settingsAuthSkipped note, which already points at Settings >
Authentication.
This commit is contained in:
jmoore-skild
2026-08-05 20:05:23 -04:00
parent 0bd0fb206d
commit f62e907e9f
2 changed files with 135 additions and 16 deletions
+36 -5
View File
@@ -108,9 +108,10 @@ _MQTT_SETTING_KEYS = {
# * bypass the lockout refusals ``update_settings`` enforces (a
# ``local_login_enabled=false`` with no enabled OIDC provider, or with no
# OIDC link on the caller, is a 400 there — #1589).
# * cross a permission boundary: /github-backup/restore is gated on
# GITHUB_RESTORE alone, so this would be a way to rewrite auth config
# without SETTINGS_UPDATE.
# * cross a permission boundary: a restore would be a way to rewrite auth
# config without SETTINGS_UPDATE. (The endpoint gates each category on the
# permission owning its rows now, but that is settings:update — still not
# the auth UI's own guards, which is what these keys actually need.)
#
# Auth is reconfigured through the auth UI, which has the guards. Restoring it
# from a snapshot has no safe reading.
@@ -121,6 +122,25 @@ _PROTECTED_SETTING_KEYS = {
"setup_completed",
}
# The LDAP family, refused for the same reason and by prefix rather than by
# name, so a key added to the schema later is refused by default.
#
# These are not "how the instance behaves" settings — together they name *which
# directory server decides who you are*. auth.py reads them live from this table
# on every login (see the ldap_keys list in _get_ldap_settings), so a restore
# that writes them substitutes the authentication source wholesale:
# ldap_server_url points at another directory, ldap_auto_provision creates a
# local account for whoever it vouches for, and ldap_default_group decides what
# that account gets — Administrators, if the backup says so.
#
# The companion rule does NOT cover this, which is the trap. ldap_enabled is
# paired with ldap_bind_password there, but an *anonymous* bind is a working
# config, so a backup that simply omits the password skips the refusal at the
# _COMPANION_EXPOSURE_TOGGLES check and the toggle is written. Omitting a
# credential is exactly what an attacker authoring this file would do — they own
# the directory being pointed at, so they need no bind credential from us.
_PROTECTED_SETTING_PREFIXES = ("ldap_",)
# Nozzle diameters the backup collector iterates. A path outside this set means
# the backup was written by a newer version, so accept it rather than dropping
# data, but keep the list for validation messages.
@@ -175,7 +195,11 @@ def _is_blocked_setting_key(key: str) -> bool:
def _is_protected_setting_key(key: str) -> bool:
return key in _PROTECTED_SETTING_KEYS
# Lowered for the prefix test for the same reason _is_blocked_setting_key
# lowers: the key comes from the backup's JSON, not from our own writer, so
# its casing is whatever the file says. An exact-match name stays exact —
# those four are ours and are only ever written lowercase.
return key in _PROTECTED_SETTING_KEYS or key.lower().startswith(_PROTECTED_SETTING_PREFIXES)
# There used to be an ``_is_skipped_setting_key`` here, the union of the two
@@ -202,9 +226,16 @@ def _is_protected_setting_key(key: str) -> bool:
# virtual_printer_enabled is largely vestigial post-migration — core/database.py
# copies the rows into the virtual_printers table — but it is the same shape, and
# refusing a vestigial toggle is a harmless no-op.
#
# ldap_enabled is deliberately NOT here. It was, paired with
# ldap_bind_password — but this rule judges availability ("will the integration
# work?"), and that is the wrong question for an authentication source. An
# anonymous bind is a working config, so the pair let a backup omit the password
# and have the toggle written; the whole LDAP family is refused by prefix above
# instead. _is_protected_setting_key runs first in _plan_settings, so leaving the
# entry here would be dead code that reads like coverage.
_COMPANION_CREDENTIALS = {
"prometheus_enabled": "prometheus_token",
"ldap_enabled": "ldap_bind_password",
"mqtt_enabled": "mqtt_password",
"ha_enabled": "ha_token",
"virtual_printer_enabled": "virtual_printer_access_code",