diff --git a/.gitignore b/.gitignore index e76fe3cb..4831b9e1 100644 --- a/.gitignore +++ b/.gitignore @@ -145,3 +145,5 @@ $RECYCLE.BIN/ .DS_Store *.nupkg *.binlog +src/snk +src/Test/Desktop/OpenRiaServices.Common.DomainServices.Test/DataModels/ScenarioModels/northwind.map diff --git a/Changelog.md b/Changelog.md index 9beed361..fc69460b 100644 --- a/Changelog.md +++ b/Changelog.md @@ -1,3 +1,13 @@ +# AspNetCore 1.5.0 + +* Allow configuring Serializer security settings + +You can configure reader quotas to limit resource consumption and mitigate denial-of-service (DoS) attacks. +By default all quotas are set to their maximum values to preserve backward compatibility. + +See [AspNetCore README](src/OpenRiaServices.Hosting.AspNetCore/Framework/README.md#configuring-serializer-settings) for more details and sample code. + + # EF Core 4.1.0 * Generate `[Required(AllowEmptyStrings=true)]` instead of `[Required]` on client for non nullable string properties diff --git a/src/OpenRiaServices.Client.DomainClients.Http/Framework/BinaryHttpDomainClientFactory.cs b/src/OpenRiaServices.Client.DomainClients.Http/Framework/BinaryHttpDomainClientFactory.cs index 36dd4c57..6acab569 100644 --- a/src/OpenRiaServices.Client.DomainClients.Http/Framework/BinaryHttpDomainClientFactory.cs +++ b/src/OpenRiaServices.Client.DomainClients.Http/Framework/BinaryHttpDomainClientFactory.cs @@ -1,5 +1,6 @@ -using System; +using System; using System.Net.Http; +using System.Xml; using OpenRiaServices.Client.DomainClients.Http; namespace OpenRiaServices.Client.DomainClients @@ -11,6 +12,16 @@ namespace OpenRiaServices.Client.DomainClients public class BinaryHttpDomainClientFactory : HttpDomainClientFactory { + /// + /// Gets or sets the quotas used by when reading responses. + /// + public XmlDictionaryReaderQuotas ReaderQuotas { get; set; } = CreateMaxQuotas(); + + /// + /// Gets or sets the shared dictionary used for binary XML reader/writer compression. + /// + public IXmlDictionary Dictionary { get; set; } + /// public BinaryHttpDomainClientFactory(Uri serverBaseUri, HttpMessageHandler messageHandler) : base(serverBaseUri, messageHandler) @@ -34,7 +45,14 @@ protected override DomainClient CreateDomainClientCore(Type serviceContract, Uri { HttpClient httpClient = CreateHttpClient(serviceUri, BinaryHttpDomainClient.MediaType); - return new BinaryHttpDomainClient(httpClient, serviceContract); + return new BinaryHttpDomainClient(httpClient, serviceContract, ReaderQuotas, Dictionary); + } + + private static XmlDictionaryReaderQuotas CreateMaxQuotas() + { + var quotas = new XmlDictionaryReaderQuotas(); + XmlDictionaryReaderQuotas.Max.CopyTo(quotas); + return quotas; } } } diff --git a/src/OpenRiaServices.Client.DomainClients.Http/Framework/Http/BinaryHttpDomainClient.cs b/src/OpenRiaServices.Client.DomainClients.Http/Framework/Http/BinaryHttpDomainClient.cs index 5ed6f774..7b36ef62 100644 --- a/src/OpenRiaServices.Client.DomainClients.Http/Framework/Http/BinaryHttpDomainClient.cs +++ b/src/OpenRiaServices.Client.DomainClients.Http/Framework/Http/BinaryHttpDomainClient.cs @@ -2,6 +2,7 @@ using System.IO; using System.Net.Http; using System.Runtime.Serialization; +using System.Xml; namespace OpenRiaServices.Client.DomainClients.Http { @@ -12,9 +13,15 @@ namespace OpenRiaServices.Client.DomainClients.Http sealed class BinaryHttpDomainClient : DataContractHttpDomainClient { internal const string MediaType = "application/msbin1"; + private readonly XmlDictionaryReaderQuotas _readerQuotas; + private readonly IXmlDictionary _dictionary; - public BinaryHttpDomainClient(HttpClient httpClient, Type serviceInterface) : base(httpClient, serviceInterface) + public BinaryHttpDomainClient(HttpClient httpClient, Type serviceInterface, XmlDictionaryReaderQuotas readerQuotas, IXmlDictionary dictionary) + : base(httpClient, serviceInterface) { + ArgumentNullException.ThrowIfNull(readerQuotas); + _readerQuotas = CreateQuotasCopy(readerQuotas); + _dictionary = dictionary; } private protected override string ContentType => MediaType; @@ -22,12 +29,19 @@ public BinaryHttpDomainClient(HttpClient httpClient, Type serviceInterface) : ba private protected override System.Xml.XmlDictionaryReader CreateReader(Stream stream) { - return System.Xml.XmlDictionaryReader.CreateBinaryReader(stream, System.Xml.XmlDictionaryReaderQuotas.Max); + return XmlDictionaryReader.CreateBinaryReader(stream, _dictionary, _readerQuotas); } private protected override System.Xml.XmlDictionaryWriter CreateWriter(Stream stream) { - return System.Xml.XmlDictionaryWriter.CreateBinaryWriter(stream, null, null, ownsStream: false); + return XmlDictionaryWriter.CreateBinaryWriter(stream, _dictionary, null, ownsStream: false); + } + + private static XmlDictionaryReaderQuotas CreateQuotasCopy(XmlDictionaryReaderQuotas source) + { + var copy = new XmlDictionaryReaderQuotas(); + source.CopyTo(copy); + return copy; } } } diff --git a/src/OpenRiaServices.Client.DomainClients.Http/Framework/Http/XmlHttpDomainClient.cs b/src/OpenRiaServices.Client.DomainClients.Http/Framework/Http/XmlHttpDomainClient.cs index 29014c18..aa07d505 100644 --- a/src/OpenRiaServices.Client.DomainClients.Http/Framework/Http/XmlHttpDomainClient.cs +++ b/src/OpenRiaServices.Client.DomainClients.Http/Framework/Http/XmlHttpDomainClient.cs @@ -2,6 +2,7 @@ using System.IO; using System.Net.Http; using System.Runtime.Serialization; +using System.Xml; namespace OpenRiaServices.Client.DomainClients.Http { @@ -13,21 +14,32 @@ sealed class XmlHttpDomainClient : DataContractHttpDomainClient internal const string MediaType = "application/xml"; private readonly System.Text.Encoding _encoding = new System.Text.UTF8Encoding(encoderShouldEmitUTF8Identifier: false); + private readonly XmlDictionaryReaderQuotas _readerQuotas; - public XmlHttpDomainClient(HttpClient httpClient, Type serviceInterface) : base(httpClient, serviceInterface) + public XmlHttpDomainClient(HttpClient httpClient, Type serviceInterface, XmlDictionaryReaderQuotas readerQuotas) + : base(httpClient, serviceInterface) { + ArgumentNullException.ThrowIfNull(readerQuotas); + _readerQuotas = CreateQuotasCopy(readerQuotas); } private protected override string ContentType => MediaType; private protected override System.Xml.XmlDictionaryReader CreateReader(Stream stream) { - return System.Xml.XmlDictionaryReader.CreateTextReader(stream, System.Xml.XmlDictionaryReaderQuotas.Max); + return XmlDictionaryReader.CreateTextReader(stream, _readerQuotas); } private protected override System.Xml.XmlDictionaryWriter CreateWriter(Stream stream) { - return System.Xml.XmlDictionaryWriter.CreateTextWriter(stream, _encoding, ownsStream: false); + return XmlDictionaryWriter.CreateTextWriter(stream, _encoding, ownsStream: false); + } + + private static XmlDictionaryReaderQuotas CreateQuotasCopy(XmlDictionaryReaderQuotas source) + { + var copy = new XmlDictionaryReaderQuotas(); + source.CopyTo(copy); + return copy; } } } diff --git a/src/OpenRiaServices.Client.DomainClients.Http/Framework/XmlHttpDomainClientFactory.cs b/src/OpenRiaServices.Client.DomainClients.Http/Framework/XmlHttpDomainClientFactory.cs index 3a0fc721..4a95e0c7 100644 --- a/src/OpenRiaServices.Client.DomainClients.Http/Framework/XmlHttpDomainClientFactory.cs +++ b/src/OpenRiaServices.Client.DomainClients.Http/Framework/XmlHttpDomainClientFactory.cs @@ -1,6 +1,6 @@ -using System; -using System.Diagnostics; +using System; using System.Net.Http; +using System.Xml; using OpenRiaServices.Client.DomainClients.Http; namespace OpenRiaServices.Client.DomainClients @@ -11,6 +11,11 @@ namespace OpenRiaServices.Client.DomainClients /// public class XmlHttpDomainClientFactory : HttpDomainClientFactory { + /// + /// Gets or sets the quotas used by when reading responses. + /// + public XmlDictionaryReaderQuotas ReaderQuotas { get; set; } = CreateMaxQuotas(); + /// public XmlHttpDomainClientFactory(Uri serverBaseUri, HttpMessageHandler messageHandler) : base(serverBaseUri, messageHandler) { @@ -26,7 +31,14 @@ protected override DomainClient CreateDomainClientCore(Type serviceContract, Uri { HttpClient httpClient = CreateHttpClient(serviceUri, XmlHttpDomainClient.MediaType); - return new XmlHttpDomainClient(httpClient, serviceContract); + return new XmlHttpDomainClient(httpClient, serviceContract, ReaderQuotas); + } + + private static XmlDictionaryReaderQuotas CreateMaxQuotas() + { + var quotas = new XmlDictionaryReaderQuotas(); + XmlDictionaryReaderQuotas.Max.CopyTo(quotas); + return quotas; } } } diff --git a/src/OpenRiaServices.Hosting.AspNetCore/Framework/AspNetCore/OpenRiaServicesOptions.cs b/src/OpenRiaServices.Hosting.AspNetCore/Framework/AspNetCore/OpenRiaServicesOptions.cs index 35afcfb5..5fc48ff7 100644 --- a/src/OpenRiaServices.Hosting.AspNetCore/Framework/AspNetCore/OpenRiaServicesOptions.cs +++ b/src/OpenRiaServices.Hosting.AspNetCore/Framework/AspNetCore/OpenRiaServicesOptions.cs @@ -30,7 +30,7 @@ public sealed class OpenRiaServicesOptions /// List of all registered wire formats on descending order of priority. /// First one is the default used for responses (when client do not specify an matching format) /// - internal ISerializationProvider[] SerializationProviders { get; set; } = [new BinaryXmlSerializationProvider()]; + internal ISerializationProvider[] SerializationProviders { get; set; } = []; /// /// Adds a serialization provider to the list of supported formats. @@ -46,7 +46,7 @@ internal void AddSerializationProvider(ISerializationProvider provider, bool def && SerializationProviders.OfType().FirstOrDefault() is { } existingDcs) { // Share the DataContractCache between the two providers to avoid duplicate work - dataContractSerializationProvider._perDomainServiceDataContractCache = existingDcs._perDomainServiceDataContractCache; + dataContractSerializationProvider.CopyDataContractCacheFrom(existingDcs); } if (defaultProvider) diff --git a/src/OpenRiaServices.Hosting.AspNetCore/Framework/AspNetCore/OpenRiaServicesOptionsBuilder.cs b/src/OpenRiaServices.Hosting.AspNetCore/Framework/AspNetCore/OpenRiaServicesOptionsBuilder.cs index 1a39efa9..d11e2e4e 100644 --- a/src/OpenRiaServices.Hosting.AspNetCore/Framework/AspNetCore/OpenRiaServicesOptionsBuilder.cs +++ b/src/OpenRiaServices.Hosting.AspNetCore/Framework/AspNetCore/OpenRiaServicesOptionsBuilder.cs @@ -1,5 +1,7 @@ using System; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using OpenRiaServices.Hosting.AspNetCore.Serialization; namespace OpenRiaServices.Hosting.AspNetCore { @@ -32,34 +34,56 @@ internal OpenRiaServicesOptionsBuilder(IServiceCollection services) /// If the Xml provider will be the default for responses (when content type is not specified) public OpenRiaServicesOptionsBuilder AddXmlSerialization(bool defaultProvider = false) { - return AddSerializationProvider(new Serialization.TextXmlSerializationProvider(), defaultProvider); + return AddXmlSerialization(configure: null, defaultProvider); } /// - /// Removes all registered s. - /// Useful for removing default serialization formats (application/msbin1). + /// Enables text based XML wire format (application/xml) in addition to built in binary Xml (application/msbin1), + /// with options configurable via a callback. /// - public OpenRiaServicesOptionsBuilder ClearSerializationProviders() + /// Request should specify mime-type application/xml using Content-Type or Accept HTTP-headers. + /// + /// An optional callback to configure . + /// If the Xml provider will be the default for responses (when content type is not specified) + public OpenRiaServicesOptionsBuilder AddXmlSerialization(Action? configure, bool defaultProvider = false) { - Services.Configure(options => - { - options.ClearSerializationProviders(); - }); + if (configure is not null) + Services.Configure(configure); + + Services.AddOptions() + .Configure((OpenRiaServicesOptions options, IOptions serializationOptions) => + { + options.AddSerializationProvider(new TextXmlSerializationProvider(serializationOptions.Value), defaultProvider); + }); return this; } - private OpenRiaServicesOptionsBuilder AddSerializationProvider(Serialization.ISerializationProvider serializationProvider, bool defaultProvider) + /// + /// Configures the default binary XML (application/msbin1) serialization provider. + /// + /// + /// Use this to restrict reader quotas and mitigate denial-of-service risks for binary requests. + /// + /// A callback to configure . + public OpenRiaServicesOptionsBuilder ConfigureBinarySerialization(Action configure) + { + ArgumentNullException.ThrowIfNull(configure); + + Services.Configure(configure); + + return this; + } + + /// + /// Removes all registered s. + /// Useful for removing default serialization formats (application/msbin1). + /// + public OpenRiaServicesOptionsBuilder ClearSerializationProviders() { - // When adding options it might make sense to resolve the provider using DI so allowing default options configuration - //Services.AddSingleton(); - //Services.AddOptions().Configure((OpenRiaServicesOptions opts, Serialization.TextXmlSerializationProvider provider) => { }); - // OR - // Services.Configure(callback) - // Services.AddOptions().Configure((OpenRiaServicesOptions opts, IOptions options) Services.Configure(options => { - options.AddSerializationProvider(serializationProvider, defaultProvider); + options.ClearSerializationProviders(); }); return this; diff --git a/src/OpenRiaServices.Hosting.AspNetCore/Framework/AspNetCore/OpenRiaServicesServiceCollectionExtensions.cs b/src/OpenRiaServices.Hosting.AspNetCore/Framework/AspNetCore/OpenRiaServicesServiceCollectionExtensions.cs index 0b6bcf38..7ae425a7 100644 --- a/src/OpenRiaServices.Hosting.AspNetCore/Framework/AspNetCore/OpenRiaServicesServiceCollectionExtensions.cs +++ b/src/OpenRiaServices.Hosting.AspNetCore/Framework/AspNetCore/OpenRiaServicesServiceCollectionExtensions.cs @@ -2,6 +2,8 @@ using System.Diagnostics.CodeAnalysis; using System.Reflection; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using OpenRiaServices.Hosting.AspNetCore.Serialization; using OpenRiaServices.Server; namespace OpenRiaServices.Hosting.AspNetCore @@ -21,6 +23,13 @@ public static OpenRiaServicesOptionsBuilder AddOpenRiaServices(this IServiceColl services.AddSingleton(); services.AddSingleton(services); + services.AddOptions() + .Configure((OpenRiaServicesOptions options, IOptions binaryOptions) => + { + // Default to using the Binary XML serializer if no serialization providers were added. + options.SerializationProviders = [new BinaryXmlSerializationProvider(binaryOptions.Value)]; + }); + return new OpenRiaServicesOptionsBuilder(services); } @@ -32,8 +41,11 @@ public static OpenRiaServicesOptionsBuilder AddOpenRiaServices(this IServiceColl ArgumentNullException.ThrowIfNull(services); ArgumentNullException.ThrowIfNull(configure); + + var builder = AddOpenRiaServices(services); + // Run user supplied configuration after the default configuration so that we have already added a default serialization provider services.Configure(configure); - return AddOpenRiaServices(services); + return builder; } /// diff --git a/src/OpenRiaServices.Hosting.AspNetCore/Framework/AspNetCore/Serialization/BinaryMessageReader.cs b/src/OpenRiaServices.Hosting.AspNetCore/Framework/AspNetCore/Serialization/BinaryMessageReader.cs index 6ffacec0..957cb645 100644 --- a/src/OpenRiaServices.Hosting.AspNetCore/Framework/AspNetCore/Serialization/BinaryMessageReader.cs +++ b/src/OpenRiaServices.Hosting.AspNetCore/Framework/AspNetCore/Serialization/BinaryMessageReader.cs @@ -24,7 +24,7 @@ private BinaryMessageReader() _currentReader = _binaryReader = XmlDictionaryReader.CreateBinaryReader(Array.Empty(), XmlDictionaryReaderQuotas.Max); } - public static BinaryMessageReader Rent(ArraySegment bytes, bool isBinary) + public static BinaryMessageReader Rent(ArraySegment bytes, bool isBinary, XmlDictionaryReaderQuotas quotas, IXmlDictionary? dictionary = null) { var messageReader = s_threadInstance ?? new BinaryMessageReader(); @@ -34,15 +34,15 @@ public static BinaryMessageReader Rent(ArraySegment bytes, bool isBinary) if (isBinary) { - ((IXmlBinaryReaderInitializer)messageReader._binaryReader).SetInput(bytes.Array!, bytes.Offset, bytes.Count, dictionary: null, XmlDictionaryReaderQuotas.Max, null, null); + ((IXmlBinaryReaderInitializer)messageReader._binaryReader).SetInput(bytes.Array!, bytes.Offset, bytes.Count, dictionary: dictionary, quotas, null, null); messageReader._currentReader = messageReader._binaryReader; } else { if (messageReader._textReader is IXmlTextReaderInitializer textReader) - textReader.SetInput(bytes.Array!, bytes.Offset, bytes.Count, encoding: null, XmlDictionaryReaderQuotas.Max, null); + textReader.SetInput(bytes.Array!, bytes.Offset, bytes.Count, encoding: null, quotas, null); else - messageReader._textReader = XmlDictionaryReader.CreateTextReader(bytes.Array!, bytes.Offset, bytes.Count, XmlDictionaryReaderQuotas.Max); + messageReader._textReader = XmlDictionaryReader.CreateTextReader(bytes.Array!, bytes.Offset, bytes.Count, quotas); messageReader._currentReader = messageReader._textReader; } diff --git a/src/OpenRiaServices.Hosting.AspNetCore/Framework/AspNetCore/Serialization/BinaryMessageWriter.cs b/src/OpenRiaServices.Hosting.AspNetCore/Framework/AspNetCore/Serialization/BinaryMessageWriter.cs index 9d56aa0d..a900bc2f 100644 --- a/src/OpenRiaServices.Hosting.AspNetCore/Framework/AspNetCore/Serialization/BinaryMessageWriter.cs +++ b/src/OpenRiaServices.Hosting.AspNetCore/Framework/AspNetCore/Serialization/BinaryMessageWriter.cs @@ -40,7 +40,7 @@ private BinaryMessageWriter() _textWriter = XmlDictionaryWriter.CreateTextWriter(_stream); } - public static BinaryMessageWriter Rent(bool isBinary) + public static BinaryMessageWriter Rent(bool isBinary, IXmlDictionary? dictionary = null) { var messageWriter = s_threadInstance ?? new BinaryMessageWriter(); @@ -50,7 +50,18 @@ public static BinaryMessageWriter Rent(bool isBinary) // Allocate first buffer messageWriter._stream.Reset(messageWriter.EstimateMessageSize()); - messageWriter._currentWriter = isBinary ? messageWriter._binaryWriter : messageWriter._textWriter; + + if (isBinary) + { + // Reinitialize the binary writer to apply current dictionary settings and ensure clean state + ((IXmlBinaryWriterInitializer)messageWriter._binaryWriter).SetOutput(messageWriter._stream, dictionary, session: null, ownsStream: false); + messageWriter._currentWriter = messageWriter._binaryWriter; + } + else + { + messageWriter._currentWriter = messageWriter._textWriter; + } + return messageWriter; } diff --git a/src/OpenRiaServices.Hosting.AspNetCore/Framework/AspNetCore/Serialization/DataContractRequestSerializer.cs b/src/OpenRiaServices.Hosting.AspNetCore/Framework/AspNetCore/Serialization/DataContractRequestSerializer.cs index 534fe7fb..b67dc98f 100644 --- a/src/OpenRiaServices.Hosting.AspNetCore/Framework/AspNetCore/Serialization/DataContractRequestSerializer.cs +++ b/src/OpenRiaServices.Hosting.AspNetCore/Framework/AspNetCore/Serialization/DataContractRequestSerializer.cs @@ -12,8 +12,8 @@ namespace OpenRiaServices.Hosting.AspNetCore.Serialization { internal sealed class TextXmlDataContractRequestSerializer : DataContractRequestSerializer { - public TextXmlDataContractRequestSerializer(DomainOperationEntry operation, DataContractCache dataContractCache) - : base(operation, dataContractCache, isBinary: false) + public TextXmlDataContractRequestSerializer(DomainOperationEntry operation, DataContractCache dataContractCache, XmlSerializationOptions options) + : base(operation, dataContractCache, isBinary: false, options) { } @@ -26,8 +26,8 @@ public override bool CanWrite(ReadOnlySpan contentType) internal sealed class BinaryXmlDataContractRequestSerializer : DataContractRequestSerializer { - public BinaryXmlDataContractRequestSerializer(DomainOperationEntry operation, DataContractCache dataContractCache) - : base(operation, dataContractCache, isBinary: true) + public BinaryXmlDataContractRequestSerializer(DomainOperationEntry operation, DataContractCache dataContractCache, BinarySerializationOptions options) + : base(operation, dataContractCache, isBinary: true, options) { } @@ -46,6 +46,8 @@ internal abstract class DataContractRequestSerializer : RequestSerializer private readonly DomainOperationEntry _operation; private readonly string _responseName; private readonly string _resultName; + private readonly XmlDictionaryReaderQuotas _readerQuotas; + private readonly IXmlDictionary? _dictionary; private const string MessageRootElementName = "MessageRoot"; private const string QueryOptionsListElementName = "QueryOptions"; private const string QueryOptionElementName = "QueryOption"; @@ -57,7 +59,7 @@ internal abstract class DataContractRequestSerializer : RequestSerializer private bool IsBinary { get; } private string ContentType => IsBinary ? MimeTypes.BinaryXml : MimeTypes.TextXml; - protected DataContractRequestSerializer(DomainOperationEntry operation, DataContractCache dataContractCache, bool isBinary) + protected DataContractRequestSerializer(DomainOperationEntry operation, DataContractCache dataContractCache, bool isBinary, SerializationOptions options) { Type actualReturnType = operation.Operation switch { @@ -73,6 +75,8 @@ protected DataContractRequestSerializer(DomainOperationEntry operation, DataCont this._responseName = operation.Name + "Response"; this._resultName = operation.Name + "Result"; this._responseSerializer = dataContractCache.GetSerializer(actualReturnType); + this._readerQuotas = options.ReaderQuotas; + this._dictionary = isBinary ? options.Dictionary : null; if (operation.Operation == DomainOperation.Custom) { @@ -102,7 +106,7 @@ protected DataContractRequestSerializer(DomainOperationEntry operation, DataCont try { - using var reader = BinaryMessageReader.Rent(memory, IsBinary); + using var reader = BinaryMessageReader.Rent(memory, IsBinary, _readerQuotas, _dictionary); return ReadQueryParametersFromBody(reader.XmlDictionaryReader, operation); } catch (Exception ex) when (ex is not BadHttpRequestException && !ExceptionHandlingUtility.IsFatal(ex)) @@ -296,7 +300,7 @@ public override async Task WriteErrorAsync(HttpContext context, DomainServiceFau if (ct.IsCancellationRequested) return; - var messageWriter = BinaryMessageWriter.Rent(IsBinary); + var messageWriter = BinaryMessageWriter.Rent(IsBinary, _dictionary); try { WriteFault(fault, messageWriter.XmlWriter); @@ -362,7 +366,7 @@ public override async Task WriteResponseAsync(HttpContext context, object? resul if (ct.IsCancellationRequested) return; - var messageWriter = BinaryMessageWriter.Rent(IsBinary); + var messageWriter = BinaryMessageWriter.Rent(IsBinary, _dictionary); try { WriteResponse(messageWriter.XmlWriter, result); diff --git a/src/OpenRiaServices.Hosting.AspNetCore/Framework/AspNetCore/Serialization/DataContractSerializationProvider.cs b/src/OpenRiaServices.Hosting.AspNetCore/Framework/AspNetCore/Serialization/DataContractSerializationProvider.cs index d2986a9e..b47c0a55 100644 --- a/src/OpenRiaServices.Hosting.AspNetCore/Framework/AspNetCore/Serialization/DataContractSerializationProvider.cs +++ b/src/OpenRiaServices.Hosting.AspNetCore/Framework/AspNetCore/Serialization/DataContractSerializationProvider.cs @@ -8,6 +8,13 @@ namespace OpenRiaServices.Hosting.AspNetCore.Serialization { internal sealed class BinaryXmlSerializationProvider : DataContractSerializationProvider { + private readonly BinarySerializationOptions _options; + + public BinaryXmlSerializationProvider(BinarySerializationOptions? options = null) + { + _options = options ?? new BinarySerializationOptions(); + } + /// /// Creates a Binary-XML DataContract based serializer for the specified domain operation. /// @@ -15,11 +22,20 @@ internal sealed class BinaryXmlSerializationProvider : DataContractSerialization /// The data contract cache containing type metadata used by the serializer. /// A configured for the given operation and data contract cache. protected override DataContractRequestSerializer CreateOperationRequestSerialiser(DomainOperationEntry operation, DataContractCache dataContractCache) - => new BinaryXmlDataContractRequestSerializer(operation, dataContractCache); + => new BinaryXmlDataContractRequestSerializer(operation, dataContractCache, _options); } internal sealed class TextXmlSerializationProvider : DataContractSerializationProvider { + private readonly XmlSerializationOptions _options; + + public TextXmlSerializationProvider(XmlSerializationOptions options) + { + ArgumentNullException.ThrowIfNull(options); + + _options = options; + } + /// /// Creates a (Text) XML DataContract based serializer for the specified domain operation. /// @@ -27,7 +43,7 @@ internal sealed class TextXmlSerializationProvider : DataContractSerializationPr /// The data contract metadata cache for the operation's domain service type. /// A that serializes request payloads using text XML. protected override DataContractRequestSerializer CreateOperationRequestSerialiser(DomainOperationEntry operation, DataContractCache dataContractCache) - => new TextXmlDataContractRequestSerializer(operation, dataContractCache); + => new TextXmlDataContractRequestSerializer(operation, dataContractCache, _options); } internal abstract class DataContractSerializationProvider() : ISerializationProvider @@ -55,6 +71,16 @@ public RequestSerializer GetRequestSerializer(DomainOperationEntry operation) return _serializers.GetOrAdd(key, serializer); } + /// + /// Shares the reference from with this instance, + /// so that multiple providers reuse the same per-domain-service metadata and avoid duplicate work. + /// + /// The provider whose data contract cache reference should be adopted by this instance. + internal void CopyDataContractCacheFrom(DataContractSerializationProvider source) + { + _perDomainServiceDataContractCache = source._perDomainServiceDataContractCache; + } + /// /// Override in derived classes to create a DataContractRequestSerializer for the specified domain operation using the provided data contract cache. /// diff --git a/src/OpenRiaServices.Hosting.AspNetCore/Framework/AspNetCore/Serialization/DataContractSerializerOptions.cs b/src/OpenRiaServices.Hosting.AspNetCore/Framework/AspNetCore/Serialization/DataContractSerializerOptions.cs new file mode 100644 index 00000000..2ae9a08a --- /dev/null +++ b/src/OpenRiaServices.Hosting.AspNetCore/Framework/AspNetCore/Serialization/DataContractSerializerOptions.cs @@ -0,0 +1,62 @@ +using System.Xml; + +namespace OpenRiaServices.Hosting.AspNetCore +{ + /// + /// Base options class for DataContract-based serializers used by OpenRIA Services. + /// + public class SerializationOptions + { + /// + /// Gets or sets the quotas applied to instances during deserialization. + /// + /// + /// Defaults to a mutable instance equivalent to to preserve existing behavior. + /// Restrict these quotas to limit resource consumption and mitigate denial-of-service attacks. + /// + public XmlDictionaryReaderQuotas ReaderQuotas { get; set; } = CreateMaxQuotas(); + + /// + /// Gets or sets the XML dictionary used for binary format compression. + /// Exposed publicly on only. + /// + internal IXmlDictionary? Dictionary { get; set; } + + /// + /// Creates a new mutable with all quotas set to their maximum values. + /// + private static XmlDictionaryReaderQuotas CreateMaxQuotas() + { + var quotas = new XmlDictionaryReaderQuotas(); + XmlDictionaryReaderQuotas.Max.CopyTo(quotas); + return quotas; + } + } + + /// + /// Options for the binary XML (application/msbin1) DataContract serializer. + /// + public sealed class BinarySerializationOptions : SerializationOptions + { + /// + /// Gets or sets the XML dictionary used for binary format compression when reading and writing. + /// + /// + /// When set, the same dictionary is applied to both the + /// and the to enable consistent compression of element + /// and attribute names. Client and server must share the same dictionary. + /// + public new IXmlDictionary? Dictionary + { + get => base.Dictionary; + set => base.Dictionary = value; + } + } + + /// + /// Options for the text XML (application/xml) DataContract serializer. + /// + public sealed class XmlSerializationOptions : SerializationOptions + { + } +} diff --git a/src/OpenRiaServices.Hosting.AspNetCore/Framework/OpenRiaServices.Hosting.AspNetCore.csproj b/src/OpenRiaServices.Hosting.AspNetCore/Framework/OpenRiaServices.Hosting.AspNetCore.csproj index ec0573ca..78179569 100644 --- a/src/OpenRiaServices.Hosting.AspNetCore/Framework/OpenRiaServices.Hosting.AspNetCore.csproj +++ b/src/OpenRiaServices.Hosting.AspNetCore/Framework/OpenRiaServices.Hosting.AspNetCore.csproj @@ -6,7 +6,7 @@ OpenRiaServices.Hosting $(NoWarn);CS1574;CS1573;CS1591;CS1572 - 1.4.0 + 1.5.0 true Daniel-Svensson OpenRiaServices AspNetCore Hosting DomainServices AspNet WCF RIA Services Server diff --git a/src/OpenRiaServices.Hosting.AspNetCore/Framework/README.md b/src/OpenRiaServices.Hosting.AspNetCore/Framework/README.md index 5f0a6ab6..8aa3f16f 100644 --- a/src/OpenRiaServices.Hosting.AspNetCore/Framework/README.md +++ b/src/OpenRiaServices.Hosting.AspNetCore/Framework/README.md @@ -132,6 +132,42 @@ builder.Services.AddOpenRiaServices(o => { } ) .AddXmlSerialization(); ``` +### Configuring serializer security quotas + +You can configure reader quotas to limit resource consumption and mitigate denial-of-service (DoS) attacks. +By default all quotas are set to their maximum values to preserve backward compatibility. + +**Configure XML serialization quotas:** + +```csharp +var builder = WebApplication.CreateBuilder(args); +builder.Services.AddOpenRiaServices() + .AddXmlSerialization(options => + { + options.ReaderQuotas = new System.Xml.XmlDictionaryReaderQuotas + { + MaxStringContentLength = 1024 * 1024, // 1 MB + MaxArrayLength = 65536, + MaxDepth = 32, + }; + }); +``` + +**Configure binary XML serialization quotas (for the default binary provider):** + +```csharp +var builder = WebApplication.CreateBuilder(args); +builder.Services.AddOpenRiaServices() + .ConfigureBinarySerialization(options => + { + options.ReaderQuotas = new System.Xml.XmlDictionaryReaderQuotas + { + MaxStringContentLength = 1024 * 1024, // 1 MB + MaxArrayLength = 65536, + MaxDepth = 32, + }; + }); +``` ### Specifying endpoint routes diff --git a/src/OpenRiaServices.Hosting.AspNetCore/Test/OpenRiaServices.Hosting.AspNetCore.Test/BadRequestTests.cs b/src/OpenRiaServices.Hosting.AspNetCore/Test/OpenRiaServices.Hosting.AspNetCore.Test/BadRequestTests.cs index fec8f04a..bd34226c 100644 --- a/src/OpenRiaServices.Hosting.AspNetCore/Test/OpenRiaServices.Hosting.AspNetCore.Test/BadRequestTests.cs +++ b/src/OpenRiaServices.Hosting.AspNetCore/Test/OpenRiaServices.Hosting.AspNetCore.Test/BadRequestTests.cs @@ -23,7 +23,7 @@ namespace OpenRiaServices.Hosting.AspNetCore [TestClass] public class BadRequestTests { - private static readonly OpenRiaServicesOptions s_options = new(); + private static readonly OpenRiaServicesOptions s_options = new() { SerializationProviders = [new Serialization.BinaryXmlSerializationProvider()] }; [TestMethod] [Description("Invoke operation: Missing parameter when calling InvokeOperationInvoker should throw BadHttpRequestException when invoked directly")] @@ -54,7 +54,7 @@ public async Task TestInvoke_MissingParameter_InvokeThrows() context.Request.QueryString = QueryString.FromUriComponent("?value="); await Assert.ThrowsExactlyAsync(async () => await operationInvoker.Invoke(context)); - + // Wrong format context.Request.QueryString = QueryString.FromUriComponent("?value=two"); var invalidFormatEx = await Assert.ThrowsExactlyAsync(async () => await operationInvoker.Invoke(context)); diff --git a/src/OpenRiaServices.Hosting.AspNetCore/Test/OpenRiaServices.Hosting.AspNetCore.Test/Serialization/SerializationOptionsTests.cs b/src/OpenRiaServices.Hosting.AspNetCore/Test/OpenRiaServices.Hosting.AspNetCore.Test/Serialization/SerializationOptionsTests.cs new file mode 100644 index 00000000..a5eeed90 --- /dev/null +++ b/src/OpenRiaServices.Hosting.AspNetCore/Test/OpenRiaServices.Hosting.AspNetCore.Test/Serialization/SerializationOptionsTests.cs @@ -0,0 +1,213 @@ +using System; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Text; +using System.Threading.Tasks; +using System.Xml; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Options; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using OpenRiaServices.Server; + +namespace OpenRiaServices.Hosting.AspNetCore; + +[TestClass] +public class SerializationOptionsTests +{ + // ------------------------------------------------------------------------- + // Builder API tests + // ------------------------------------------------------------------------- + + [TestMethod] + [Description("ConfigureBinarySerialization replaces existing BinaryXmlSerializationProvider with configured one")] + public void Builder_ConfigureBinarySerialization_ReplacesDefaultBinaryProvider() + { + bool configureWasCalled = false; + var services = new ServiceCollection(); + var builder = services.AddOpenRiaServices(); + builder.ConfigureBinarySerialization(opts => + { + configureWasCalled = true; + opts.ReaderQuotas = new XmlDictionaryReaderQuotas { MaxStringContentLength = 2048 }; + }); + + var sp = services.BuildServiceProvider(); + var options = sp.GetRequiredService>().Value; + + Assert.IsTrue(configureWasCalled, "Configure callback should have been called"); + + int binaryCount = options.SerializationProviders.Count(p => p is BinaryXmlSerializationProvider); + Assert.AreEqual(1, binaryCount, "Should still have exactly one BinaryXmlSerializationProvider"); + } + + [TestMethod] + [Description("ConfigureBinarySerialization can be fluently chained")] + public void Builder_ConfigureBinarySerialization_ReturnsSameBuilder() + { + var services = new ServiceCollection(); + var builder = services.AddOpenRiaServices(); + var returned = builder.ConfigureBinarySerialization(_ => { }); + Assert.AreSame(builder, returned, "ConfigureBinarySerialization should return the builder for chaining"); + } + + [TestMethod] + [Description("AddXmlSerialization with configure returns the builder for chaining")] + public void Builder_AddXmlSerialization_WithConfigure_ReturnsSameBuilder() + { + var services = new ServiceCollection(); + var builder = services.AddOpenRiaServices(); + var returned = builder.AddXmlSerialization(_ => { }); + Assert.AreSame(builder, returned, "AddXmlSerialization should return the builder for chaining"); + } + + // ------------------------------------------------------------------------- + // Reader quota enforcement tests (binary) + // ------------------------------------------------------------------------- + + [TestMethod] + [Description("Binary reader quota: MaxStringContentLength is enforced")] + public async Task BinaryReaderQuota_MaxStringContentLength_IsEnforced() + { + using var host = await CreateHost(binaryConfigure: opts => + { + opts.ReaderQuotas = new XmlDictionaryReaderQuotas { MaxStringContentLength = 10 }; + }); + + var client = host.GetTestClient(); + + // A request with a string param longer than 10 chars should fail + var longString = new string('a', 100); + using var tooLongContent = BuildBinaryInvokeRequest("EchoString", "value", longString); + await AssertBadRequestAsync(client.PostAsync("SerializationTestDomainService/EchoString", tooLongContent), "The maximum string content length quota (10) has been exceeded"); + + using var shortContent = BuildBinaryInvokeRequest("EchoString", "value", "hello"); + var response = await client.PostAsync("SerializationTestDomainService/EchoString", shortContent); + Assert.AreEqual(System.Net.HttpStatusCode.OK, response.StatusCode, + "Request within quota should succeed"); + } + + // ------------------------------------------------------------------------- + // Reader quota enforcement tests (XML) + // ------------------------------------------------------------------------- + + [TestMethod] + [Description("XML reader quota: MaxStringContentLength is enforced")] + public async Task XmlReaderQuota_MaxStringContentLength_IsEnforced() + { + using var host = await CreateHost(xmlConfigure: opts => + { + opts.ReaderQuotas = new XmlDictionaryReaderQuotas { MaxStringContentLength = 10 }; + }); + + var client = host.GetTestClient(); + var longString = new string('a', 100); + using var content = BuildXmlInvokeRequest("EchoString", "value", longString); + + await AssertBadRequestAsync(client.PostAsync("SerializationTestDomainService/EchoString", content), "The maximum string content length quota (10) has been exceeded"); + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + private static async Task CreateHost( + Action binaryConfigure = null, + Action xmlConfigure = null) + { + return await new HostBuilder() + .ConfigureWebHost(webBuilder => + { + webBuilder + .UseTestServer() + .ConfigureServices(services => + { + services.AddRouting(); + var oria = services.AddOpenRiaServices(); + if (binaryConfigure is not null) + oria.ConfigureBinarySerialization(binaryConfigure); + if (xmlConfigure is not null) + oria.AddXmlSerialization(xmlConfigure); + services.AddDomainService(); + }) + .Configure(app => + { + app.UseRouting(); + app.UseEndpoints(e => + { + e.MapOpenRiaServices(b => b.AddRegisteredDomainServices()); + }); + }); + }) + .StartAsync(); + } + + /// Asserts that a request results in HTTP 400 Bad Request (handles TestServer propagating the BadHttpRequestException). + private static async Task AssertBadRequestAsync(Task responseTask, string messageContents = null) + { + try + { + var response = await responseTask; + Assert.AreEqual(System.Net.HttpStatusCode.BadRequest, response.StatusCode, + "Expected HTTP 400 Bad Request"); + } + catch (BadHttpRequestException ex) + { + // TestServer propagates BadHttpRequestException directly instead of converting it to HTTP 400 + Assert.AreEqual(StatusCodes.Status400BadRequest, ex.StatusCode, + "Expected BadHttpRequestException with status 400"); + + if (messageContents is not null) + Assert.Contains(messageContents, ex.Message); + } + } + + /// Builds a binary-XML POST body for an invoke operation with one string parameter. + private static ByteArrayContent BuildBinaryInvokeRequest(string operationName, string paramName, string paramValue) + { + using var ms = new MemoryStream(); + using var writer = XmlDictionaryWriter.CreateBinaryWriter(ms, dictionary: null, session: null, ownsStream: false); + writer.WriteStartElement(operationName); + writer.WriteStartElement(paramName); + writer.WriteString(paramValue); + writer.WriteEndElement(); + writer.WriteEndElement(); + writer.Flush(); + var content = new ByteArrayContent(ms.ToArray()); + content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/msbin1"); + return content; + } + + /// Builds a plain-XML POST body for an invoke operation with one string parameter. + private static ByteArrayContent BuildXmlInvokeRequest(string operationName, string paramName, string paramValue) + { + using var ms = new MemoryStream(); + var settings = new XmlWriterSettings { Encoding = new UTF8Encoding(false), CloseOutput = false }; + using var writer = XmlWriter.Create(ms, settings); + writer.WriteStartElement(operationName); + writer.WriteStartElement(paramName); + writer.WriteString(paramValue); + writer.WriteEndElement(); + writer.WriteEndElement(); + writer.Flush(); + var content = new ByteArrayContent(ms.ToArray()); + content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/xml"); + return content; + } + + // ------------------------------------------------------------------------- + // Test domain service + // ------------------------------------------------------------------------- + + [EnableClientAccess] + public class SerializationTestDomainService : DomainService + { + [Invoke(HasSideEffects = true)] + public string EchoString(string value) => value; + } +}