Skip to content

Commit a20b307

Browse files
authored
Improved metadata binding parsing and validation. (#11101)
* Improved metadata binding parsing and validation. * Adding another test input to check `:` in binding value. * Sanitize only subset of bindings. * Improvements. * empty string/null tests. * Switching from Parse to Load and setting DateParseHandling to None by default. * Test improvements. * Linting fixes. * PR feedback updates.
1 parent c33151c commit a20b307

File tree

9 files changed

+345
-18
lines changed

9 files changed

+345
-18
lines changed

release_notes.md

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
1-
### Release notes
2-
3-
<!-- Please add your release notes in the following format:
4-
- My change description (#PR)
5-
-->
6-
- Adding activity sources for Durable and WebJobs (Kafka and RabbitMQ) (#11137)
7-
- Add JitTrace Files for v4.1041
8-
- Fix startup deadlock on transient exceptions (#11142)
9-
- Add warning log for end of support bundle version, any bundle version < 4 (#11075), (#11160)
10-
- Handles loading extensions.json with empty extensions(#11174)
11-
- Update HttpWorkerOptions to implement IOptionsFormatter (#11175)
1+
### Release notes
2+
3+
<!-- Please add your release notes in the following format:
4+
- My change description (#PR)
5+
-->
6+
- Adding activity sources for Durable and WebJobs (Kafka and RabbitMQ) (#11137)
7+
- Add JitTrace Files for v4.1041
8+
- Fix startup deadlock on transient exceptions (#11142)
9+
- Add warning log for end of support bundle version, any bundle version < 4 (#11075), (#11160)
10+
- Handles loading extensions.json with empty extensions(#11174)
11+
- Update HttpWorkerOptions to implement IOptionsFormatter (#11175)
12+
- Improved metadata binding validation (#11101)

src/WebJobs.Script.WebHost/Extensions/FunctionMetadataExtensions.cs

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
using System.IO;
66
using System.Linq;
77
using System.Threading.Tasks;
8+
using Microsoft.Azure.WebJobs.Logging;
89
using Microsoft.Azure.WebJobs.Script.Description;
910
using Microsoft.Azure.WebJobs.Script.Management.Models;
1011
using Microsoft.Azure.WebJobs.Script.WebHost.Management;
@@ -175,7 +176,21 @@ private static async Task<JObject> GetFunctionConfig(FunctionMetadata metadata,
175176

176177
private static async Task<JObject> GetFunctionConfigFromFile(string path)
177178
{
178-
return JObject.Parse(await FileUtility.ReadAsync(path));
179+
var fileContent = await FileUtility.ReadAsync(path);
180+
var jObject = JObject.Parse(fileContent);
181+
182+
if (jObject.TryGetValue("bindings", StringComparison.OrdinalIgnoreCase, out JToken bindingsToken) && bindingsToken is JArray bindingsArray)
183+
{
184+
for (int i = 0; i < bindingsArray.Count; i++)
185+
{
186+
if (bindingsArray[i] is JObject binding)
187+
{
188+
bindingsArray[i] = MetadataJsonHelper.SanitizeProperties(binding, ScriptConstants.SensitiveMetadataBindingPropertyNames);
189+
}
190+
}
191+
}
192+
193+
return jObject;
179194
}
180195

181196
private static JObject GetFunctionConfigFromMetadata(FunctionMetadata metadata)

src/WebJobs.Script/Host/HostFunctionMetadataProvider.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,9 @@ internal static FunctionMetadata ParseFunctionMetadata(string functionName, JObj
135135
{
136136
foreach (JObject binding in bindingArray)
137137
{
138-
BindingMetadata bindingMetadata = BindingMetadata.Create(binding);
138+
var sanitizedJObject = MetadataJsonHelper.SanitizeProperties(binding, ScriptConstants.SensitiveMetadataBindingPropertyNames);
139+
140+
BindingMetadata bindingMetadata = BindingMetadata.Create(sanitizedJObject);
139141
functionMetadata.Bindings.Add(bindingMetadata);
140142
}
141143
}
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
// Copyright (c) .NET Foundation. All rights reserved.
2+
// Licensed under the MIT License. See License.txt in the project root for license information.
3+
4+
using System;
5+
using System.Collections.Immutable;
6+
using System.IO;
7+
using System.Linq;
8+
using Microsoft.Azure.WebJobs.Logging;
9+
using Newtonsoft.Json;
10+
using Newtonsoft.Json.Linq;
11+
12+
namespace Microsoft.Azure.WebJobs.Script
13+
{
14+
internal static class MetadataJsonHelper
15+
{
16+
/// <summary>
17+
/// Sanitizes the values of top-level properties in the specified <see cref="JObject"/>
18+
/// whose names match any in the provided collection, using case-insensitive comparison.
19+
/// The original property casing is preserved.
20+
/// <strong>Note:</strong> This method mutates the input <see cref="JObject"/> only if one or more property values are sanitized.
21+
/// </summary>
22+
/// <param name="jsonObject">The <see cref="JObject"/> to sanitize. This object may be modified in place.</param>
23+
/// <param name="propertyNames">A collection of top-level property names to sanitize.</param>
24+
/// <returns>
25+
/// The modified <see cref="JObject"/> with the specified properties' values sanitized if found.
26+
/// </returns>
27+
/// <exception cref="ArgumentNullException">
28+
/// Thrown if <paramref name="jsonObject"/> or <paramref name="propertyNames"/> is <c>null</c>.
29+
/// </exception>
30+
public static JObject SanitizeProperties(JObject jsonObject, ImmutableHashSet<string> propertyNames)
31+
{
32+
ArgumentNullException.ThrowIfNull(jsonObject, nameof(jsonObject));
33+
ArgumentNullException.ThrowIfNull(propertyNames, nameof(propertyNames));
34+
35+
if (propertyNames.Count == 0)
36+
{
37+
return jsonObject;
38+
}
39+
40+
foreach (var prop in jsonObject.Properties())
41+
{
42+
if (propertyNames.Contains(prop.Name, StringComparer.OrdinalIgnoreCase))
43+
{
44+
if (prop.Value.Type == JTokenType.Null)
45+
{
46+
continue;
47+
}
48+
49+
var valueToSanitize = prop.Value.Type == JTokenType.String ? (string)prop.Value : prop.Value.ToString();
50+
jsonObject[prop.Name] = Sanitizer.Sanitize(valueToSanitize);
51+
}
52+
}
53+
54+
return jsonObject;
55+
}
56+
57+
/// <summary>
58+
/// Parses the input JSON string into a <see cref="JObject"/> and sanitizes the values of top-level properties
59+
/// whose names match any in the provided collection, using case-insensitive comparison.
60+
/// The original property casing is preserved. Allows customization of JSON date parsing behavior.
61+
/// </summary>
62+
/// <param name="json">The JSON string to parse and sanitize.</param>
63+
/// <param name="propertyNames">A collection of top-level property names to sanitize. Case-insensitive matching is used.</param>
64+
/// <param name="dateParseHandling">
65+
/// Specifies how date strings should be parsed. Defaults to <see cref="DateParseHandling.None"/> to avoid automatic date conversion.
66+
/// </param>
67+
/// <returns>
68+
/// A <see cref="JObject"/> representing the parsed JSON, with the specified properties' values sanitized if found.
69+
/// </returns>
70+
/// <exception cref="ArgumentException">
71+
/// Thrown if <paramref name="json"/> is <c>null</c>, empty, or whitespace.
72+
/// </exception>
73+
/// <exception cref="ArgumentNullException">
74+
/// Thrown if <paramref name="propertyNames"/> is <c>null</c>.
75+
/// </exception>
76+
/// <exception cref="JsonReaderException">
77+
/// Thrown if <paramref name="json"/> is not a valid JSON string.
78+
/// </exception>
79+
public static JObject CreateJObjectWithSanitizedPropertyValue(string json, ImmutableHashSet<string> propertyNames, DateParseHandling dateParseHandling = DateParseHandling.None)
80+
{
81+
if (string.IsNullOrWhiteSpace(json))
82+
{
83+
throw new ArgumentException("Input JSON cannot be null or empty.", nameof(json));
84+
}
85+
86+
ArgumentNullException.ThrowIfNull(propertyNames, nameof(propertyNames));
87+
88+
using var stringReader = new StringReader(json);
89+
using var jsonReader = new JsonTextReader(stringReader)
90+
{
91+
DateParseHandling = dateParseHandling
92+
};
93+
94+
var jsonObject = JObject.Load(jsonReader);
95+
96+
return SanitizeProperties(jsonObject, propertyNames);
97+
}
98+
}
99+
}

src/WebJobs.Script/Host/WorkerFunctionMetadataProvider.cs

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,6 @@ internal class WorkerFunctionMetadataProvider : IWorkerFunctionMetadataProvider,
2929
private readonly IEnvironment _environment;
3030
private readonly IWebHostRpcWorkerChannelManager _channelManager;
3131
private readonly IScriptHostManager _scriptHostManager;
32-
private readonly JsonSerializerSettings _dateTimeSerializerSettings;
3332
private string _workerRuntime;
3433
private ImmutableArray<FunctionMetadata> _functions;
3534
private IHost _currentJobHost = null;
@@ -47,7 +46,6 @@ public WorkerFunctionMetadataProvider(
4746
_channelManager = webHostRpcWorkerChannelManager;
4847
_scriptHostManager = scriptHostManager;
4948
_workerRuntime = _environment.GetEnvironmentVariable(EnvironmentSettingNames.FunctionWorkerRuntime);
50-
_dateTimeSerializerSettings = new JsonSerializerSettings { DateParseHandling = DateParseHandling.None };
5149

5250
_scriptHostManager.ActiveHostChanged += OnHostChanged;
5351
}
@@ -289,8 +287,8 @@ internal FunctionMetadata ValidateBindings(IEnumerable<string> rawBindings, Func
289287

290288
foreach (string binding in rawBindings)
291289
{
292-
var deserializedObj = JsonConvert.DeserializeObject<JObject>(binding, _dateTimeSerializerSettings);
293-
var functionBinding = BindingMetadata.Create(deserializedObj);
290+
var sanitizedBinding = MetadataJsonHelper.CreateJObjectWithSanitizedPropertyValue(binding, ScriptConstants.SensitiveMetadataBindingPropertyNames, DateParseHandling.None);
291+
var functionBinding = BindingMetadata.Create(sanitizedBinding);
294292

295293
Utility.ValidateBinding(functionBinding);
296294

src/WebJobs.Script/ScriptConstants.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -267,5 +267,7 @@ public static class ScriptConstants
267267
public static readonly string CancellationTokenRegistration = "CancellationTokenRegistration";
268268

269269
internal const string MasterKeyName = "_master";
270+
271+
public static readonly ImmutableHashSet<string> SensitiveMetadataBindingPropertyNames = ImmutableHashSet.Create(StringComparer.OrdinalIgnoreCase, "connection");
270272
}
271273
}

test/WebJobs.Script.Tests/HostFunctionMetadataProviderTests.cs

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -350,5 +350,59 @@ public void ParseFunctionMetadata_ResolvesCorrectDotNetLanguage(string scriptFil
350350
var metadata = HostFunctionMetadataProvider.ParseFunctionMetadata("Function1", json, scriptRoot, fileSystemMock.Object, workerConfigs, functionsWorkerRuntime);
351351
Assert.Equal(expectedLanguage, metadata.Language);
352352
}
353+
354+
[Fact]
355+
public void ParseFunctionMetadata_MasksSensitiveDataInBindings()
356+
{
357+
const string functionJson = @"{
358+
""scriptFile"": ""app.dll"",
359+
""bindings"": [
360+
{
361+
""name"": ""myQueueItem"",
362+
""type"": ""queueTrigger"",
363+
""direction"": ""in"",
364+
""queueName"": ""test-input-node"",
365+
""connection"": ""DefaultEndpointsProtocol=https;AccountName=a;AccountKey=b/c==;EndpointSuffix=core.windows.net""
366+
},
367+
{
368+
""name"": ""$return"",
369+
""type"": ""queue"",
370+
""direction"": ""out"",
371+
""queueName"": ""test-output-node"",
372+
""connection"": ""MyConnection""
373+
}
374+
]
375+
}";
376+
377+
var json = JObject.Parse(functionJson);
378+
var scriptRoot = Path.Combine(Path.GetTempPath(), Path.GetTempFileName());
379+
380+
var fullFileSystem = new FileSystem();
381+
var fileSystemMock = new Mock<IFileSystem>();
382+
var fileBaseMock = new Mock<FileBase>();
383+
fileSystemMock.Setup(f => f.Path).Returns(fullFileSystem.Path);
384+
fileSystemMock.Setup(f => f.File).Returns(fileBaseMock.Object);
385+
fileBaseMock.Setup(f => f.Exists(It.IsAny<string>())).Returns(true);
386+
387+
IList<RpcWorkerConfig> workerConfigs = [];
388+
389+
var metadata = HostFunctionMetadataProvider.ParseFunctionMetadata("Function1", json, scriptRoot, fileSystemMock.Object, workerConfigs, "custom");
390+
391+
Assert.NotNull(metadata);
392+
Assert.NotNull(metadata.Bindings);
393+
Assert.Equal(2, metadata.Bindings.Count);
394+
395+
// The first binding should have its connection string replaced with "[Hidden Credential]"
396+
var bindingMetadata1 = metadata.Bindings[0];
397+
Assert.Equal("[Hidden Credential]", bindingMetadata1.Connection);
398+
Assert.Equal("[Hidden Credential]", bindingMetadata1.Raw["connection"]!.ToString());
399+
Assert.DoesNotContain("AccountKey", bindingMetadata1.Raw.ToString());
400+
401+
// The second binding should remain unchanged (named connection)
402+
var outputBinding = metadata.Bindings[1];
403+
Assert.Equal("MyConnection", outputBinding.Connection);
404+
Assert.Contains("MyConnection", outputBinding.Raw.ToString());
405+
Assert.DoesNotContain("[Hidden Credential]", outputBinding.Raw["connection"]!.ToString());
406+
}
353407
}
354408
}
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
// Copyright (c) .NET Foundation. All rights reserved.
2+
// Licensed under the MIT License. See License.txt in the project root for license information.
3+
4+
using System;
5+
using System.Collections.Immutable;
6+
using Newtonsoft.Json.Linq;
7+
using Xunit;
8+
9+
namespace Microsoft.Azure.WebJobs.Script.Tests
10+
{
11+
public sealed class MetadataJsonHelperTests
12+
{
13+
[Fact]
14+
public void CreateJObjectWithSanitizedPropertyValue_NullJsonObject_ThrowsArgumentNullException()
15+
{
16+
JObject jsonObject = null;
17+
ImmutableHashSet<string> propertyNames = ["sensitiveProperty"];
18+
19+
Assert.Throws<ArgumentNullException>(() => MetadataJsonHelper.SanitizeProperties(jsonObject, propertyNames));
20+
}
21+
22+
[Fact]
23+
public void CreateJObjectWithSanitizedPropertyValue_NullPropertyNames_ThrowsArgumentNullException()
24+
{
25+
var jsonObject = new JObject();
26+
ImmutableHashSet<string> propertyNames = null;
27+
28+
Assert.Throws<ArgumentNullException>(() => MetadataJsonHelper.SanitizeProperties(jsonObject, propertyNames));
29+
}
30+
31+
[Fact]
32+
public void CreateJObjectWithSanitizedPropertyValue_ValidInput_SanitizesMatchingProperties()
33+
{
34+
var jsonObject = new JObject
35+
{
36+
{ "sensitiveProperty1", "AccountKey=foo" },
37+
{ "sensitiveProperty2", "MyConnection" },
38+
{ "SENSITIVEPROPERTY3", "AccountKey=bar" },
39+
{ "otherProperty", "value2" }
40+
};
41+
var sensitiveBindingPropertyNames = ImmutableHashSet.Create("sensitiveProperty1", "sensitiveproperty2", "sensitiveproperty3");
42+
43+
var result = MetadataJsonHelper.SanitizeProperties(jsonObject, sensitiveBindingPropertyNames);
44+
45+
Assert.Equal("[Hidden Credential]", result["sensitiveProperty1"].ToString());
46+
Assert.Equal("MyConnection", result["sensitiveProperty2"].ToString());
47+
Assert.Equal("[Hidden Credential]", result["SENSITIVEPROPERTY3"].ToString());
48+
Assert.Equal("value2", result["otherProperty"].ToString());
49+
}
50+
51+
[Fact]
52+
public void CreateJObjectWithSanitizedPropertyValue_NoMatchingProperties_DoesNotSanitize()
53+
{
54+
var jsonObject = new JObject
55+
{
56+
{ "otherProperty1", "value1" },
57+
{ "otherProperty2", "value2" },
58+
{ "otherProperty3", "AccountKey=foo" }
59+
};
60+
var sensitiveBindingPropertyNames = ImmutableHashSet.Create("sensitiveProperty");
61+
62+
var result = MetadataJsonHelper.SanitizeProperties(jsonObject, sensitiveBindingPropertyNames);
63+
64+
Assert.Equal("value1", result["otherProperty1"].ToString());
65+
Assert.Equal("value2", result["otherProperty2"].ToString());
66+
Assert.Equal("AccountKey=foo", result["otherProperty3"].ToString());
67+
}
68+
69+
[Fact]
70+
public void CreateJObjectWithSanitizedPropertyValue_StringInput_NullOrEmptyJson_ThrowsArgumentException()
71+
{
72+
string json = null;
73+
var propertyNames = ImmutableHashSet.Create("sensitiveProperty");
74+
75+
Assert.Throws<ArgumentException>(() => MetadataJsonHelper.CreateJObjectWithSanitizedPropertyValue(json, propertyNames));
76+
}
77+
78+
[Fact]
79+
public void CreateJObjectWithSanitizedPropertyValue_StringInput_InvalidJson_ThrowsJsonReaderException()
80+
{
81+
var json = "invalid json";
82+
var propertyNames = ImmutableHashSet.Create("sensitiveProperty");
83+
84+
Assert.Throws<Newtonsoft.Json.JsonReaderException>(() => MetadataJsonHelper.CreateJObjectWithSanitizedPropertyValue(json, propertyNames));
85+
}
86+
87+
[Fact]
88+
public void CreateJObjectWithSanitizedPropertyValue_StringInput_ValidJson_SanitizesMatchingProperties()
89+
{
90+
var json = """{ "SensitiveProperty": "pwd=12345", "otherProperty": "value2" }""";
91+
var propertyNames = ImmutableHashSet.Create("sensitiveproperty");
92+
93+
var result = MetadataJsonHelper.CreateJObjectWithSanitizedPropertyValue(json, propertyNames);
94+
95+
Assert.Equal("[Hidden Credential]", result["SensitiveProperty"].ToString());
96+
Assert.Equal("value2", result["otherProperty"].ToString());
97+
}
98+
99+
[Fact]
100+
public void CreateJObjectWithSanitizedPropertyValue_NullSensitiveProperty_DoesNotThrow()
101+
{
102+
var jsonObject = new JObject
103+
{
104+
{ "connection", null },
105+
{ "otherProperty1", "value1" },
106+
{ "otherProperty2", string.Empty }
107+
};
108+
var propertyNames = ImmutableHashSet.Create("connection", "otherProperty2");
109+
110+
var result = MetadataJsonHelper.SanitizeProperties(jsonObject, propertyNames);
111+
112+
Assert.Equal(JTokenType.Null, result["connection"].Type); // Ensure null remains null
113+
Assert.Equal("value1", result["otherProperty1"].ToString());
114+
Assert.Equal(string.Empty, result["otherProperty2"].ToString()); // Ensure empty string remains empty
115+
}
116+
117+
[Fact]
118+
public void CreateJObjectWithSanitizedPropertyValue_StringInput_DateTimeWithTimezoneOffset_RemainsUnchanged()
119+
{
120+
var json = """{ "timestamp": "2025-07-03T12:30:45+02:00", "otherProperty": "value2" }""";
121+
var propertyNames = ImmutableHashSet.Create("sensitiveProperty");
122+
123+
var result = MetadataJsonHelper.CreateJObjectWithSanitizedPropertyValue(json, propertyNames);
124+
125+
Assert.Equal("2025-07-03T12:30:45+02:00", result["timestamp"].ToObject<string>()); // ensure the value remains unchanged(not parsed as DateTime)
126+
Assert.Equal("value2", result["otherProperty"].ToString());
127+
}
128+
}
129+
}

0 commit comments

Comments
 (0)