From a17073c211529f00c1a9a4ff0be1017ace5ba5d4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 18 Jun 2026 23:55:46 +0000 Subject: [PATCH 1/9] Send SQL Server xml parameters as SqlXml (fix #38429) Co-authored-by: AndriySvyryd <6539701+AndriySvyryd@users.noreply.github.com> --- .../Internal/SqlServerStringTypeMapping.cs | 20 +++++ .../Internal/SqlServerTypeMappingSource.cs | 2 +- .../XmlTypeMappingSqlServerTest.cs | 76 +++++++++++++++++++ .../Storage/SqlServerTypeMappingTest.cs | 32 ++++++++ 4 files changed, 129 insertions(+), 1 deletion(-) create mode 100644 test/EFCore.SqlServer.FunctionalTests/XmlTypeMappingSqlServerTest.cs diff --git a/src/EFCore.SqlServer/Storage/Internal/SqlServerStringTypeMapping.cs b/src/EFCore.SqlServer/Storage/Internal/SqlServerStringTypeMapping.cs index f28f1d15523..7a6485278b8 100644 --- a/src/EFCore.SqlServer/Storage/Internal/SqlServerStringTypeMapping.cs +++ b/src/EFCore.SqlServer/Storage/Internal/SqlServerStringTypeMapping.cs @@ -2,7 +2,9 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Data; +using System.Data.SqlTypes; using System.Text; +using System.Xml; using Microsoft.Data.SqlClient; using Microsoft.EntityFrameworkCore.Storage.Json; @@ -155,6 +157,24 @@ protected override void ConfigureParameter(DbParameter parameter) sqlParameter.SqlDbType = _sqlDbType.Value; } + if (_sqlDbType == SqlDbType.Xml) + { + // SqlClient only sends a parameter as 'xml' (rather than 'nvarchar(max)') when its value is a SqlXml. + // Sending it as 'xml' lets SQL Server honor any encoding declared in the XML prolog (e.g. utf-8), which + // otherwise fails with "unable to switch the encoding". A fragment-conformant reader is used so that the + // content forms that the 'xml' store type accepts - empty strings, text, and multiple top-level nodes - + // continue to round-trip. + if (value is string xml + && parameter is SqlParameter xmlParameter) + { + using var reader = XmlReader.Create( + new StringReader(xml), new XmlReaderSettings { ConformanceLevel = ConformanceLevel.Fragment }); + xmlParameter.Value = new SqlXml(reader); + } + + return; + } + if ((value == null || value == DBNull.Value) || (IsFixedLength diff --git a/src/EFCore.SqlServer/Storage/Internal/SqlServerTypeMappingSource.cs b/src/EFCore.SqlServer/Storage/Internal/SqlServerTypeMappingSource.cs index 2523a347c8f..b0fc6184acc 100644 --- a/src/EFCore.SqlServer/Storage/Internal/SqlServerTypeMappingSource.cs +++ b/src/EFCore.SqlServer/Storage/Internal/SqlServerTypeMappingSource.cs @@ -120,7 +120,7 @@ private static readonly GuidTypeMapping Uniqueidentifier = new("uniqueidentifier"); private static readonly SqlServerStringTypeMapping Xml - = new("xml", unicode: true, storeTypePostfix: StoreTypePostfix.None); + = new("xml", unicode: true, sqlDbType: SqlDbType.Xml, storeTypePostfix: StoreTypePostfix.None); private static readonly Dictionary _clrTypeMappings; diff --git a/test/EFCore.SqlServer.FunctionalTests/XmlTypeMappingSqlServerTest.cs b/test/EFCore.SqlServer.FunctionalTests/XmlTypeMappingSqlServerTest.cs new file mode 100644 index 00000000000..528a698a62d --- /dev/null +++ b/test/EFCore.SqlServer.FunctionalTests/XmlTypeMappingSqlServerTest.cs @@ -0,0 +1,76 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.EntityFrameworkCore; + +#nullable disable + +public class XmlTypeMappingSqlServerTest : IAsyncLifetime +{ + private readonly IServiceProvider _serviceProvider = new ServiceCollection() + .AddEntityFrameworkSqlServer() + .BuildServiceProvider(validateScopes: true); + + // The grinning-face emoji is outside the BMP and is lost when an xml value is sent to the server as a + // non-Unicode string, which is what makes it a good probe for the SqlXml/SqlDbType.Xml parameter path. + private const string Emoji = "\U0001F600"; + + [Theory] + [InlineData("" + Emoji + "", "" + Emoji + "")] + // An explicit non-UTF-16 prolog is accepted because the value is sent as 'xml', not 'nvarchar(max)'. + [InlineData("" + Emoji + "", "" + Emoji + "")] + [InlineData("a", "a")] + // Content forms that the 'xml' store type accepts beyond a single well-formed document. + [InlineData("", "")] + [InlineData("text fragment", "text fragment")] + [InlineData("", "")] + public async Task Xml_value_round_trips(string value, string expected) + { + int id; + await using (var context = new XmlContext(_serviceProvider, TestStore.Name)) + { + await context.Database.EnsureCreatedResilientlyAsync(); + var document = new XmlDocument { Content = value }; + context.Documents.Add(document); + await context.SaveChangesAsync(); + id = document.Id; + } + + await using (var context = new XmlContext(_serviceProvider, TestStore.Name)) + { + // xml columns cannot be used in a WHERE comparison, so the row is fetched by its key. + var roundTripped = (await context.Documents.SingleAsync(d => d.Id == id)).Content; + Assert.Equal(expected, roundTripped); + } + } + + private class XmlContext(IServiceProvider serviceProvider, string databaseName) : DbContext + { + private readonly IServiceProvider _serviceProvider = serviceProvider; + private readonly string _databaseName = databaseName; + + public DbSet Documents { get; set; } + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder + .UseSqlServer(SqlServerTestStore.CreateConnectionString(_databaseName), b => b.ApplyConfiguration()) + .UseInternalServiceProvider(_serviceProvider); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + => modelBuilder.Entity().Property(e => e.Content).HasColumnType("xml"); + } + + private class XmlDocument + { + public int Id { get; set; } + public string Content { get; set; } + } + + protected SqlServerTestStore TestStore { get; private set; } + + public async ValueTask InitializeAsync() + => TestStore = await SqlServerTestStore.CreateInitializedAsync("XmlTypeMappingSqlServerTest"); + + public async ValueTask DisposeAsync() + => await TestStore.DisposeAsync(); +} diff --git a/test/EFCore.SqlServer.Tests/Storage/SqlServerTypeMappingTest.cs b/test/EFCore.SqlServer.Tests/Storage/SqlServerTypeMappingTest.cs index 1657cda4b61..a03c31ab396 100644 --- a/test/EFCore.SqlServer.Tests/Storage/SqlServerTypeMappingTest.cs +++ b/test/EFCore.SqlServer.Tests/Storage/SqlServerTypeMappingTest.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Data; +using System.Data.SqlTypes; using Microsoft.Data.SqlClient; using Microsoft.Data.SqlTypes; using Microsoft.EntityFrameworkCore.Design.Internal; @@ -378,6 +379,37 @@ public virtual void Char_Utf8() Assert.Equal(DbType.String, parameter.DbType); } + [Theory] + [InlineData("a")] + [InlineData("a")] + [InlineData("")] + [InlineData("text fragment")] + [InlineData("")] + public virtual void Xml_parameter_is_sent_as_SqlXml(string value) + { + var mapping = GetMapping("xml"); + Assert.Equal("xml", mapping.StoreType); + + using var command = CreateTestCommand(); + var parameter = (SqlParameter)mapping.CreateParameter(command, "foo", value); + + Assert.Equal(SqlDbType.Xml, parameter.SqlDbType); + var sqlXml = Assert.IsType(parameter.Value); + Assert.False(sqlXml.IsNull); + } + + [Fact] + public virtual void Xml_null_parameter_is_sent_as_SqlDbType_Xml() + { + var mapping = GetMapping("xml"); + + using var command = CreateTestCommand(); + var parameter = (SqlParameter)mapping.CreateParameter(command, "foo", null, nullable: true); + + Assert.Equal(SqlDbType.Xml, parameter.SqlDbType); + Assert.Equal(DBNull.Value, parameter.Value); + } + [Fact] public virtual void DateOnly_code_literal_generated_correctly() { From 4601cb3bcafcf3d1381d1dd718360beabbe6da00 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 19 Jun 2026 00:00:24 +0000 Subject: [PATCH 2/9] Address review: cache XmlReaderSettings, use nameof for store Co-authored-by: AndriySvyryd <6539701+AndriySvyryd@users.noreply.github.com> --- .../Storage/Internal/SqlServerStringTypeMapping.cs | 5 +++-- .../XmlTypeMappingSqlServerTest.cs | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/EFCore.SqlServer/Storage/Internal/SqlServerStringTypeMapping.cs b/src/EFCore.SqlServer/Storage/Internal/SqlServerStringTypeMapping.cs index 7a6485278b8..2f82d74550c 100644 --- a/src/EFCore.SqlServer/Storage/Internal/SqlServerStringTypeMapping.cs +++ b/src/EFCore.SqlServer/Storage/Internal/SqlServerStringTypeMapping.cs @@ -23,6 +23,8 @@ public class SqlServerStringTypeMapping : StringTypeMapping private static readonly CaseInsensitiveValueComparer CaseInsensitiveValueComparer = new(); + private static readonly XmlReaderSettings XmlFragmentSettings = new() { ConformanceLevel = ConformanceLevel.Fragment }; + private readonly bool _isUtf16; private readonly SqlDbType? _sqlDbType; private readonly int _maxSpecificSize; @@ -167,8 +169,7 @@ protected override void ConfigureParameter(DbParameter parameter) if (value is string xml && parameter is SqlParameter xmlParameter) { - using var reader = XmlReader.Create( - new StringReader(xml), new XmlReaderSettings { ConformanceLevel = ConformanceLevel.Fragment }); + using var reader = XmlReader.Create(new StringReader(xml), XmlFragmentSettings); xmlParameter.Value = new SqlXml(reader); } diff --git a/test/EFCore.SqlServer.FunctionalTests/XmlTypeMappingSqlServerTest.cs b/test/EFCore.SqlServer.FunctionalTests/XmlTypeMappingSqlServerTest.cs index 528a698a62d..8e2d6ad5c26 100644 --- a/test/EFCore.SqlServer.FunctionalTests/XmlTypeMappingSqlServerTest.cs +++ b/test/EFCore.SqlServer.FunctionalTests/XmlTypeMappingSqlServerTest.cs @@ -69,7 +69,7 @@ private class XmlDocument protected SqlServerTestStore TestStore { get; private set; } public async ValueTask InitializeAsync() - => TestStore = await SqlServerTestStore.CreateInitializedAsync("XmlTypeMappingSqlServerTest"); + => TestStore = await SqlServerTestStore.CreateInitializedAsync(nameof(XmlTypeMappingSqlServerTest)); public async ValueTask DisposeAsync() => await TestStore.DisposeAsync(); From 19ee474fafa6d2050e75da58127069f7a62fed8f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 19 Jun 2026 01:06:53 +0000 Subject: [PATCH 3/9] Move Xml_value_round_trips to BuiltInDataTypesSqlServerTest; harden XmlReader Co-authored-by: AndriySvyryd <6539701+AndriySvyryd@users.noreply.github.com> --- .../Internal/SqlServerStringTypeMapping.cs | 10 +- .../BuiltInDataTypesSqlServerTest.cs | 101 ++++++++++++++++++ .../XmlTypeMappingSqlServerTest.cs | 76 ------------- 3 files changed, 110 insertions(+), 77 deletions(-) delete mode 100644 test/EFCore.SqlServer.FunctionalTests/XmlTypeMappingSqlServerTest.cs diff --git a/src/EFCore.SqlServer/Storage/Internal/SqlServerStringTypeMapping.cs b/src/EFCore.SqlServer/Storage/Internal/SqlServerStringTypeMapping.cs index 2f82d74550c..1c552f8a767 100644 --- a/src/EFCore.SqlServer/Storage/Internal/SqlServerStringTypeMapping.cs +++ b/src/EFCore.SqlServer/Storage/Internal/SqlServerStringTypeMapping.cs @@ -23,7 +23,15 @@ public class SqlServerStringTypeMapping : StringTypeMapping private static readonly CaseInsensitiveValueComparer CaseInsensitiveValueComparer = new(); - private static readonly XmlReaderSettings XmlFragmentSettings = new() { ConformanceLevel = ConformanceLevel.Fragment }; + // Secure by default: DTD processing is prohibited and no external resolver is used so that a malicious + // payload (e.g. an XXE external entity reference or a "billion laughs" entity-expansion document) is + // rejected rather than processed. + private static readonly XmlReaderSettings XmlFragmentSettings = new() + { + ConformanceLevel = ConformanceLevel.Fragment, + DtdProcessing = DtdProcessing.Prohibit, + XmlResolver = null + }; private readonly bool _isUtf16; private readonly SqlDbType? _sqlDbType; diff --git a/test/EFCore.SqlServer.FunctionalTests/BuiltInDataTypesSqlServerTest.cs b/test/EFCore.SqlServer.FunctionalTests/BuiltInDataTypesSqlServerTest.cs index 1ac0dc92916..2d8dbb48f95 100644 --- a/test/EFCore.SqlServer.FunctionalTests/BuiltInDataTypesSqlServerTest.cs +++ b/test/EFCore.SqlServer.FunctionalTests/BuiltInDataTypesSqlServerTest.cs @@ -4,6 +4,7 @@ using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; using System.Globalization; +using System.Xml; using Microsoft.Data.SqlClient; // ReSharper disable InconsistentNaming @@ -3816,6 +3817,106 @@ FROM INFORMATION_SCHEMA.COLUMNS return actual; } + // The grinning-face emoji is outside the BMP and is lost when an xml value is sent to the server as a + // non-Unicode string, which is what makes it a good probe for the SqlXml/SqlDbType.Xml parameter path. + private const string XmlEmoji = "\U0001F600"; + + [Theory] + [InlineData("" + XmlEmoji + "", "" + XmlEmoji + "")] + // An explicit non-UTF-16 prolog is accepted because the value is sent as 'xml', not 'nvarchar(max)'. + [InlineData("" + XmlEmoji + "", "" + XmlEmoji + "")] + [InlineData("a", "a")] + // Content forms that the 'xml' store type accepts beyond a single well-formed document. + [InlineData("", "")] + [InlineData("text fragment", "text fragment")] + [InlineData("", "")] + public async Task Xml_value_round_trips(string value, string expected) + { + await using var testStore = await SqlServerTestStore.CreateInitializedAsync("XmlValueRoundTrips"); + var serviceProvider = new ServiceCollection() + .AddEntityFrameworkSqlServer() + .BuildServiceProvider(validateScopes: true); + + int id; + await using (var context = new XmlContext(serviceProvider, testStore.Name)) + { + await context.Database.EnsureCreatedResilientlyAsync(); + var document = new XmlDocument { Content = value }; + context.Documents.Add(document); + await context.SaveChangesAsync(); + id = document.Id; + } + + await using (var context = new XmlContext(serviceProvider, testStore.Name)) + { + // xml columns cannot be used in a WHERE comparison, so the row is fetched by its key. + var roundTripped = (await context.Documents.SingleAsync(d => d.Id == id)).Content; + Assert.Equal(expected, roundTripped); + } + } + + [Fact] + public async Task Xml_value_with_dtd_payload_is_rejected() + { + await using var testStore = await SqlServerTestStore.CreateInitializedAsync("XmlValueRoundTrips"); + var serviceProvider = new ServiceCollection() + .AddEntityFrameworkSqlServer() + .BuildServiceProvider(validateScopes: true); + + await using var context = new XmlContext(serviceProvider, testStore.Name); + await context.Database.EnsureCreatedResilientlyAsync(); + + // A "billion laughs" entity-expansion payload: the reader must reject the DTD rather than expand it. + const string maliciousXml = + "" + + "" + + "" + + "]>" + + "&lol3;"; + + context.Documents.Add(new XmlDocument { Content = maliciousXml }); + + var exception = await Assert.ThrowsAnyAsync(() => context.SaveChangesAsync()); + Assert.True( + HasXmlException(exception), + $"Expected an {nameof(XmlException)} in the exception chain but found: {exception}"); + + static bool HasXmlException(Exception exception) + { + for (var current = exception; current is not null; current = current.InnerException) + { + if (current is XmlException) + { + return true; + } + } + + return false; + } + } + + private class XmlContext(IServiceProvider serviceProvider, string databaseName) : DbContext + { + private readonly IServiceProvider _serviceProvider = serviceProvider; + private readonly string _databaseName = databaseName; + + public DbSet Documents { get; set; } + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder + .UseSqlServer(SqlServerTestStore.CreateConnectionString(_databaseName), b => b.ApplyConfiguration()) + .UseInternalServiceProvider(_serviceProvider); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + => modelBuilder.Entity().Property(e => e.Content).HasColumnType("xml"); + } + + private class XmlDocument + { + public int Id { get; set; } + public string Content { get; set; } + } + private void AssertSql(params string[] expected) => Fixture.TestSqlLoggerFactory.AssertBaseline(expected); diff --git a/test/EFCore.SqlServer.FunctionalTests/XmlTypeMappingSqlServerTest.cs b/test/EFCore.SqlServer.FunctionalTests/XmlTypeMappingSqlServerTest.cs deleted file mode 100644 index 8e2d6ad5c26..00000000000 --- a/test/EFCore.SqlServer.FunctionalTests/XmlTypeMappingSqlServerTest.cs +++ /dev/null @@ -1,76 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -namespace Microsoft.EntityFrameworkCore; - -#nullable disable - -public class XmlTypeMappingSqlServerTest : IAsyncLifetime -{ - private readonly IServiceProvider _serviceProvider = new ServiceCollection() - .AddEntityFrameworkSqlServer() - .BuildServiceProvider(validateScopes: true); - - // The grinning-face emoji is outside the BMP and is lost when an xml value is sent to the server as a - // non-Unicode string, which is what makes it a good probe for the SqlXml/SqlDbType.Xml parameter path. - private const string Emoji = "\U0001F600"; - - [Theory] - [InlineData("" + Emoji + "", "" + Emoji + "")] - // An explicit non-UTF-16 prolog is accepted because the value is sent as 'xml', not 'nvarchar(max)'. - [InlineData("" + Emoji + "", "" + Emoji + "")] - [InlineData("a", "a")] - // Content forms that the 'xml' store type accepts beyond a single well-formed document. - [InlineData("", "")] - [InlineData("text fragment", "text fragment")] - [InlineData("", "")] - public async Task Xml_value_round_trips(string value, string expected) - { - int id; - await using (var context = new XmlContext(_serviceProvider, TestStore.Name)) - { - await context.Database.EnsureCreatedResilientlyAsync(); - var document = new XmlDocument { Content = value }; - context.Documents.Add(document); - await context.SaveChangesAsync(); - id = document.Id; - } - - await using (var context = new XmlContext(_serviceProvider, TestStore.Name)) - { - // xml columns cannot be used in a WHERE comparison, so the row is fetched by its key. - var roundTripped = (await context.Documents.SingleAsync(d => d.Id == id)).Content; - Assert.Equal(expected, roundTripped); - } - } - - private class XmlContext(IServiceProvider serviceProvider, string databaseName) : DbContext - { - private readonly IServiceProvider _serviceProvider = serviceProvider; - private readonly string _databaseName = databaseName; - - public DbSet Documents { get; set; } - - protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) - => optionsBuilder - .UseSqlServer(SqlServerTestStore.CreateConnectionString(_databaseName), b => b.ApplyConfiguration()) - .UseInternalServiceProvider(_serviceProvider); - - protected override void OnModelCreating(ModelBuilder modelBuilder) - => modelBuilder.Entity().Property(e => e.Content).HasColumnType("xml"); - } - - private class XmlDocument - { - public int Id { get; set; } - public string Content { get; set; } - } - - protected SqlServerTestStore TestStore { get; private set; } - - public async ValueTask InitializeAsync() - => TestStore = await SqlServerTestStore.CreateInitializedAsync(nameof(XmlTypeMappingSqlServerTest)); - - public async ValueTask DisposeAsync() - => await TestStore.DisposeAsync(); -} From 16d73b946ff0416ac631838a3c7a314a7d9db677 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 19 Jun 2026 01:10:56 +0000 Subject: [PATCH 4/9] Rename test entity to XmlTestDocument and share store name constant Co-authored-by: AndriySvyryd <6539701+AndriySvyryd@users.noreply.github.com> --- .../BuiltInDataTypesSqlServerTest.cs | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/test/EFCore.SqlServer.FunctionalTests/BuiltInDataTypesSqlServerTest.cs b/test/EFCore.SqlServer.FunctionalTests/BuiltInDataTypesSqlServerTest.cs index 2d8dbb48f95..eb804bf1269 100644 --- a/test/EFCore.SqlServer.FunctionalTests/BuiltInDataTypesSqlServerTest.cs +++ b/test/EFCore.SqlServer.FunctionalTests/BuiltInDataTypesSqlServerTest.cs @@ -3821,6 +3821,8 @@ FROM INFORMATION_SCHEMA.COLUMNS // non-Unicode string, which is what makes it a good probe for the SqlXml/SqlDbType.Xml parameter path. private const string XmlEmoji = "\U0001F600"; + private const string XmlTestStoreName = "XmlValueRoundTrips"; + [Theory] [InlineData("" + XmlEmoji + "", "" + XmlEmoji + "")] // An explicit non-UTF-16 prolog is accepted because the value is sent as 'xml', not 'nvarchar(max)'. @@ -3832,7 +3834,7 @@ FROM INFORMATION_SCHEMA.COLUMNS [InlineData("", "")] public async Task Xml_value_round_trips(string value, string expected) { - await using var testStore = await SqlServerTestStore.CreateInitializedAsync("XmlValueRoundTrips"); + await using var testStore = await SqlServerTestStore.CreateInitializedAsync(XmlTestStoreName); var serviceProvider = new ServiceCollection() .AddEntityFrameworkSqlServer() .BuildServiceProvider(validateScopes: true); @@ -3841,7 +3843,7 @@ public async Task Xml_value_round_trips(string value, string expected) await using (var context = new XmlContext(serviceProvider, testStore.Name)) { await context.Database.EnsureCreatedResilientlyAsync(); - var document = new XmlDocument { Content = value }; + var document = new XmlTestDocument { Content = value }; context.Documents.Add(document); await context.SaveChangesAsync(); id = document.Id; @@ -3858,7 +3860,7 @@ public async Task Xml_value_round_trips(string value, string expected) [Fact] public async Task Xml_value_with_dtd_payload_is_rejected() { - await using var testStore = await SqlServerTestStore.CreateInitializedAsync("XmlValueRoundTrips"); + await using var testStore = await SqlServerTestStore.CreateInitializedAsync(XmlTestStoreName); var serviceProvider = new ServiceCollection() .AddEntityFrameworkSqlServer() .BuildServiceProvider(validateScopes: true); @@ -3874,7 +3876,7 @@ public async Task Xml_value_with_dtd_payload_is_rejected() + "]>" + "&lol3;"; - context.Documents.Add(new XmlDocument { Content = maliciousXml }); + context.Documents.Add(new XmlTestDocument { Content = maliciousXml }); var exception = await Assert.ThrowsAnyAsync(() => context.SaveChangesAsync()); Assert.True( @@ -3900,7 +3902,7 @@ private class XmlContext(IServiceProvider serviceProvider, string databaseName) private readonly IServiceProvider _serviceProvider = serviceProvider; private readonly string _databaseName = databaseName; - public DbSet Documents { get; set; } + public DbSet Documents { get; set; } protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) => optionsBuilder @@ -3908,10 +3910,10 @@ protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) .UseInternalServiceProvider(_serviceProvider); protected override void OnModelCreating(ModelBuilder modelBuilder) - => modelBuilder.Entity().Property(e => e.Content).HasColumnType("xml"); + => modelBuilder.Entity().Property(e => e.Content).HasColumnType("xml"); } - private class XmlDocument + private class XmlTestDocument { public int Id { get; set; } public string Content { get; set; } From 97293d714f3364335f33569ce2ce4e181cecfe1a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 19 Jun 2026 02:14:42 +0000 Subject: [PATCH 5/9] Address review: fixture-based xml tests, N-prefixed xml literal, GenerateNonNullSqlLiteral test Co-authored-by: AndriySvyryd <6539701+AndriySvyryd@users.noreply.github.com> --- .../Internal/SqlServerStringTypeMapping.cs | 6 +- .../BuiltInDataTypesSqlServerTest.cs | 115 ++++++++++-------- .../Storage/SqlServerStringTypeMappingTest.cs | 14 +++ 3 files changed, 83 insertions(+), 52 deletions(-) diff --git a/src/EFCore.SqlServer/Storage/Internal/SqlServerStringTypeMapping.cs b/src/EFCore.SqlServer/Storage/Internal/SqlServerStringTypeMapping.cs index 1c552f8a767..acb3daa48a5 100644 --- a/src/EFCore.SqlServer/Storage/Internal/SqlServerStringTypeMapping.cs +++ b/src/EFCore.SqlServer/Storage/Internal/SqlServerStringTypeMapping.cs @@ -24,8 +24,7 @@ public class SqlServerStringTypeMapping : StringTypeMapping private static readonly CaseInsensitiveValueComparer CaseInsensitiveValueComparer = new(); // Secure by default: DTD processing is prohibited and no external resolver is used so that a malicious - // payload (e.g. an XXE external entity reference or a "billion laughs" entity-expansion document) is - // rejected rather than processed. + // payload is rejected rather than processed. private static readonly XmlReaderSettings XmlFragmentSettings = new() { ConformanceLevel = ConformanceLevel.Fragment, @@ -122,8 +121,9 @@ protected SqlServerStringTypeMapping(RelationalTypeMappingParameters parameters, _maxSize = AnsiMax; } - _isUtf16 = parameters.Unicode && parameters.StoreType.StartsWith("n", StringComparison.OrdinalIgnoreCase); _sqlDbType = sqlDbType; + _isUtf16 = parameters.Unicode + && (parameters.StoreType.StartsWith("n", StringComparison.OrdinalIgnoreCase) || _sqlDbType == SqlDbType.Xml); } /// diff --git a/test/EFCore.SqlServer.FunctionalTests/BuiltInDataTypesSqlServerTest.cs b/test/EFCore.SqlServer.FunctionalTests/BuiltInDataTypesSqlServerTest.cs index eb804bf1269..272d9dd0024 100644 --- a/test/EFCore.SqlServer.FunctionalTests/BuiltInDataTypesSqlServerTest.cs +++ b/test/EFCore.SqlServer.FunctionalTests/BuiltInDataTypesSqlServerTest.cs @@ -3817,56 +3817,87 @@ FROM INFORMATION_SCHEMA.COLUMNS return actual; } - // The grinning-face emoji is outside the BMP and is lost when an xml value is sent to the server as a - // non-Unicode string, which is what makes it a good probe for the SqlXml/SqlDbType.Xml parameter path. + // The grinning-face emoji is outside the BMP (a UTF-16 surrogate pair, four UTF-8 bytes) and the euro sign + // is a single UTF-16 code unit but three UTF-8 bytes; both are represented differently in UTF-16 than in + // UTF-8 and are lost when an xml value is sent to the server as a non-Unicode string, which makes them good + // probes for the SqlXml/SqlDbType.Xml parameter path. private const string XmlEmoji = "\U0001F600"; - - private const string XmlTestStoreName = "XmlValueRoundTrips"; + private const string XmlEuro = "\u20AC"; [Theory] - [InlineData("" + XmlEmoji + "", "" + XmlEmoji + "")] + [InlineData("" + XmlEmoji + XmlEuro + "", "" + XmlEmoji + XmlEuro + "")] // An explicit non-UTF-16 prolog is accepted because the value is sent as 'xml', not 'nvarchar(max)'. [InlineData("" + XmlEmoji + "", "" + XmlEmoji + "")] - [InlineData("a", "a")] + [InlineData("" + XmlEuro + "", "" + XmlEuro + "")] // Content forms that the 'xml' store type accepts beyond a single well-formed document. [InlineData("", "")] [InlineData("text fragment", "text fragment")] [InlineData("", "")] public async Task Xml_value_round_trips(string value, string expected) { - await using var testStore = await SqlServerTestStore.CreateInitializedAsync(XmlTestStoreName); - var serviceProvider = new ServiceCollection() - .AddEntityFrameworkSqlServer() - .BuildServiceProvider(validateScopes: true); + await using var context = CreateContext(); - int id; - await using (var context = new XmlContext(serviceProvider, testStore.Name)) - { - await context.Database.EnsureCreatedResilientlyAsync(); - var document = new XmlTestDocument { Content = value }; - context.Documents.Add(document); - await context.SaveChangesAsync(); - id = document.Id; - } + var document = new XmlTestDocument { Content = value }; + context.Add(document); + await context.SaveChangesAsync(); - await using (var context = new XmlContext(serviceProvider, testStore.Name)) - { - // xml columns cannot be used in a WHERE comparison, so the row is fetched by its key. - var roundTripped = (await context.Documents.SingleAsync(d => d.Id == id)).Content; - Assert.Equal(expected, roundTripped); - } + var id = document.Id; + context.ChangeTracker.Clear(); + + // xml columns cannot be compared directly in a WHERE clause, so the row is fetched by its key. + var roundTripped = (await context.Set().SingleAsync(d => d.Id == id)).Content; + Assert.Equal(expected, roundTripped); } [Fact] - public async Task Xml_value_with_dtd_payload_is_rejected() + public async Task Xml_value_can_be_inserted_and_filtered() { - await using var testStore = await SqlServerTestStore.CreateInitializedAsync(XmlTestStoreName); - var serviceProvider = new ServiceCollection() - .AddEntityFrameworkSqlServer() - .BuildServiceProvider(validateScopes: true); + await using var context = CreateContext(); + + var content = "" + XmlEmoji + ""; + context.Add(new XmlTestDocument { Content = content }); + await context.SaveChangesAsync(); + + // xml columns cannot be compared directly, so the value is converted to nvarchar(max) before filtering. + var query = context.Set().Where(d => Convert.ToString(d.Content) == content); - await using var context = new XmlContext(serviceProvider, testStore.Name); - await context.Database.EnsureCreatedResilientlyAsync(); + Assert.Equal( + """ +DECLARE @content nvarchar(4000) = N'😀'; + +SELECT [x].[Id], [x].[Content] +FROM [XmlTestDocument] AS [x] +WHERE CONVERT(nvarchar(max), [x].[Content]) = @content +""", + query.ToQueryString(), + ignoreLineEndingDifferences: true); + + Assert.Equal(1, await query.CountAsync()); + + AssertSql( + """ +@p0='😀' (DbType = Xml) + +SET IMPLICIT_TRANSACTIONS OFF; +SET NOCOUNT ON; +INSERT INTO [XmlTestDocument] ([Content]) +OUTPUT INSERTED.[Id] +VALUES (@p0); +""", + // + """ +@content='😀' (Size = 4000) + +SELECT COUNT(*) +FROM [XmlTestDocument] AS [x] +WHERE CONVERT(nvarchar(max), [x].[Content]) = @content +"""); + } + + [Fact] + public async Task Xml_value_with_dtd_payload_is_rejected() + { + await using var context = CreateContext(); // A "billion laughs" entity-expansion payload: the reader must reject the DTD rather than expand it. const string maliciousXml = @@ -3876,7 +3907,7 @@ public async Task Xml_value_with_dtd_payload_is_rejected() + "]>" + "&lol3;"; - context.Documents.Add(new XmlTestDocument { Content = maliciousXml }); + context.Add(new XmlTestDocument { Content = maliciousXml }); var exception = await Assert.ThrowsAnyAsync(() => context.SaveChangesAsync()); Assert.True( @@ -3897,22 +3928,6 @@ static bool HasXmlException(Exception exception) } } - private class XmlContext(IServiceProvider serviceProvider, string databaseName) : DbContext - { - private readonly IServiceProvider _serviceProvider = serviceProvider; - private readonly string _databaseName = databaseName; - - public DbSet Documents { get; set; } - - protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) - => optionsBuilder - .UseSqlServer(SqlServerTestStore.CreateConnectionString(_databaseName), b => b.ApplyConfiguration()) - .UseInternalServiceProvider(_serviceProvider); - - protected override void OnModelCreating(ModelBuilder modelBuilder) - => modelBuilder.Entity().Property(e => e.Content).HasColumnType("xml"); - } - private class XmlTestDocument { public int Id { get; set; } @@ -4000,6 +4015,8 @@ protected override void OnModelCreating(ModelBuilder modelBuilder, DbContext con b.Property(e => e.DecimalAsDec52).HasPrecision(7, 3); }); + modelBuilder.Entity().Property(e => e.Content).HasColumnType("xml"); + MakeRequired(modelBuilder); MakeRequired(modelBuilder); MakeRequired(modelBuilder); diff --git a/test/EFCore.SqlServer.Tests/Storage/SqlServerStringTypeMappingTest.cs b/test/EFCore.SqlServer.Tests/Storage/SqlServerStringTypeMappingTest.cs index 41db30f7291..138334e4f38 100644 --- a/test/EFCore.SqlServer.Tests/Storage/SqlServerStringTypeMappingTest.cs +++ b/test/EFCore.SqlServer.Tests/Storage/SqlServerStringTypeMappingTest.cs @@ -1,6 +1,8 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Data; + namespace Microsoft.EntityFrameworkCore.SqlServer.Storage.Internal; public class SqlServerStringTypeMappingTest @@ -37,4 +39,16 @@ public void GenerateProviderValueSqlLiteral_works_unicode(string value, string e var mapping = new SqlServerStringTypeMapping("nvarchar(max)", unicode: true); Assert.Equal(expected, mapping.GenerateProviderValueSqlLiteral(value)); } + + [Theory] + [InlineData("a", "N'a'")] + [InlineData("\U0001F600", "N'\U0001F600'")] + [InlineData("I'm", "N'I''m'")] + [InlineData("", "N''")] + public void GenerateProviderValueSqlLiteral_works_xml(string value, string expected) + { + var mapping = new SqlServerStringTypeMapping( + "xml", unicode: true, sqlDbType: SqlDbType.Xml, storeTypePostfix: StoreTypePostfix.None); + Assert.Equal(expected, mapping.GenerateProviderValueSqlLiteral(value)); + } } From c2bfa09b0882a3069da97ac0a482e82b7c56a90c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 19 Jun 2026 02:51:59 +0000 Subject: [PATCH 6/9] Merge xml filter test into round-trips with dynamic baseline; remove xml literal unit test Co-authored-by: AndriySvyryd <6539701+AndriySvyryd@users.noreply.github.com> --- .../BuiltInDataTypesSqlServerTest.cs | 48 ++++++------------- .../Storage/SqlServerStringTypeMappingTest.cs | 14 ------ 2 files changed, 14 insertions(+), 48 deletions(-) diff --git a/test/EFCore.SqlServer.FunctionalTests/BuiltInDataTypesSqlServerTest.cs b/test/EFCore.SqlServer.FunctionalTests/BuiltInDataTypesSqlServerTest.cs index 272d9dd0024..006ba2d9c6f 100644 --- a/test/EFCore.SqlServer.FunctionalTests/BuiltInDataTypesSqlServerTest.cs +++ b/test/EFCore.SqlServer.FunctionalTests/BuiltInDataTypesSqlServerTest.cs @@ -3844,39 +3844,18 @@ public async Task Xml_value_round_trips(string value, string expected) var id = document.Id; context.ChangeTracker.Clear(); - // xml columns cannot be compared directly in a WHERE clause, so the row is fetched by its key. - var roundTripped = (await context.Set().SingleAsync(d => d.Id == id)).Content; + // xml columns cannot be compared directly in a WHERE clause, so the row is fetched by its key. Coalescing + // the column with the original value sends that value as an 'xml' parameter, exercising the SqlXml + // parameter path in a query in addition to the insert above. + var roundTripped = await context.Set() + .Where(d => d.Id == id) + .Select(d => d.Content ?? value) + .SingleAsync(); Assert.Equal(expected, roundTripped); - } - - [Fact] - public async Task Xml_value_can_be_inserted_and_filtered() - { - await using var context = CreateContext(); - - var content = "" + XmlEmoji + ""; - context.Add(new XmlTestDocument { Content = content }); - await context.SaveChangesAsync(); - - // xml columns cannot be compared directly, so the value is converted to nvarchar(max) before filtering. - var query = context.Set().Where(d => Convert.ToString(d.Content) == content); - - Assert.Equal( - """ -DECLARE @content nvarchar(4000) = N'😀'; - -SELECT [x].[Id], [x].[Content] -FROM [XmlTestDocument] AS [x] -WHERE CONVERT(nvarchar(max), [x].[Content]) = @content -""", - query.ToQueryString(), - ignoreLineEndingDifferences: true); - - Assert.Equal(1, await query.CountAsync()); AssertSql( - """ -@p0='😀' (DbType = Xml) + $""" +@p0='{expected}' (DbType = Xml) SET IMPLICIT_TRANSACTIONS OFF; SET NOCOUNT ON; @@ -3885,12 +3864,13 @@ OUTPUT INSERTED.[Id] VALUES (@p0); """, // - """ -@content='😀' (Size = 4000) + $""" +@value='{expected}' (DbType = Xml) +@id='{id}' -SELECT COUNT(*) +SELECT TOP(2) COALESCE([x].[Content], @value) FROM [XmlTestDocument] AS [x] -WHERE CONVERT(nvarchar(max), [x].[Content]) = @content +WHERE [x].[Id] = @id """); } diff --git a/test/EFCore.SqlServer.Tests/Storage/SqlServerStringTypeMappingTest.cs b/test/EFCore.SqlServer.Tests/Storage/SqlServerStringTypeMappingTest.cs index 138334e4f38..41db30f7291 100644 --- a/test/EFCore.SqlServer.Tests/Storage/SqlServerStringTypeMappingTest.cs +++ b/test/EFCore.SqlServer.Tests/Storage/SqlServerStringTypeMappingTest.cs @@ -1,8 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System.Data; - namespace Microsoft.EntityFrameworkCore.SqlServer.Storage.Internal; public class SqlServerStringTypeMappingTest @@ -39,16 +37,4 @@ public void GenerateProviderValueSqlLiteral_works_unicode(string value, string e var mapping = new SqlServerStringTypeMapping("nvarchar(max)", unicode: true); Assert.Equal(expected, mapping.GenerateProviderValueSqlLiteral(value)); } - - [Theory] - [InlineData("a", "N'a'")] - [InlineData("\U0001F600", "N'\U0001F600'")] - [InlineData("I'm", "N'I''m'")] - [InlineData("", "N''")] - public void GenerateProviderValueSqlLiteral_works_xml(string value, string expected) - { - var mapping = new SqlServerStringTypeMapping( - "xml", unicode: true, sqlDbType: SqlDbType.Xml, storeTypePostfix: StoreTypePostfix.None); - Assert.Equal(expected, mapping.GenerateProviderValueSqlLiteral(value)); - } } From 96f6a7aa3245e855a62a33b606ad35368ac347eb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 19 Jun 2026 04:33:26 +0000 Subject: [PATCH 7/9] Fix SqlXml ambiguity and remove SqlParameter guard on conversion Co-authored-by: AndriySvyryd <6539701+AndriySvyryd@users.noreply.github.com> --- .../Storage/Internal/SqlServerStringTypeMapping.cs | 5 ++--- .../Storage/SqlServerTypeMappingTest.cs | 2 +- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/EFCore.SqlServer/Storage/Internal/SqlServerStringTypeMapping.cs b/src/EFCore.SqlServer/Storage/Internal/SqlServerStringTypeMapping.cs index acb3daa48a5..95d481690ba 100644 --- a/src/EFCore.SqlServer/Storage/Internal/SqlServerStringTypeMapping.cs +++ b/src/EFCore.SqlServer/Storage/Internal/SqlServerStringTypeMapping.cs @@ -174,11 +174,10 @@ protected override void ConfigureParameter(DbParameter parameter) // otherwise fails with "unable to switch the encoding". A fragment-conformant reader is used so that the // content forms that the 'xml' store type accepts - empty strings, text, and multiple top-level nodes - // continue to round-trip. - if (value is string xml - && parameter is SqlParameter xmlParameter) + if (value is string xml) { using var reader = XmlReader.Create(new StringReader(xml), XmlFragmentSettings); - xmlParameter.Value = new SqlXml(reader); + parameter.Value = new SqlXml(reader); } return; diff --git a/test/EFCore.SqlServer.Tests/Storage/SqlServerTypeMappingTest.cs b/test/EFCore.SqlServer.Tests/Storage/SqlServerTypeMappingTest.cs index a03c31ab396..4de0bcaba01 100644 --- a/test/EFCore.SqlServer.Tests/Storage/SqlServerTypeMappingTest.cs +++ b/test/EFCore.SqlServer.Tests/Storage/SqlServerTypeMappingTest.cs @@ -394,7 +394,7 @@ public virtual void Xml_parameter_is_sent_as_SqlXml(string value) var parameter = (SqlParameter)mapping.CreateParameter(command, "foo", value); Assert.Equal(SqlDbType.Xml, parameter.SqlDbType); - var sqlXml = Assert.IsType(parameter.Value); + var sqlXml = Assert.IsType(parameter.Value); Assert.False(sqlXml.IsNull); } From 633a8a6124db2a2b472673b85ac5a022a3beefd1 Mon Sep 17 00:00:00 2001 From: Andriy Svyryd Date: Thu, 18 Jun 2026 23:25:01 -0700 Subject: [PATCH 8/9] Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- test/EFCore.SqlServer.Tests/Storage/SqlServerTypeMappingTest.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/test/EFCore.SqlServer.Tests/Storage/SqlServerTypeMappingTest.cs b/test/EFCore.SqlServer.Tests/Storage/SqlServerTypeMappingTest.cs index 4de0bcaba01..f4b096cdc34 100644 --- a/test/EFCore.SqlServer.Tests/Storage/SqlServerTypeMappingTest.cs +++ b/test/EFCore.SqlServer.Tests/Storage/SqlServerTypeMappingTest.cs @@ -2,7 +2,6 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Data; -using System.Data.SqlTypes; using Microsoft.Data.SqlClient; using Microsoft.Data.SqlTypes; using Microsoft.EntityFrameworkCore.Design.Internal; From 46e937611e3b3756b211d7e9690c06e3c384e5c2 Mon Sep 17 00:00:00 2001 From: Andriy Svyryd Date: Thu, 18 Jun 2026 23:25:42 -0700 Subject: [PATCH 9/9] Add test for XML literal generation as Unicode --- .../Storage/SqlServerTypeMappingTest.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/test/EFCore.SqlServer.Tests/Storage/SqlServerTypeMappingTest.cs b/test/EFCore.SqlServer.Tests/Storage/SqlServerTypeMappingTest.cs index f4b096cdc34..fcd172edabb 100644 --- a/test/EFCore.SqlServer.Tests/Storage/SqlServerTypeMappingTest.cs +++ b/test/EFCore.SqlServer.Tests/Storage/SqlServerTypeMappingTest.cs @@ -409,6 +409,10 @@ public virtual void Xml_null_parameter_is_sent_as_SqlDbType_Xml() Assert.Equal(DBNull.Value, parameter.Value); } + [Fact] + public virtual void Xml_literal_is_generated_as_unicode() + => Test_GenerateSqlLiteral_helper(GetMapping("xml"), "\U0001F62D", "N'\U0001F62D'"); + [Fact] public virtual void DateOnly_code_literal_generated_correctly() {