Skip to content

Commit f52abed

Browse files
fix: settings-mutating routes preserve masked secrets (C2 + H4 + M3) (#224)
Internal security audit (May 2026): three findings, one root cause. PR #215 added `_strip_masked_values` + `_deep_merge_dict` on the central `/api/web/settings` POST path so a round-trip POST of the masked GET response did not overwrite the on-disk credential with the literal sentinel. The audit then found two OTHER mutation routes that bypassed that pipeline. ### C2 (CRITICAL) — POST /api/storage/config `StorageBackendConfig.post` wholesale-set the per-backend dict: storage['azure_keyvault'] = data.get('azure_keyvault', {}) When the UI POSTed back the masked GET, the sentinel `'********'` rode inside that dict and the deep-merge propagated it to the leaf — overwriting the on-disk credential. Same shape for AWS / Vault / Infisical. Fix: call `_strip_masked_values` BEFORE `atomic_update`, same as the settings POST path. ### H4 (HIGH) — POST /api/notifications/config `s['notifications'] = data` wholesale-replaced the subtree. SMTP `smtp_password` and webhook URLs with embedded auth tokens returned masked on GET were destroyed on the round-trip POST. Toggling a non-secret notifications field (e.g. `enabled`) silently wiped the SMTP password. Fix: strip masked values and deep-merge against the existing on-disk subtree inside the `settings_manager.update` mutator. ### M3 (MEDIUM) — `notifications` missing from `_DEEP_MERGE_SETTINGS_KEYS` Even a future caller using `atomic_update({'notifications': ...})` would shallow-merge and wipe siblings. Adding `notifications` to the deep-merge registry closes the gap for any caller. ### Changes - `modules/core/settings.py` — `_DEEP_MERGE_SETTINGS_KEYS` now includes `notifications`. Comment expanded to point at the audit reasoning. - `modules/api/resources.py::StorageBackendConfig.post` — strip masked values via `_strip_masked_values` before `atomic_update`. - `modules/web/misc_routes.py::api_notifications_config` (POST) — strip masked values, deep-merge against the existing on-disk notifications subtree inside the mutator. ### Tests `tests/test_audit_settings_mutating_routes.py` (new, 9 cases): - Registry: `notifications` is in `_DEEP_MERGE_SETTINGS_KEYS`; `certificate_storage` + `ca_providers` still present (defensive pin so a future refactor cannot silently drop them). - StorageBackendConfig (HTTP integration test): - Round-trip with masked `client_secret` preserves on-disk value. - Genuine rotation (real new value) overwrites. - Sibling backend's secret survives a single-backend update. - Notifications POST (handler-logic level — avoids the state pollution that registering the full misc_routes blueprint causes when paired with the CertificateManager lock tests): - Round-trip with masked `smtp_password` preserves on-disk. - Genuine rotation overwrites. - Empty-string secret preserves (same #215 semantic). - Webhook URL list-merge contract pinned (list overlay replaces wholesale — current behaviour; documented for any future list-merge improvement). Full local unit suite: 901 passed, 2 skipped, 115 deselected.
1 parent 23fc9e4 commit f52abed

4 files changed

Lines changed: 361 additions & 15 deletions

File tree

modules/api/resources.py

Lines changed: 21 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1896,24 +1896,33 @@ def post(self):
18961896
if backend_type not in valid_backends:
18971897
return {'error': 'Invalid backend type'}, 400
18981898

1899-
# Build only the storage subtree and merge atomically so we
1900-
# don't race with concurrent writes or wipe other settings keys.
1901-
current = settings_manager.load_settings()
1902-
storage = dict(current.get('certificate_storage') or {})
1903-
storage['backend'] = backend_type
1904-
1899+
# Build only the storage subtree to merge atomically.
1900+
# Audit C2 (May 2026): the prior version wholesale-set
1901+
# the per-backend dict (e.g. `storage['azure_keyvault']
1902+
# = data.get('azure_keyvault', {})`). When the UI POSTs
1903+
# the round-trip from GET /api/web/settings, the masked
1904+
# sentinel `'********'` rides inside that dict and the
1905+
# deep-merge propagates it down to the leaf — overwriting
1906+
# the on-disk credential with the literal sentinel. The
1907+
# fix is the same shape PR #215 applied to the settings
1908+
# POST path: strip the sentinel BEFORE the merge so any
1909+
# masked / empty secret field falls back to its on-disk
1910+
# value, only genuinely-changed values overwrite.
1911+
from ..core.settings import _strip_masked_values
1912+
storage_update = {'backend': backend_type}
19051913
if backend_type == 'local_filesystem':
1906-
storage['cert_dir'] = data.get('cert_dir', 'certificates')
1914+
storage_update['cert_dir'] = data.get('cert_dir', 'certificates')
19071915
elif backend_type == 'azure_keyvault':
1908-
storage['azure_keyvault'] = data.get('azure_keyvault', {})
1916+
storage_update['azure_keyvault'] = data.get('azure_keyvault', {})
19091917
elif backend_type == 'aws_secrets_manager':
1910-
storage['aws_secrets_manager'] = data.get('aws_secrets_manager', {})
1918+
storage_update['aws_secrets_manager'] = data.get('aws_secrets_manager', {})
19111919
elif backend_type == 'hashicorp_vault':
1912-
storage['hashicorp_vault'] = data.get('hashicorp_vault', {})
1920+
storage_update['hashicorp_vault'] = data.get('hashicorp_vault', {})
19131921
elif backend_type == 'infisical':
1914-
storage['infisical'] = data.get('infisical', {})
1922+
storage_update['infisical'] = data.get('infisical', {})
19151923

1916-
success = settings_manager.atomic_update({'certificate_storage': storage})
1924+
clean_payload = _strip_masked_values({'certificate_storage': storage_update})
1925+
success = settings_manager.atomic_update(clean_payload)
19171926

19181927
if success:
19191928
return {

modules/core/settings.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -223,11 +223,22 @@ def _strip_masked_values(payload):
223223
# Top-level settings keys whose value is a nested dict that should be
224224
# deep-merged rather than wholesale-replaced on save. Each of these stores
225225
# multiple secret-bearing subtrees (per-backend storage credentials, per-CA
226-
# EAB credentials), so the user editing one field in the UI must not blow
227-
# away the others or the previously-saved secret for the same field.
226+
# EAB credentials, per-channel notification credentials), so the user
227+
# editing one field in the UI must not blow away the others or the
228+
# previously-saved secret for the same field.
229+
#
230+
# Audit finding M3 (May 2026): `notifications` was originally absent
231+
# from this list. The dedicated notifications POST route in
232+
# `modules/web/misc_routes.py` wholesale-replaced the subtree, so the
233+
# masked-sentinel + sibling-preservation logic that PR #215 added for
234+
# `certificate_storage` / `ca_providers` did not protect SMTP and
235+
# webhook credentials. Including `notifications` here AND routing the
236+
# misc_routes POST through `_strip_masked_values` + `_deep_merge_dict`
237+
# (the same shape as the settings POST path) closes the gap.
228238
_DEEP_MERGE_SETTINGS_KEYS = frozenset({
229239
'certificate_storage',
230240
'ca_providers',
241+
'notifications',
231242
})
232243

233244

modules/web/misc_routes.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -184,8 +184,25 @@ def api_notifications_config():
184184
if not isinstance(data, dict):
185185
return jsonify({'error': 'Body must be a JSON object'}), 400
186186

187+
# Audit H4 (May 2026): the prior `s['notifications'] = data`
188+
# wholesale-replaced the subtree. The UI round-trips a GET
189+
# response — where SMTP `smtp_password` and webhook URLs
190+
# arrive masked as `'********'` — back into POST, and the
191+
# plain assignment overwrote real on-disk secrets with the
192+
# literal sentinel. Strip the sentinel BEFORE the write
193+
# (same shape PR #215 + audit C2 fix applied elsewhere)
194+
# and deep-merge against the existing notifications block
195+
# so a partial UI submit (e.g. toggling `enabled` without
196+
# re-typing the SMTP password) does not destroy siblings.
197+
from modules.core.settings import _strip_masked_values, _deep_merge_dict
198+
clean_data = _strip_masked_values(data)
199+
187200
def _mutator(s):
188-
s['notifications'] = data
201+
existing = s.get('notifications')
202+
if isinstance(existing, dict) and isinstance(clean_data, dict):
203+
s['notifications'] = _deep_merge_dict(existing, clean_data)
204+
else:
205+
s['notifications'] = clean_data
189206
return s
190207

191208
settings_manager.update(_mutator)

0 commit comments

Comments
 (0)