Skip to content

Improved metadata binding parsing and validation. #11101

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 12 commits into from
Jul 15, 2025
23 changes: 12 additions & 11 deletions release_notes.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
### Release notes

<!-- Please add your release notes in the following format:
- My change description (#PR)
-->
- Adding activity sources for Durable and WebJobs (Kafka and RabbitMQ) (#11137)
- Add JitTrace Files for v4.1041
- Fix startup deadlock on transient exceptions (#11142)
- Add warning log for end of support bundle version, any bundle version < 4 (#11075), (#11160)
- Handles loading extensions.json with empty extensions(#11174)
- Update HttpWorkerOptions to implement IOptionsFormatter (#11175)
### Release notes

<!-- Please add your release notes in the following format:
- My change description (#PR)
-->
- Adding activity sources for Durable and WebJobs (Kafka and RabbitMQ) (#11137)
- Add JitTrace Files for v4.1041
- Fix startup deadlock on transient exceptions (#11142)
- Add warning log for end of support bundle version, any bundle version < 4 (#11075), (#11160)
- Handles loading extensions.json with empty extensions(#11174)
- Update HttpWorkerOptions to implement IOptionsFormatter (#11175)
- Improved metadata binding validation (#11101)
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.Azure.WebJobs.Logging;
using Microsoft.Azure.WebJobs.Script.Description;
using Microsoft.Azure.WebJobs.Script.Management.Models;
using Microsoft.Azure.WebJobs.Script.WebHost.Management;
Expand Down Expand Up @@ -175,7 +176,21 @@ private static async Task<JObject> GetFunctionConfig(FunctionMetadata metadata,

private static async Task<JObject> GetFunctionConfigFromFile(string path)
{
return JObject.Parse(await FileUtility.ReadAsync(path));
var fileContent = await FileUtility.ReadAsync(path);
var jObject = JObject.Parse(fileContent);

if (jObject.TryGetValue("bindings", StringComparison.OrdinalIgnoreCase, out JToken bindingsToken) && bindingsToken is JArray bindingsArray)
{
for (int i = 0; i < bindingsArray.Count; i++)
{
if (bindingsArray[i] is JObject binding)
{
bindingsArray[i] = MetadataJsonHelper.SanitizeProperties(binding, ScriptConstants.SensitiveMetadataBindingPropertyNames);
}
}
}

return jObject;
}

private static JObject GetFunctionConfigFromMetadata(FunctionMetadata metadata)
Expand Down
4 changes: 3 additions & 1 deletion src/WebJobs.Script/Host/HostFunctionMetadataProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,9 @@ internal static FunctionMetadata ParseFunctionMetadata(string functionName, JObj
{
foreach (JObject binding in bindingArray)
{
BindingMetadata bindingMetadata = BindingMetadata.Create(binding);
var sanitizedJObject = MetadataJsonHelper.SanitizeProperties(binding, ScriptConstants.SensitiveMetadataBindingPropertyNames);

BindingMetadata bindingMetadata = BindingMetadata.Create(sanitizedJObject);
functionMetadata.Bindings.Add(bindingMetadata);
}
}
Expand Down
99 changes: 99 additions & 0 deletions src/WebJobs.Script/Host/MetadataJsonHelper.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the MIT License. See License.txt in the project root for license information.

using System;
using System.Collections.Immutable;
using System.IO;
using System.Linq;
using Microsoft.Azure.WebJobs.Logging;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;

namespace Microsoft.Azure.WebJobs.Script
{
internal static class MetadataJsonHelper
{
/// <summary>
/// Sanitizes the values of top-level properties in the specified <see cref="JObject"/>
/// whose names match any in the provided collection, using case-insensitive comparison.
/// The original property casing is preserved.
/// <strong>Note:</strong> This method mutates the input <see cref="JObject"/> only if one or more property values are sanitized.
/// </summary>
/// <param name="jsonObject">The <see cref="JObject"/> to sanitize. This object may be modified in place.</param>
/// <param name="propertyNames">A collection of top-level property names to sanitize.</param>
/// <returns>
/// The modified <see cref="JObject"/> with the specified properties' values sanitized if found.
/// </returns>
/// <exception cref="ArgumentNullException">
/// Thrown if <paramref name="jsonObject"/> or <paramref name="propertyNames"/> is <c>null</c>.
/// </exception>
public static JObject SanitizeProperties(JObject jsonObject, ImmutableHashSet<string> propertyNames)
{
ArgumentNullException.ThrowIfNull(jsonObject, nameof(jsonObject));
ArgumentNullException.ThrowIfNull(propertyNames, nameof(propertyNames));

if (propertyNames.Count == 0)
{
return jsonObject;
}

foreach (var prop in jsonObject.Properties())
{
if (propertyNames.Contains(prop.Name, StringComparer.OrdinalIgnoreCase))
{
if (prop.Value.Type == JTokenType.Null)
{
continue;
}

var valueToSanitize = prop.Value.Type == JTokenType.String ? (string)prop.Value : prop.Value.ToString();
jsonObject[prop.Name] = Sanitizer.Sanitize(valueToSanitize);
}
}

return jsonObject;
}

/// <summary>
/// Parses the input JSON string into a <see cref="JObject"/> and sanitizes the values of top-level properties
/// whose names match any in the provided collection, using case-insensitive comparison.
/// The original property casing is preserved. Allows customization of JSON date parsing behavior.
/// </summary>
/// <param name="json">The JSON string to parse and sanitize.</param>
/// <param name="propertyNames">A collection of top-level property names to sanitize. Case-insensitive matching is used.</param>
/// <param name="dateParseHandling">
/// Specifies how date strings should be parsed. Defaults to <see cref="DateParseHandling.None"/> to avoid automatic date conversion.
/// </param>
/// <returns>
/// A <see cref="JObject"/> representing the parsed JSON, with the specified properties' values sanitized if found.
/// </returns>
/// <exception cref="ArgumentException">
/// Thrown if <paramref name="json"/> is <c>null</c>, empty, or whitespace.
/// </exception>
/// <exception cref="ArgumentNullException">
/// Thrown if <paramref name="propertyNames"/> is <c>null</c>.
/// </exception>
/// <exception cref="JsonReaderException">
/// Thrown if <paramref name="json"/> is not a valid JSON string.
/// </exception>
public static JObject CreateJObjectWithSanitizedPropertyValue(string json, ImmutableHashSet<string> propertyNames, DateParseHandling dateParseHandling = DateParseHandling.None)
{
if (string.IsNullOrWhiteSpace(json))
{
throw new ArgumentException("Input JSON cannot be null or empty.", nameof(json));
}

ArgumentNullException.ThrowIfNull(propertyNames, nameof(propertyNames));

using var stringReader = new StringReader(json);
using var jsonReader = new JsonTextReader(stringReader)
{
DateParseHandling = dateParseHandling
};

var jsonObject = JObject.Load(jsonReader);

return SanitizeProperties(jsonObject, propertyNames);
}
}
}
6 changes: 2 additions & 4 deletions src/WebJobs.Script/Host/WorkerFunctionMetadataProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@ internal class WorkerFunctionMetadataProvider : IWorkerFunctionMetadataProvider,
private readonly IEnvironment _environment;
private readonly IWebHostRpcWorkerChannelManager _channelManager;
private readonly IScriptHostManager _scriptHostManager;
private readonly JsonSerializerSettings _dateTimeSerializerSettings;
private string _workerRuntime;
private ImmutableArray<FunctionMetadata> _functions;
private IHost _currentJobHost = null;
Expand All @@ -47,7 +46,6 @@ public WorkerFunctionMetadataProvider(
_channelManager = webHostRpcWorkerChannelManager;
_scriptHostManager = scriptHostManager;
_workerRuntime = _environment.GetEnvironmentVariable(EnvironmentSettingNames.FunctionWorkerRuntime);
_dateTimeSerializerSettings = new JsonSerializerSettings { DateParseHandling = DateParseHandling.None };

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

foreach (string binding in rawBindings)
{
var deserializedObj = JsonConvert.DeserializeObject<JObject>(binding, _dateTimeSerializerSettings);
var functionBinding = BindingMetadata.Create(deserializedObj);
var sanitizedBinding = MetadataJsonHelper.CreateJObjectWithSanitizedPropertyValue(binding, ScriptConstants.SensitiveMetadataBindingPropertyNames, DateParseHandling.None);
var functionBinding = BindingMetadata.Create(sanitizedBinding);

Utility.ValidateBinding(functionBinding);

Expand Down
2 changes: 2 additions & 0 deletions src/WebJobs.Script/ScriptConstants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -267,5 +267,7 @@ public static class ScriptConstants
public static readonly string CancellationTokenRegistration = "CancellationTokenRegistration";

internal const string MasterKeyName = "_master";

public static readonly ImmutableHashSet<string> SensitiveMetadataBindingPropertyNames = ImmutableHashSet.Create(StringComparer.OrdinalIgnoreCase, "connection");
}
}
54 changes: 54 additions & 0 deletions test/WebJobs.Script.Tests/HostFunctionMetadataProviderTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -350,5 +350,59 @@ public void ParseFunctionMetadata_ResolvesCorrectDotNetLanguage(string scriptFil
var metadata = HostFunctionMetadataProvider.ParseFunctionMetadata("Function1", json, scriptRoot, fileSystemMock.Object, workerConfigs, functionsWorkerRuntime);
Assert.Equal(expectedLanguage, metadata.Language);
}

[Fact]
public void ParseFunctionMetadata_MasksSensitiveDataInBindings()
{
const string functionJson = @"{
""scriptFile"": ""app.dll"",
""bindings"": [
{
""name"": ""myQueueItem"",
""type"": ""queueTrigger"",
""direction"": ""in"",
""queueName"": ""test-input-node"",
""connection"": ""DefaultEndpointsProtocol=https;AccountName=a;AccountKey=b/c==;EndpointSuffix=core.windows.net""
},
{
""name"": ""$return"",
""type"": ""queue"",
""direction"": ""out"",
""queueName"": ""test-output-node"",
""connection"": ""MyConnection""
}
]
}";

var json = JObject.Parse(functionJson);
var scriptRoot = Path.Combine(Path.GetTempPath(), Path.GetTempFileName());

var fullFileSystem = new FileSystem();
var fileSystemMock = new Mock<IFileSystem>();
var fileBaseMock = new Mock<FileBase>();
fileSystemMock.Setup(f => f.Path).Returns(fullFileSystem.Path);
fileSystemMock.Setup(f => f.File).Returns(fileBaseMock.Object);
fileBaseMock.Setup(f => f.Exists(It.IsAny<string>())).Returns(true);

IList<RpcWorkerConfig> workerConfigs = [];

var metadata = HostFunctionMetadataProvider.ParseFunctionMetadata("Function1", json, scriptRoot, fileSystemMock.Object, workerConfigs, "custom");

Assert.NotNull(metadata);
Assert.NotNull(metadata.Bindings);
Assert.Equal(2, metadata.Bindings.Count);

// The first binding should have its connection string replaced with "[Hidden Credential]"
var bindingMetadata1 = metadata.Bindings[0];
Assert.Equal("[Hidden Credential]", bindingMetadata1.Connection);
Assert.Equal("[Hidden Credential]", bindingMetadata1.Raw["connection"]!.ToString());
Assert.DoesNotContain("AccountKey", bindingMetadata1.Raw.ToString());

// The second binding should remain unchanged (named connection)
var outputBinding = metadata.Bindings[1];
Assert.Equal("MyConnection", outputBinding.Connection);
Assert.Contains("MyConnection", outputBinding.Raw.ToString());
Assert.DoesNotContain("[Hidden Credential]", outputBinding.Raw["connection"]!.ToString());
}
}
}
129 changes: 129 additions & 0 deletions test/WebJobs.Script.Tests/MetadataJsonHelperTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the MIT License. See License.txt in the project root for license information.

using System;
using System.Collections.Immutable;
using Newtonsoft.Json.Linq;
using Xunit;

namespace Microsoft.Azure.WebJobs.Script.Tests
{
public sealed class MetadataJsonHelperTests
{
[Fact]
public void CreateJObjectWithSanitizedPropertyValue_NullJsonObject_ThrowsArgumentNullException()
{
JObject jsonObject = null;
ImmutableHashSet<string> propertyNames = ["sensitiveProperty"];

Assert.Throws<ArgumentNullException>(() => MetadataJsonHelper.SanitizeProperties(jsonObject, propertyNames));
}

[Fact]
public void CreateJObjectWithSanitizedPropertyValue_NullPropertyNames_ThrowsArgumentNullException()
{
var jsonObject = new JObject();
ImmutableHashSet<string> propertyNames = null;

Assert.Throws<ArgumentNullException>(() => MetadataJsonHelper.SanitizeProperties(jsonObject, propertyNames));
}

[Fact]
public void CreateJObjectWithSanitizedPropertyValue_ValidInput_SanitizesMatchingProperties()
{
var jsonObject = new JObject
{
{ "sensitiveProperty1", "AccountKey=foo" },
{ "sensitiveProperty2", "MyConnection" },
{ "SENSITIVEPROPERTY3", "AccountKey=bar" },
{ "otherProperty", "value2" }
};
var sensitiveBindingPropertyNames = ImmutableHashSet.Create("sensitiveProperty1", "sensitiveproperty2", "sensitiveproperty3");

var result = MetadataJsonHelper.SanitizeProperties(jsonObject, sensitiveBindingPropertyNames);

Assert.Equal("[Hidden Credential]", result["sensitiveProperty1"].ToString());
Assert.Equal("MyConnection", result["sensitiveProperty2"].ToString());
Assert.Equal("[Hidden Credential]", result["SENSITIVEPROPERTY3"].ToString());
Assert.Equal("value2", result["otherProperty"].ToString());
}

[Fact]
public void CreateJObjectWithSanitizedPropertyValue_NoMatchingProperties_DoesNotSanitize()
{
var jsonObject = new JObject
{
{ "otherProperty1", "value1" },
{ "otherProperty2", "value2" },
{ "otherProperty3", "AccountKey=foo" }
};
var sensitiveBindingPropertyNames = ImmutableHashSet.Create("sensitiveProperty");

var result = MetadataJsonHelper.SanitizeProperties(jsonObject, sensitiveBindingPropertyNames);

Assert.Equal("value1", result["otherProperty1"].ToString());
Assert.Equal("value2", result["otherProperty2"].ToString());
Assert.Equal("AccountKey=foo", result["otherProperty3"].ToString());
}

[Fact]
public void CreateJObjectWithSanitizedPropertyValue_StringInput_NullOrEmptyJson_ThrowsArgumentException()
{
string json = null;
var propertyNames = ImmutableHashSet.Create("sensitiveProperty");

Assert.Throws<ArgumentException>(() => MetadataJsonHelper.CreateJObjectWithSanitizedPropertyValue(json, propertyNames));
}

[Fact]
public void CreateJObjectWithSanitizedPropertyValue_StringInput_InvalidJson_ThrowsJsonReaderException()
{
var json = "invalid json";
var propertyNames = ImmutableHashSet.Create("sensitiveProperty");

Assert.Throws<Newtonsoft.Json.JsonReaderException>(() => MetadataJsonHelper.CreateJObjectWithSanitizedPropertyValue(json, propertyNames));
}

[Fact]
public void CreateJObjectWithSanitizedPropertyValue_StringInput_ValidJson_SanitizesMatchingProperties()
{
var json = """{ "SensitiveProperty": "pwd=12345", "otherProperty": "value2" }""";
var propertyNames = ImmutableHashSet.Create("sensitiveproperty");

var result = MetadataJsonHelper.CreateJObjectWithSanitizedPropertyValue(json, propertyNames);

Assert.Equal("[Hidden Credential]", result["SensitiveProperty"].ToString());
Assert.Equal("value2", result["otherProperty"].ToString());
}

[Fact]
public void CreateJObjectWithSanitizedPropertyValue_NullSensitiveProperty_DoesNotThrow()
{
var jsonObject = new JObject
{
{ "connection", null },
{ "otherProperty1", "value1" },
{ "otherProperty2", string.Empty }
};
var propertyNames = ImmutableHashSet.Create("connection", "otherProperty2");

var result = MetadataJsonHelper.SanitizeProperties(jsonObject, propertyNames);

Assert.Equal(JTokenType.Null, result["connection"].Type); // Ensure null remains null
Assert.Equal("value1", result["otherProperty1"].ToString());
Assert.Equal(string.Empty, result["otherProperty2"].ToString()); // Ensure empty string remains empty
}

[Fact]
public void CreateJObjectWithSanitizedPropertyValue_StringInput_DateTimeWithTimezoneOffset_RemainsUnchanged()
{
var json = """{ "timestamp": "2025-07-03T12:30:45+02:00", "otherProperty": "value2" }""";
var propertyNames = ImmutableHashSet.Create("sensitiveProperty");

var result = MetadataJsonHelper.CreateJObjectWithSanitizedPropertyValue(json, propertyNames);

Assert.Equal("2025-07-03T12:30:45+02:00", result["timestamp"].ToObject<string>()); // ensure the value remains unchanged(not parsed as DateTime)
Assert.Equal("value2", result["otherProperty"].ToString());
}
}
}
Loading