Conversation
GET /configuration/startupconfig and GET /settings replace each secret with ApiResponseRedactor.RedactedValue for any caller the redaction gate does not exempt. The settings screen holds that document and posts it back unchanged when the operator saves, so the literal sentinel was written over the stored value and the real secret was lost. SaveProwlarrImportSettingsAsync and the ProwlarrApiKeyEncrypted branch of SaveApplicationSettingsAsync already compare the incoming value against the sentinel and keep what is stored. This applies the same check to the fields that never got it: ApiKey and SslCertPassword on the startup config, and WebhookUrl, DiscordBotToken and the per-webhook Url on the settings row. Only the sentinel is special-cased. Blank still clears the value on these paths, so an operator can still remove a key or a webhook URL, and the tests cover that alongside a plain rotation to a new value. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Keep stored secrets when a save carries the redaction sentinel
What happens to an operator
Open Settings, change any field, save. The instance API key and the SSL certificate password are both gone, replaced by the literal string
REDACTED. The save reports success and the page redraws as normal, so there is nothing to notice at the time.It sticks. The value is on disk in
config.json, so a restart does not help, and a later save from a session that is not affected writes the placeholder again rather than repairing it. From the session that caused it there is no way back at all:RequireApiKeyManagementAccessturns that same caller away from bothGET apikeyandPOST apikey/regenerate, so the key control on the screen comes up empty and a regenerate is refused with a 403 (listenarr.api/Attributes/RequireApiKeyManagementAccessAttribute.cs:49-81). Recovery means reaching the instance some other way, either regenerating from a session that gate does allow or putting the old value back intoconfig.jsonby hand. Regenerating is the easier of the two but it rotates the key, so anything already configured with the old one has to be updated.Not every caller hits this. It needs a request that
HttpSecurityRequestUtils.ShouldRedactSecretsForCalleranswers true for, which is a remote address the helper does not treat as private or loopback, on a request that is not already an authenticated admin or API-key principal (listenarr.api/Security/SecurityRequestHttpContextExtensions.cs:67). The plainest way to reach it is an instance with the login screen off, opened from an address outside the private ranges: a directly exposed instance, or one behind a proxy configured to forward the real client address. With the login screen off,RequireAdminOrApiKeyAttributelets the request through, and nothing else stands between the browser and the save.Why it happens
The settings screen fetches the startup config when it mounts and posts the same document back when the operator saves:
fe/src/views/SettingsView.vue:1380stores whateverGET /configuration/startupconfigreturned.fe/src/views/SettingsView.vue:845spreads that stored document into the outgoing payload and changes onlyauthenticationRequired.fe/src/views/SettingsView.vue:850posts it.For a caller the redaction gate does not exempt, the document fetched in step one is the redacted one:
StartupConfigurationController.cs:75-78runs it throughApiResponseRedactor.RedactStartupConfig, which putsRedactedValueinApiKeyandSslCertPassword(ApiResponseRedactor.cs:89-103). Nothing on the way back in looks at that.ConfigurationService.SaveStartupConfigAsyncpasses the payload tostartupConfigService.SaveAsyncas it stands, and the sentinel is serialised intoconfig.jsonon top of the real value.Two things make it harder to notice:
StartupConfigurationController.cs:105-107), so the client is shownREDACTEDeither way and cannot tell the difference between a successful save and a destroyed key.SettingsView.vue:818reading "If user toggled the authEnabled, attempt to save to startup config", which reads as though the save is conditional. It is not.didEnableAuthanddidDisableAuthare computed a few lines down but only gate what happens to the session afterwards, at lines 907 and 918. The save itself runs on every settings save.I think the settings row has the same shape, though this part is a code reading rather than something I observed.
RedactApplicationSettings(ApiResponseRedactor.cs:57-87) coversWebhookUrl,DiscordBotToken,ProwlarrApiKeyEncryptedand eachWebhooks[].Url, andSettingsController.cs:61applies it behind the same gate. Of those, onlyProwlarrApiKeyEncryptedis checked on the way back in.AdminUsernameandAdminPasswordare nulled rather than given the sentinel, andSaveApplicationSettingsAsyncalready skips provisioning when they are blank, so those two look fine to me.The fix
You already handle this correctly for the Prowlarr key, in two places in the same file:
ConfigurationService.cs:158-162, inSaveApplicationSettingsAsyncConfigurationService.cs:307-311, inSaveProwlarrImportSettingsAsyncBoth compare the incoming value against
ApiResponseRedactor.RedactedValueand keep what is stored when it matches. The other secret-bearing fields never got the same check. This PR adds it, in the same shape and with the same comparison, to:StartupConfig.ApiKeyandStartupConfig.SslCertPasswordinSaveStartupConfigAsync. The current config was already being read a few lines lower for the auth-enable backstop, so the read is hoisted and reused rather than repeated.WebhookUrl,DiscordBotTokenand eachWebhooks[].UrlinSaveApplicationSettingsAsync, next to theProwlarrApiKeyEncryptedcheck that is already there. Webhook URLs are matched back to the stored list byId.This stops the value being destroyed. It does not repair a
config.jsonthat already holds the sentinel, which still needs a regeneration or a hand edit as above.There is one deliberate difference from the two checks that already exist. Those treat blank and the sentinel alike and preserve for both. I have matched only the sentinel, because on these paths a blank value currently clears the field, and an operator has to keep being able to remove an API key or empty a webhook URL. Narrowing it to the sentinel is what the bug calls for and leaves every other behaviour where it was. Happy to widen it to match the neighbouring checks if you would rather the file be uniform.
Tests
Five tests in
tests/Features/Application/Configuration/Core/ConfigurationServiceTests.cs. I build the payloads by calling the realApiResponseRedactorrather than writing"REDACTED"into the test, so they exercise the round trip and not my reading of it.SaveStartupConfig_RedactedSecrets_KeepStoredValuesSaveApplicationSettings_RedactedSecrets_KeepStoredValuesand three controls, which matter more than the two above:
SaveStartupConfig_NewSecrets_ReplaceStoredValuesandSaveApplicationSettings_NewSecrets_ReplaceStoredValues, so the check cannot quietly become "never update these fields". A save carrying a genuinely new key or a new certificate password still has to replace what is stored.SaveStartupConfig_BlankOrAbsentSecrets_AreWrittenThroughUnchanged, covering the narrowing described above. Blank and null still write through and clear the value.With the production change stashed and only the tests applied, the suite is 2 failed, 3121 passed, 130 skipped. With the change, 0 failed, 3123 passed, 130 skipped. The three controls pass in both runs, which is the point of them.
dotnet format --verify-no-changesis clean on both files.Verified on a real install
This was not only measured on a throwaway. An install running this build had its API key destroyed by ordinary use: routine settings saves from a browser on another machine, across two sessions, with nothing unusual being done and nothing to notice at the time.
After deploying the fix to that same install, the same path was run again deliberately:
config.jsonhashed to the same value.config.jsonwas then read directly rather than through the API, because a remote GET returns the placeholder whether or not the stored value survived.The hash after matched the hash before. That is the same value surviving, not merely something that is not the placeholder.
SslCertPasswordand the webhook list were confirmed unchanged on the same pass.One thing worth passing on from doing this. Regenerating from loopback is harder than it looks, because the listener is IPv6-only inside the container and a request from the host to
localhost:4545arrives over the bridge, which the local-address check does not accept. Reachinghttp://[::1]:4545from inside the container is what worked.Every line number above is from
a630572e983614a52ea409a23da52a99e3b8b91b.Disclosure: drafted with Claude Code at my direction; I read the cited code at the stated commit and reviewed this before posting.