Description
SecretManager.GetFunctionSecretsAsync has an optional merged parameter. When merged: true, it combines the function-specific keys with the host-level function keys:
|
if (merged) |
|
{ |
|
// If merged is true, we combine function specific keys with host level function keys, |
|
// prioritizing function specific keys |
|
var hostSecrets = await GetHostSecretsAsync(); |
|
functionSecrets = functionSecrets.Union(hostSecrets.FunctionKeys.Where(s => !functionSecrets.ContainsKey(s.Key))) |
|
.ToDictionary(kv => kv.Key, kv => kv.Value, StringComparer.OrdinalIgnoreCase); |
if (merged)
{
// If merged is true, we combine function specific keys with host level function keys,
// prioritizing function specific keys
var hostSecrets = await GetHostSecretsAsync();
functionSecrets = functionSecrets.Union(hostSecrets.FunctionKeys.Where(s => !functionSecrets.ContainsKey(s.Key)))
.ToDictionary(kv => kv.Key, kv => kv.Value, StringComparer.OrdinalIgnoreCase);
}
The final ToDictionary(...) uses StringComparer.OrdinalIgnoreCase, but the !functionSecrets.ContainsKey(s.Key) filter uses the comparer of the functionSecrets instance. When functionSecrets was populated from the startup context cache, it is a plain (case-sensitive) Dictionary<string,string>, because GetFunctionSecretsOrNull passes the deserialized dictionary through without normalizing its comparer:
|
public virtual IDictionary<string, IDictionary<string, string>> GetFunctionSecretsOrNull() |
|
{ |
|
if (Context?.Secrets?.Function != null) |
|
{ |
|
var functionKeys = Context.Secrets.Function.ToDictionary(p => p.Name, p => p.Secrets); |
|
|
|
_logger.LogDebug($"Loaded keys for {functionKeys.Keys.Count} functions from startup context"); |
|
|
|
return functionKeys; |
|
} |
|
|
|
return null; |
|
} |
(Note the host-secrets path a few lines above does normalize to OrdinalIgnoreCase, but the function-secrets path does not.)
Result
If a function-scoped key and a host-scoped function key have names that differ only by case (e.g. function key foo and host function key FOO):
functionSecrets.ContainsKey("FOO") returns false (case-sensitive lookup), so the host key survives the filter.
- The final
.ToDictionary(..., OrdinalIgnoreCase) then receives both foo and FOO, which collide under OrdinalIgnoreCase → ArgumentException: An item with the same key has already been added.
The two key sets live in separate scopes and are deduplicated independently, so nothing prevents this cross-scope name overlap.
Scope / impact
This only affects the merged: true code path. As far as I can tell, no production/API code path calls GetFunctionSecretsAsync with merged: true — all production callers (KeysController, FunctionsSyncManager, the internal authorization-level helper) use the default merged: false. The only callers passing merged: true are unit tests (SecretManagerTests).
Proposed fix
Since the merged behavior isn't used by any production path, the simplest option is to remove the merged parameter and the merge branch entirely (and the tests that exercise it). Alternatively, if the behavior should be retained, make the filter and the final projection use a consistent OrdinalIgnoreCase comparer (e.g. normalize functionSecrets to OrdinalIgnoreCase, mirroring the host-secrets path in GetFunctionSecretsOrNull).
Description
SecretManager.GetFunctionSecretsAsynchas an optionalmergedparameter. Whenmerged: true, it combines the function-specific keys with the host-level function keys:azure-functions-host/src/WebJobs.Script.WebHost/Security/KeyManagement/SecretManager.cs
Lines 318 to 324 in 03797f6
The final
ToDictionary(...)usesStringComparer.OrdinalIgnoreCase, but the!functionSecrets.ContainsKey(s.Key)filter uses the comparer of thefunctionSecretsinstance. WhenfunctionSecretswas populated from the startup context cache, it is a plain (case-sensitive)Dictionary<string,string>, becauseGetFunctionSecretsOrNullpasses the deserialized dictionary through without normalizing its comparer:azure-functions-host/src/WebJobs.Script.WebHost/StartupContextProvider.cs
Lines 82 to 94 in 03797f6
(Note the host-secrets path a few lines above does normalize to
OrdinalIgnoreCase, but the function-secrets path does not.)Result
If a function-scoped key and a host-scoped function key have names that differ only by case (e.g. function key
fooand host function keyFOO):functionSecrets.ContainsKey("FOO")returnsfalse(case-sensitive lookup), so the host key survives the filter..ToDictionary(..., OrdinalIgnoreCase)then receives bothfooandFOO, which collide underOrdinalIgnoreCase→ArgumentException: An item with the same key has already been added.The two key sets live in separate scopes and are deduplicated independently, so nothing prevents this cross-scope name overlap.
Scope / impact
This only affects the
merged: truecode path. As far as I can tell, no production/API code path callsGetFunctionSecretsAsyncwithmerged: true— all production callers (KeysController,FunctionsSyncManager, the internal authorization-level helper) use the defaultmerged: false. The only callers passingmerged: trueare unit tests (SecretManagerTests).Proposed fix
Since the
mergedbehavior isn't used by any production path, the simplest option is to remove themergedparameter and the merge branch entirely (and the tests that exercise it). Alternatively, if the behavior should be retained, make the filter and the final projection use a consistentOrdinalIgnoreCasecomparer (e.g. normalizefunctionSecretstoOrdinalIgnoreCase, mirroring the host-secrets path inGetFunctionSecretsOrNull).