From bfdb48717f8553673d8b071d08e5375f9e4d0132 Mon Sep 17 00:00:00 2001 From: "Chris Ross (ASP.NET)" Date: Fri, 19 Jan 2018 09:28:04 -0800 Subject: [PATCH] Host header format validation --- .../Http1ConnectionBenchmark.cs | 115 +++++++ .../Internal/Http/Http1Connection.cs | 33 +- .../Internal/Http2/HPack/HPackDecoder.cs | 35 +- .../Internal/Http2/Http2Stream.cs | 53 ++- .../Infrastructure/HttpUtilities.Generated.cs | 1 + .../Internal/Infrastructure/HttpUtilities.cs | 133 ++++++++ .../Http1ConnectionTests.cs | 50 +++ .../Http2ConnectionTests.cs | 320 +++++++++++++++++- test/Kestrel.Core.Tests/HttpUtilitiesTest.cs | 95 ++++++ .../BadHttpRequestTests.cs | 18 + test/Kestrel.FunctionalTests/RequestTests.cs | 2 +- .../HttpUtilities/HttpUtilities.cs | 1 + 12 files changed, 825 insertions(+), 31 deletions(-) create mode 100644 benchmarks/Kestrel.Performance/Http1ConnectionBenchmark.cs diff --git a/benchmarks/Kestrel.Performance/Http1ConnectionBenchmark.cs b/benchmarks/Kestrel.Performance/Http1ConnectionBenchmark.cs new file mode 100644 index 0000000000..b90221b4a7 --- /dev/null +++ b/benchmarks/Kestrel.Performance/Http1ConnectionBenchmark.cs @@ -0,0 +1,115 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Buffers; +using System.IO.Pipelines; +using BenchmarkDotNet.Attributes; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.AspNetCore.Server.Kestrel.Core; +using Microsoft.AspNetCore.Server.Kestrel.Core.Internal; +using Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http; +using Microsoft.AspNetCore.Server.Kestrel.Performance.Mocks; +using Microsoft.AspNetCore.Server.Kestrel.Transport.Abstractions.Internal; + +namespace Microsoft.AspNetCore.Server.Kestrel.Performance +{ + public class Http1ConnectionBenchmark + { + private const int InnerLoopCount = 512; + + private readonly HttpParser _parser = new HttpParser(); + + private ReadOnlySequence _buffer; + + public Http1Connection Connection { get; set; } + + [GlobalSetup] + public void Setup() + { + var memoryPool = KestrelMemoryPool.Create(); + var options = new PipeOptions(memoryPool, readerScheduler: PipeScheduler.Inline, writerScheduler: PipeScheduler.Inline, useSynchronizationContext: false); + var pair = DuplexPipe.CreateConnectionPair(options, options); + + var serviceContext = new ServiceContext + { + ServerOptions = new KestrelServerOptions(), + HttpParser = NullParser.Instance + }; + + var http1Connection = new Http1Connection(context: new Http1ConnectionContext + { + ServiceContext = serviceContext, + ConnectionFeatures = new FeatureCollection(), + MemoryPool = memoryPool, + TimeoutControl = new MockTimeoutControl(), + Application = pair.Application, + Transport = pair.Transport + }); + + http1Connection.Reset(); + + Connection = http1Connection; + } + + [Benchmark(Baseline = true, OperationsPerInvoke = RequestParsingData.InnerLoopCount)] + public void PlaintextTechEmpower() + { + for (var i = 0; i < RequestParsingData.InnerLoopCount; i++) + { + InsertData(RequestParsingData.PlaintextTechEmpowerRequest); + ParseData(); + } + } + + [Benchmark(OperationsPerInvoke = RequestParsingData.InnerLoopCount)] + public void LiveAspNet() + { + for (var i = 0; i < RequestParsingData.InnerLoopCount; i++) + { + InsertData(RequestParsingData.LiveaspnetRequest); + ParseData(); + } + } + + private void InsertData(byte[] data) + { + _buffer = new ReadOnlySequence(data); + } + + private void ParseData() + { + if (!_parser.ParseRequestLine(new Adapter(this), _buffer, out var consumed, out var examined)) + { + ErrorUtilities.ThrowInvalidRequestHeaders(); + } + + _buffer = _buffer.Slice(consumed, _buffer.End); + + if (!_parser.ParseHeaders(new Adapter(this), _buffer, out consumed, out examined, out var consumedBytes)) + { + ErrorUtilities.ThrowInvalidRequestHeaders(); + } + + Connection.EnsureHostHeaderExists(); + + Connection.Reset(); + } + + private struct Adapter : IHttpRequestLineHandler, IHttpHeadersHandler + { + public Http1ConnectionBenchmark RequestHandler; + + public Adapter(Http1ConnectionBenchmark requestHandler) + { + RequestHandler = requestHandler; + } + + public void OnHeader(Span name, Span value) + => RequestHandler.Connection.OnHeader(name, value); + + public void OnStartLine(HttpMethod method, HttpVersion version, Span target, Span path, Span query, Span customMethod, bool pathEncoded) + => RequestHandler.Connection.OnStartLine(method, version, target, path, query, customMethod, pathEncoded); + } + } +} \ No newline at end of file diff --git a/src/Kestrel.Core/Internal/Http/Http1Connection.cs b/src/Kestrel.Core/Internal/Http/Http1Connection.cs index 1251ac3518..d0105f9bfe 100644 --- a/src/Kestrel.Core/Internal/Http/Http1Connection.cs +++ b/src/Kestrel.Core/Internal/Http/Http1Connection.cs @@ -3,13 +3,11 @@ using System; using System.Buffers; -using System.Collections; using System.Diagnostics; +using System.Globalization; using System.IO.Pipelines; using System.Runtime.InteropServices; using System.Text; -using System.Text.Encodings.Web; -using System.Threading; using System.Threading.Tasks; using Microsoft.AspNetCore.Http.Features; using Microsoft.AspNetCore.Protocols.Abstractions; @@ -354,13 +352,8 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http } } - private void EnsureHostHeaderExists() + internal void EnsureHostHeaderExists() { - if (_httpVersion == Http.HttpVersion.Http10) - { - return; - } - // https://tools.ietf.org/html/rfc7230#section-5.4 // A server MUST respond with a 400 (Bad Request) status code to any // HTTP/1.1 request message that lacks a Host header field and to any @@ -368,8 +361,13 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http // Host header field with an invalid field-value. var host = HttpRequestHeaders.HeaderHost; + var hostText = host.ToString(); if (host.Count <= 0) { + if (_httpVersion == Http.HttpVersion.Http10) + { + return; + } BadHttpRequestException.Throw(RequestRejectionReason.MissingHostHeader); } else if (host.Count > 1) @@ -380,7 +378,7 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http { if (!host.Equals(RawTarget)) { - BadHttpRequestException.Throw(RequestRejectionReason.InvalidHostHeader, in host); + BadHttpRequestException.Throw(RequestRejectionReason.InvalidHostHeader, hostText); } } else if (_requestTargetForm == HttpRequestTarget.AbsoluteForm) @@ -392,13 +390,20 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http // System.Uri doesn't not tell us if the port was in the original string or not. // When IsDefaultPort = true, we will allow Host: with or without the default port - var authorityAndPort = _absoluteRequestTarget.Authority + ":" + _absoluteRequestTarget.Port; - if ((host != _absoluteRequestTarget.Authority || !_absoluteRequestTarget.IsDefaultPort) - && host != authorityAndPort) + if (host != _absoluteRequestTarget.Authority) { - BadHttpRequestException.Throw(RequestRejectionReason.InvalidHostHeader, in host); + if (!_absoluteRequestTarget.IsDefaultPort + || host != _absoluteRequestTarget.Authority + ":" + _absoluteRequestTarget.Port.ToString(CultureInfo.InvariantCulture)) + { + BadHttpRequestException.Throw(RequestRejectionReason.InvalidHostHeader, hostText); + } } } + + if (!HttpUtilities.IsValidHostHeader(hostText)) + { + BadHttpRequestException.Throw(RequestRejectionReason.InvalidHostHeader, hostText); + } } protected override void OnReset() diff --git a/src/Kestrel.Core/Internal/Http2/HPack/HPackDecoder.cs b/src/Kestrel.Core/Internal/Http2/HPack/HPackDecoder.cs index 3df0d7af86..84f91ca896 100644 --- a/src/Kestrel.Core/Internal/Http2/HPack/HPackDecoder.cs +++ b/src/Kestrel.Core/Internal/Http2/HPack/HPackDecoder.cs @@ -261,6 +261,10 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http2.HPack if (_integerDecoder.BeginDecode((byte)(b & ~HuffmanMask), StringLengthPrefix)) { OnStringLength(_integerDecoder.Value, nextState: State.HeaderValue); + if (_integerDecoder.Value == 0) + { + ProcessHeaderValue(handler); + } } else { @@ -272,6 +276,10 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http2.HPack if (_integerDecoder.Decode(b)) { OnStringLength(_integerDecoder.Value, nextState: State.HeaderValue); + if (_integerDecoder.Value == 0) + { + ProcessHeaderValue(handler); + } } break; @@ -280,17 +288,7 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http2.HPack if (_stringIndex == _stringLength) { - OnString(nextState: State.Ready); - - var headerNameSpan = new Span(_headerName, 0, _headerNameLength); - var headerValueSpan = new Span(_headerValueOctets, 0, _headerValueLength); - - handler.OnHeader(headerNameSpan, headerValueSpan); - - if (_index) - { - _dynamicTable.Insert(headerNameSpan, headerValueSpan); - } + ProcessHeaderValue(handler); } break; @@ -314,6 +312,21 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http2.HPack } } + private void ProcessHeaderValue(IHttpHeadersHandler handler) + { + OnString(nextState: State.Ready); + + var headerNameSpan = new Span(_headerName, 0, _headerNameLength); + var headerValueSpan = new Span(_headerValueOctets, 0, _headerValueLength); + + handler.OnHeader(headerNameSpan, headerValueSpan); + + if (_index) + { + _dynamicTable.Insert(headerNameSpan, headerValueSpan); + } + } + private void OnIndexedHeaderField(int index, IHttpHeadersHandler handler) { var header = GetHeader(index); diff --git a/src/Kestrel.Core/Internal/Http2/Http2Stream.cs b/src/Kestrel.Core/Internal/Http2/Http2Stream.cs index 41e84805d9..fd73b5ac98 100644 --- a/src/Kestrel.Core/Internal/Http2/Http2Stream.cs +++ b/src/Kestrel.Core/Internal/Http2/Http2Stream.cs @@ -9,6 +9,8 @@ using Microsoft.AspNetCore.Http.Features; using Microsoft.AspNetCore.Server.Kestrel.Core.Features; using Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http; using Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Infrastructure; +using Microsoft.Extensions.Primitives; +using Microsoft.Net.Http.Headers; namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http2 { @@ -54,12 +56,14 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http2 // We don't need any of the parameters because we don't implement BeginRead to actually // do the reading from a pipeline, nor do we use endConnection to report connection-level errors. + _httpVersion = Http.HttpVersion.Http2; var methodText = RequestHeaders[":method"]; Method = HttpUtilities.GetKnownMethod(methodText); _methodText = methodText; - - Scheme = RequestHeaders[":scheme"]; - _httpVersion = Http.HttpVersion.Http2; + if (!string.Equals(RequestHeaders[":scheme"], Scheme, StringComparison.OrdinalIgnoreCase)) + { + BadHttpRequestException.Throw(RequestRejectionReason.InvalidRequestLine); + } var path = RequestHeaders[":path"].ToString(); var queryIndex = path.IndexOf('?'); @@ -68,7 +72,48 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http2 QueryString = queryIndex == -1 ? string.Empty : path.Substring(queryIndex); RawTarget = path; - RequestHeaders["Host"] = RequestHeaders[":authority"]; + // https://tools.ietf.org/html/rfc7230#section-5.4 + // A server MUST respond with a 400 (Bad Request) status code to any + // HTTP/1.1 request message that lacks a Host header field and to any + // request message that contains more than one Host header field or a + // Host header field with an invalid field-value. + + var authority = RequestHeaders[":authority"]; + var host = HttpRequestHeaders.HeaderHost; + if (authority.Count > 0) + { + // https://tools.ietf.org/html/rfc7540#section-8.1.2.3 + // An intermediary that converts an HTTP/2 request to HTTP/1.1 MUST + // create a Host header field if one is not present in a request by + // copying the value of the ":authority" pseudo - header field. + // + // We take this one step further, we don't want mismatched :authority + // and Host headers, replace Host if :authority is defined. + HttpRequestHeaders.HeaderHost = authority; + host = authority; + } + + // TODO: OPTIONS * requests? + // To ensure that the HTTP / 1.1 request line can be reproduced + // accurately, this pseudo - header field MUST be omitted when + // translating from an HTTP/ 1.1 request that has a request target in + // origin or asterisk form(see[RFC7230], Section 5.3). + // https://tools.ietf.org/html/rfc7230#section-5.3 + + if (host.Count <= 0) + { + BadHttpRequestException.Throw(RequestRejectionReason.MissingHostHeader); + } + else if (host.Count > 1) + { + BadHttpRequestException.Throw(RequestRejectionReason.MultipleHostHeaders); + } + + var hostText = host.ToString(); + if (!HttpUtilities.IsValidHostHeader(hostText)) + { + BadHttpRequestException.Throw(RequestRejectionReason.InvalidHostHeader, hostText); + } endConnection = false; return true; diff --git a/src/Kestrel.Core/Internal/Infrastructure/HttpUtilities.Generated.cs b/src/Kestrel.Core/Internal/Infrastructure/HttpUtilities.Generated.cs index 1dd2e252c2..15e3e1cd7b 100644 --- a/src/Kestrel.Core/Internal/Infrastructure/HttpUtilities.Generated.cs +++ b/src/Kestrel.Core/Internal/Infrastructure/HttpUtilities.Generated.cs @@ -51,6 +51,7 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Infrastructure SetKnownMethod(_mask8Chars, _httpConnectMethodLong, HttpMethod.Connect, 7); SetKnownMethod(_mask8Chars, _httpOptionsMethodLong, HttpMethod.Options, 7); FillKnownMethodsGaps(); + InitializeHostCharValidity(); _methodNames[(byte)HttpMethod.Connect] = HttpMethods.Connect; _methodNames[(byte)HttpMethod.Delete] = HttpMethods.Delete; _methodNames[(byte)HttpMethod.Get] = HttpMethods.Get; diff --git a/src/Kestrel.Core/Internal/Infrastructure/HttpUtilities.cs b/src/Kestrel.Core/Internal/Infrastructure/HttpUtilities.cs index 457b7afe5f..bfc3baa89b 100644 --- a/src/Kestrel.Core/Internal/Infrastructure/HttpUtilities.cs +++ b/src/Kestrel.Core/Internal/Infrastructure/HttpUtilities.cs @@ -13,6 +13,8 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Infrastructure { public static partial class HttpUtilities { + private static readonly bool[] HostCharValidity = new bool[127]; + public const string Http10Version = "HTTP/1.0"; public const string Http11Version = "HTTP/1.1"; public const string Http2Version = "HTTP/2"; @@ -29,6 +31,35 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Infrastructure private const ulong _http10VersionLong = 3471766442030158920; // GetAsciiStringAsLong("HTTP/1.0"); const results in better codegen private const ulong _http11VersionLong = 3543824036068086856; // GetAsciiStringAsLong("HTTP/1.1"); const results in better codegen + // Only called from the static constructor + private static void InitializeHostCharValidity() + { + // Matches Http.Sys + // Matches RFC 3986 except "*" / "+" / "," / ";" / "=" and "%" HEXDIG HEXDIG which are not allowed by Http.Sys + HostCharValidity['!'] = true; + HostCharValidity['$'] = true; + HostCharValidity['&'] = true; + HostCharValidity['\''] = true; + HostCharValidity['('] = true; + HostCharValidity[')'] = true; + HostCharValidity['-'] = true; + HostCharValidity['.'] = true; + HostCharValidity['_'] = true; + HostCharValidity['~'] = true; + for (var ch = '0'; ch <= '9'; ch++) + { + HostCharValidity[ch] = true; + } + for (var ch = 'A'; ch <= 'Z'; ch++) + { + HostCharValidity[ch] = true; + } + for (var ch = 'a'; ch <= 'z'; ch++) + { + HostCharValidity[ch] = true; + } + } + [MethodImpl(MethodImplOptions.AggressiveInlining)] private static void SetKnownMethod(ulong mask, ulong knownMethodUlong, HttpMethod knownMethod, int length) { @@ -394,5 +425,107 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Infrastructure return null; } } + + public static bool IsValidHostHeader(string hostText) + { + // The spec allows empty values + if (string.IsNullOrEmpty(hostText)) + { + return true; + } + + if (hostText[0] == '[') + { + return IsValidIPv6Host(hostText); + } + + if (hostText[0] == ':') + { + // Only a port + return false; + } + + var i = 0; + for (; i < hostText.Length; i++) + { + if (!IsValidHostChar(hostText[i])) + { + break; + } + } + return IsValidHostPort(hostText, i); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool IsValidHostChar(char ch) + { + return ch < HostCharValidity.Length && HostCharValidity[ch]; + } + + // The lead '[' was already checked + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool IsValidIPv6Host(string hostText) + { + for (var i = 1; i < hostText.Length; i++) + { + var ch = hostText[i]; + if (ch == ']') + { + // [::1] is the shortest valid IPv6 host + if (i < 4) + { + return false; + } + return IsValidHostPort(hostText, i + 1); + } + + if (!IsHex(ch) && ch != ':' && ch != '.') + { + return false; + } + } + + // Must contain a ']' + return false; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool IsValidHostPort(string hostText, int offset) + { + if (offset == hostText.Length) + { + return true; + } + + if (hostText[offset] != ':' || hostText.Length == offset + 1) + { + // Must have at least one number after the colon if present. + return false; + } + + for (var i = offset + 1; i < hostText.Length; i++) + { + if (!IsNumeric(hostText[i])) + { + return false; + } + } + + return true; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool IsNumeric(char ch) + { + return '0' <= ch && ch <= '9'; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool IsHex(char ch) + { + return IsNumeric(ch) + || ('a' <= ch && ch <= 'f') + || ('A' <= ch && ch <= 'F'); + } } } diff --git a/test/Kestrel.Core.Tests/Http1ConnectionTests.cs b/test/Kestrel.Core.Tests/Http1ConnectionTests.cs index c4cac14b78..9d69dc79df 100644 --- a/test/Kestrel.Core.Tests/Http1ConnectionTests.cs +++ b/test/Kestrel.Core.Tests/Http1ConnectionTests.cs @@ -21,6 +21,7 @@ using Microsoft.AspNetCore.Server.Kestrel.Transport.Abstractions.Internal; using Microsoft.AspNetCore.Testing; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Primitives; +using Microsoft.Net.Http.Headers; using Moq; using Xunit; @@ -813,6 +814,55 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Tests mockMessageBody.Verify(body => body.ConsumeAsync(), Times.Once); } + [Fact] + public void Http10HostHeaderNotRequired() + { + _http1Connection.HttpVersion = "HTTP/1.0"; + _http1Connection.EnsureHostHeaderExists(); + } + + [Fact] + public void Http10HostHeaderAllowed() + { + _http1Connection.HttpVersion = "HTTP/1.0"; + _http1Connection.RequestHeaders[HeaderNames.Host] = "localhost:5000"; + _http1Connection.EnsureHostHeaderExists(); + } + + [Fact] + public void Http11EmptyHostHeaderAccepted() + { + _http1Connection.HttpVersion = "HTTP/1.1"; + _http1Connection.RequestHeaders[HeaderNames.Host] = ""; + _http1Connection.EnsureHostHeaderExists(); + } + + [Fact] + public void Http11ValidHostHeadersAccepted() + { + _http1Connection.HttpVersion = "HTTP/1.1"; + _http1Connection.RequestHeaders[HeaderNames.Host] = "localhost:5000"; + _http1Connection.EnsureHostHeaderExists(); + } + + [Fact] + public void BadRequestFor10BadHostHeaderFormat() + { + _http1Connection.HttpVersion = "HTTP/1.0"; + _http1Connection.RequestHeaders[HeaderNames.Host] = "a=b"; + var ex = Assert.Throws(() => _http1Connection.EnsureHostHeaderExists()); + Assert.Equal(CoreStrings.FormatBadRequest_InvalidHostHeader_Detail("a=b"), ex.Message); + } + + [Fact] + public void BadRequestFor11BadHostHeaderFormat() + { + _http1Connection.HttpVersion = "HTTP/1.1"; + _http1Connection.RequestHeaders[HeaderNames.Host] = "a=b"; + var ex = Assert.Throws(() => _http1Connection.EnsureHostHeaderExists()); + Assert.Equal(CoreStrings.FormatBadRequest_InvalidHostHeader_Detail("a=b"), ex.Message); + } + private static async Task WaitForCondition(TimeSpan timeout, Func condition) { const int MaxWaitLoop = 150; diff --git a/test/Kestrel.Core.Tests/Http2ConnectionTests.cs b/test/Kestrel.Core.Tests/Http2ConnectionTests.cs index 7766a9179b..46af33c2dc 100644 --- a/test/Kestrel.Core.Tests/Http2ConnectionTests.cs +++ b/test/Kestrel.Core.Tests/Http2ConnectionTests.cs @@ -20,6 +20,7 @@ using Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http2.HPack; using Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Infrastructure; using Microsoft.AspNetCore.Server.Kestrel.Transport.Abstractions.Internal; using Microsoft.AspNetCore.Testing; +using Microsoft.Net.Http.Headers; using Xunit; namespace Microsoft.AspNetCore.Server.Kestrel.Core.Tests @@ -33,6 +34,7 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Tests new KeyValuePair(":method", "POST"), new KeyValuePair(":path", "/"), new KeyValuePair(":scheme", "http"), + new KeyValuePair(":authority", "localhost:80"), }; private static readonly IEnumerable> _expectContinueRequestHeaders = new[] @@ -40,7 +42,7 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Tests new KeyValuePair(":method", "POST"), new KeyValuePair(":path", "/"), new KeyValuePair(":authority", "127.0.0.1"), - new KeyValuePair(":scheme", "https"), + new KeyValuePair(":scheme", "http"), new KeyValuePair("expect", "100-continue"), }; @@ -49,6 +51,7 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Tests new KeyValuePair(":method", "GET"), new KeyValuePair(":path", "/"), new KeyValuePair(":scheme", "http"), + new KeyValuePair(":authority", "localhost:80"), new KeyValuePair("user-agent", "Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:54.0) Gecko/20100101 Firefox/54.0"), new KeyValuePair("accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"), new KeyValuePair("accept-language", "en-US,en;q=0.5"), @@ -67,6 +70,7 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Tests new KeyValuePair(":method", "GET"), new KeyValuePair(":path", "/"), new KeyValuePair(":scheme", "http"), + new KeyValuePair(":authority", "localhost:80"), new KeyValuePair("a", _largeHeaderValue), new KeyValuePair("b", _largeHeaderValue), new KeyValuePair("c", _largeHeaderValue), @@ -78,6 +82,7 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Tests new KeyValuePair(":method", "GET"), new KeyValuePair(":path", "/"), new KeyValuePair(":scheme", "http"), + new KeyValuePair(":authority", "localhost:80"), new KeyValuePair("a", _largeHeaderValue), new KeyValuePair("b", _largeHeaderValue), new KeyValuePair("c", _largeHeaderValue), @@ -110,6 +115,7 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Tests private readonly object _abortedStreamIdsLock = new object(); private readonly RequestDelegate _noopApplication; + private readonly RequestDelegate _echoHost; private readonly RequestDelegate _readHeadersApplication; private readonly RequestDelegate _readTrailersApplication; private readonly RequestDelegate _bufferingApplication; @@ -134,6 +140,13 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Tests _noopApplication = context => Task.CompletedTask; + _echoHost = context => + { + context.Response.Headers[HeaderNames.Host] = context.Request.Headers[HeaderNames.Host]; + + return Task.CompletedTask; + }; + _readHeadersApplication = context => { foreach (var header in context.Request.Headers) @@ -1178,6 +1191,311 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Tests await StopConnectionAsync(expectedLastStreamId: 1, ignoreNonGoAwayFrames: false); } + [Fact] + public async Task HEADERS_Received_InvalidAuthority_400Status() + { + var headers = new[] + { + new KeyValuePair(":method", "GET"), + new KeyValuePair(":path", "/"), + new KeyValuePair(":scheme", "http"), + new KeyValuePair(":authority", "local=host:80"), + }; + await InitializeConnectionAsync(_noopApplication); + + await StartStreamAsync(1, headers, endStream: true); + + var headersFrame = await ExpectAsync(Http2FrameType.HEADERS, + withLength: 55, + withFlags: (byte)Http2HeadersFrameFlags.END_HEADERS, + withStreamId: 1); + await ExpectAsync(Http2FrameType.DATA, + withLength: 0, + withFlags: (byte)Http2DataFrameFlags.END_STREAM, + withStreamId: 1); + + await StopConnectionAsync(expectedLastStreamId: 1, ignoreNonGoAwayFrames: false); + + _hpackDecoder.Decode(headersFrame.HeadersPayload, endHeaders: false, handler: this); + + Assert.Equal(3, _decodedHeaders.Count); + Assert.Contains("date", _decodedHeaders.Keys, StringComparer.OrdinalIgnoreCase); + Assert.Equal("400", _decodedHeaders[":status"]); + Assert.Equal("0", _decodedHeaders["content-length"]); + } + + [Fact] + public async Task HEADERS_Received_MissingAuthority_400Status() + { + var headers = new[] + { + new KeyValuePair(":method", "GET"), + new KeyValuePair(":path", "/"), + new KeyValuePair(":scheme", "http"), + }; + await InitializeConnectionAsync(_noopApplication); + + await StartStreamAsync(1, headers, endStream: true); + + var headersFrame = await ExpectAsync(Http2FrameType.HEADERS, + withLength: 55, + withFlags: (byte)Http2HeadersFrameFlags.END_HEADERS, + withStreamId: 1); + await ExpectAsync(Http2FrameType.DATA, + withLength: 0, + withFlags: (byte)Http2DataFrameFlags.END_STREAM, + withStreamId: 1); + + await StopConnectionAsync(expectedLastStreamId: 1, ignoreNonGoAwayFrames: false); + + _hpackDecoder.Decode(headersFrame.HeadersPayload, endHeaders: false, handler: this); + + Assert.Equal(3, _decodedHeaders.Count); + Assert.Contains("date", _decodedHeaders.Keys, StringComparer.OrdinalIgnoreCase); + Assert.Equal("400", _decodedHeaders[":status"]); + Assert.Equal("0", _decodedHeaders["content-length"]); + } + + [Fact] + public async Task HEADERS_Received_TwoHosts_400Status() + { + var headers = new[] + { + new KeyValuePair(":method", "GET"), + new KeyValuePair(":path", "/"), + new KeyValuePair(":scheme", "http"), + new KeyValuePair("Host", "host1"), + new KeyValuePair("Host", "host2"), + }; + await InitializeConnectionAsync(_noopApplication); + + await StartStreamAsync(1, headers, endStream: true); + + var headersFrame = await ExpectAsync(Http2FrameType.HEADERS, + withLength: 55, + withFlags: (byte)Http2HeadersFrameFlags.END_HEADERS, + withStreamId: 1); + await ExpectAsync(Http2FrameType.DATA, + withLength: 0, + withFlags: (byte)Http2DataFrameFlags.END_STREAM, + withStreamId: 1); + + await StopConnectionAsync(expectedLastStreamId: 1, ignoreNonGoAwayFrames: false); + + _hpackDecoder.Decode(headersFrame.HeadersPayload, endHeaders: false, handler: this); + + Assert.Equal(3, _decodedHeaders.Count); + Assert.Contains("date", _decodedHeaders.Keys, StringComparer.OrdinalIgnoreCase); + Assert.Equal("400", _decodedHeaders[":status"]); + Assert.Equal("0", _decodedHeaders["content-length"]); + } + + [Fact] + public async Task HEADERS_Received_EmptyAuthority_200Status() + { + var headers = new[] + { + new KeyValuePair(":method", "GET"), + new KeyValuePair(":path", "/"), + new KeyValuePair(":scheme", "http"), + new KeyValuePair(":authority", ""), + }; + await InitializeConnectionAsync(_noopApplication); + + await StartStreamAsync(1, headers, endStream: true); + + var headersFrame = await ExpectAsync(Http2FrameType.HEADERS, + withLength: 55, + withFlags: (byte)Http2HeadersFrameFlags.END_HEADERS, + withStreamId: 1); + await ExpectAsync(Http2FrameType.DATA, + withLength: 0, + withFlags: (byte)Http2DataFrameFlags.END_STREAM, + withStreamId: 1); + + await StopConnectionAsync(expectedLastStreamId: 1, ignoreNonGoAwayFrames: false); + + _hpackDecoder.Decode(headersFrame.HeadersPayload, endHeaders: false, handler: this); + + Assert.Equal(3, _decodedHeaders.Count); + Assert.Contains("date", _decodedHeaders.Keys, StringComparer.OrdinalIgnoreCase); + Assert.Equal("200", _decodedHeaders[":status"]); + Assert.Equal("0", _decodedHeaders["content-length"]); + } + + [Fact] + public async Task HEADERS_Received_EmptyAuthorityOverridesHost_200Status() + { + var headers = new[] + { + new KeyValuePair(":method", "GET"), + new KeyValuePair(":path", "/"), + new KeyValuePair(":scheme", "http"), + new KeyValuePair(":authority", ""), + new KeyValuePair("Host", "abc"), + }; + await InitializeConnectionAsync(_echoHost); + + await StartStreamAsync(1, headers, endStream: true); + + var headersFrame = await ExpectAsync(Http2FrameType.HEADERS, + withLength: 62, + withFlags: (byte)Http2HeadersFrameFlags.END_HEADERS, + withStreamId: 1); + await ExpectAsync(Http2FrameType.DATA, + withLength: 0, + withFlags: (byte)Http2DataFrameFlags.END_STREAM, + withStreamId: 1); + + await StopConnectionAsync(expectedLastStreamId: 1, ignoreNonGoAwayFrames: false); + + _hpackDecoder.Decode(headersFrame.HeadersPayload, endHeaders: false, handler: this); + + Assert.Equal(4, _decodedHeaders.Count); + Assert.Contains("date", _decodedHeaders.Keys, StringComparer.OrdinalIgnoreCase); + Assert.Equal("200", _decodedHeaders[":status"]); + Assert.Equal("0", _decodedHeaders[HeaderNames.ContentLength]); + Assert.Equal("", _decodedHeaders[HeaderNames.Host]); + } + + [Fact] + public async Task HEADERS_Received_AuthorityOverridesHost_200Status() + { + var headers = new[] + { + new KeyValuePair(":method", "GET"), + new KeyValuePair(":path", "/"), + new KeyValuePair(":scheme", "http"), + new KeyValuePair(":authority", "def"), + new KeyValuePair("Host", "abc"), + }; + await InitializeConnectionAsync(_echoHost); + + await StartStreamAsync(1, headers, endStream: true); + + var headersFrame = await ExpectAsync(Http2FrameType.HEADERS, + withLength: 65, + withFlags: (byte)Http2HeadersFrameFlags.END_HEADERS, + withStreamId: 1); + await ExpectAsync(Http2FrameType.DATA, + withLength: 0, + withFlags: (byte)Http2DataFrameFlags.END_STREAM, + withStreamId: 1); + + await StopConnectionAsync(expectedLastStreamId: 1, ignoreNonGoAwayFrames: false); + + _hpackDecoder.Decode(headersFrame.HeadersPayload, endHeaders: false, handler: this); + + Assert.Equal(4, _decodedHeaders.Count); + Assert.Contains("date", _decodedHeaders.Keys, StringComparer.OrdinalIgnoreCase); + Assert.Equal("200", _decodedHeaders[":status"]); + Assert.Equal("0", _decodedHeaders[HeaderNames.ContentLength]); + Assert.Equal("def", _decodedHeaders[HeaderNames.Host]); + } + + [Fact] + public async Task HEADERS_Received_MissingAuthorityFallsBackToHost_200Status() + { + var headers = new[] + { + new KeyValuePair(":method", "GET"), + new KeyValuePair(":path", "/"), + new KeyValuePair(":scheme", "http"), + new KeyValuePair("Host", "abc"), + }; + await InitializeConnectionAsync(_echoHost); + + await StartStreamAsync(1, headers, endStream: true); + + var headersFrame = await ExpectAsync(Http2FrameType.HEADERS, + withLength: 65, + withFlags: (byte)Http2HeadersFrameFlags.END_HEADERS, + withStreamId: 1); + await ExpectAsync(Http2FrameType.DATA, + withLength: 0, + withFlags: (byte)Http2DataFrameFlags.END_STREAM, + withStreamId: 1); + + await StopConnectionAsync(expectedLastStreamId: 1, ignoreNonGoAwayFrames: false); + + _hpackDecoder.Decode(headersFrame.HeadersPayload, endHeaders: false, handler: this); + + Assert.Equal(4, _decodedHeaders.Count); + Assert.Contains("date", _decodedHeaders.Keys, StringComparer.OrdinalIgnoreCase); + Assert.Equal("200", _decodedHeaders[":status"]); + Assert.Equal("0", _decodedHeaders[HeaderNames.ContentLength]); + Assert.Equal("abc", _decodedHeaders[HeaderNames.Host]); + } + + [Fact] + public async Task HEADERS_Received_AuthorityOverridesInvalidHost_200Status() + { + var headers = new[] + { + new KeyValuePair(":method", "GET"), + new KeyValuePair(":path", "/"), + new KeyValuePair(":scheme", "http"), + new KeyValuePair(":authority", "def"), + new KeyValuePair("Host", "a=bc"), + }; + await InitializeConnectionAsync(_echoHost); + + await StartStreamAsync(1, headers, endStream: true); + + var headersFrame = await ExpectAsync(Http2FrameType.HEADERS, + withLength: 65, + withFlags: (byte)Http2HeadersFrameFlags.END_HEADERS, + withStreamId: 1); + await ExpectAsync(Http2FrameType.DATA, + withLength: 0, + withFlags: (byte)Http2DataFrameFlags.END_STREAM, + withStreamId: 1); + + await StopConnectionAsync(expectedLastStreamId: 1, ignoreNonGoAwayFrames: false); + + _hpackDecoder.Decode(headersFrame.HeadersPayload, endHeaders: false, handler: this); + + Assert.Equal(4, _decodedHeaders.Count); + Assert.Contains("date", _decodedHeaders.Keys, StringComparer.OrdinalIgnoreCase); + Assert.Equal("200", _decodedHeaders[":status"]); + Assert.Equal("0", _decodedHeaders[HeaderNames.ContentLength]); + Assert.Equal("def", _decodedHeaders[HeaderNames.Host]); + } + + [Fact] + public async Task HEADERS_Received_InvalidAuthorityWithValidHost_400Status() + { + var headers = new[] + { + new KeyValuePair(":method", "GET"), + new KeyValuePair(":path", "/"), + new KeyValuePair(":scheme", "http"), + new KeyValuePair(":authority", "d=ef"), + new KeyValuePair("Host", "abc"), + }; + await InitializeConnectionAsync(_echoHost); + + await StartStreamAsync(1, headers, endStream: true); + + var headersFrame = await ExpectAsync(Http2FrameType.HEADERS, + withLength: 55, + withFlags: (byte)Http2HeadersFrameFlags.END_HEADERS, + withStreamId: 1); + await ExpectAsync(Http2FrameType.DATA, + withLength: 0, + withFlags: (byte)Http2DataFrameFlags.END_STREAM, + withStreamId: 1); + + await StopConnectionAsync(expectedLastStreamId: 1, ignoreNonGoAwayFrames: false); + + _hpackDecoder.Decode(headersFrame.HeadersPayload, endHeaders: false, handler: this); + + Assert.Equal(3, _decodedHeaders.Count); + Assert.Contains("date", _decodedHeaders.Keys, StringComparer.OrdinalIgnoreCase); + Assert.Equal("400", _decodedHeaders[":status"]); + Assert.Equal("0", _decodedHeaders[HeaderNames.ContentLength]); + } + [Fact] public async Task PRIORITY_Received_StreamIdZero_ConnectionError() { diff --git a/test/Kestrel.Core.Tests/HttpUtilitiesTest.cs b/test/Kestrel.Core.Tests/HttpUtilitiesTest.cs index 8966c181b0..d2c1233cee 100644 --- a/test/Kestrel.Core.Tests/HttpUtilitiesTest.cs +++ b/test/Kestrel.Core.Tests/HttpUtilitiesTest.cs @@ -131,5 +131,100 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Tests Assert.Equal(knownString1, expected); Assert.Same(knownString1, knownString2); } + + public static TheoryData HostHeaderData + { + get + { + return new TheoryData() { + "z", + "1", + "y:1", + "1:1", + "[ABCdef]", + "[abcDEF]:0", + "[abcdef:127.2355.1246.114]:0", + "[::1]:80", + "127.0.0.1:80", + "900.900.900.900:9523547852", + "foo", + "foo:234", + "foo.bar.baz", + "foo.BAR.baz:46245", + "foo.ba-ar.baz:46245", + "-foo:1234", + "xn--asdfaf:134", + "-", + "_", + "~", + "!", + "$", + "'", + "(", + ")", + }; + } + } + + [Theory] + [MemberData(nameof(HostHeaderData))] + public void ValidHostHeadersParsed(string host) + { + Assert.True(HttpUtilities.IsValidHostHeader(host)); + } + + public static TheoryData HostHeaderInvalidData + { + get + { + // see https://tools.ietf.org/html/rfc7230#section-5.4 + var data = new TheoryData() { + "[]", // Too short + "[::]", // Too short + "[ghijkl]", // Non-hex + "[afd:adf:123", // Incomplete + "[afd:adf]123", // Missing : + "[afd:adf]:", // Missing port digits + "[afd adf]", // Space + "[ad-314]", // dash + ":1234", // Missing host + "a:b:c", // Missing [] + "::1", // Missing [] + "::", // Missing everything + "abcd:1abcd", // Letters in port + "abcd:1.2", // Dot in port + "1.2.3.4:", // Missing port digits + "1.2 .4", // Space + }; + + // These aren't allowed anywhere in the host header + var invalid = "\"#%*+,/;<=>?@[]\\^`{}|"; + foreach (var ch in invalid) + { + data.Add(ch.ToString()); + } + + invalid = "!\"#$%&'()*+,/;<=>?@[]\\^_`{}|~-"; + foreach (var ch in invalid) + { + data.Add("[abd" + ch + "]:1234"); + } + + invalid = "!\"#$%&'()*+,/;<=>?@[]\\^_`{}|~:abcABC-."; + foreach (var ch in invalid) + { + data.Add("a.b.c:" + ch); + } + + return data; + } + } + + [Theory] + [MemberData(nameof(HostHeaderInvalidData))] + public void InvalidHostHeadersRejected(string host) + { + Assert.False(HttpUtilities.IsValidHostHeader(host)); + } } } \ No newline at end of file diff --git a/test/Kestrel.FunctionalTests/BadHttpRequestTests.cs b/test/Kestrel.FunctionalTests/BadHttpRequestTests.cs index 29b8164fc2..d01f81b347 100644 --- a/test/Kestrel.FunctionalTests/BadHttpRequestTests.cs +++ b/test/Kestrel.FunctionalTests/BadHttpRequestTests.cs @@ -132,6 +132,24 @@ namespace Microsoft.AspNetCore.Server.Kestrel.FunctionalTests CoreStrings.FormatBadRequest_InvalidHostHeader_Detail(host.Trim())); } + [Fact] + public Task BadRequestFor10BadHostHeaderFormat() + { + return TestBadRequest( + $"GET / HTTP/1.0\r\nHost: a=b\r\n\r\n", + "400 Bad Request", + CoreStrings.FormatBadRequest_InvalidHostHeader_Detail("a=b")); + } + + [Fact] + public Task BadRequestFor11BadHostHeaderFormat() + { + return TestBadRequest( + $"GET / HTTP/1.1\r\nHost: a=b\r\n\r\n", + "400 Bad Request", + CoreStrings.FormatBadRequest_InvalidHostHeader_Detail("a=b")); + } + [Fact] public async Task BadRequestLogsAreNotHigherThanInformation() { diff --git a/test/Kestrel.FunctionalTests/RequestTests.cs b/test/Kestrel.FunctionalTests/RequestTests.cs index 8b712af1a2..673cf42ff8 100644 --- a/test/Kestrel.FunctionalTests/RequestTests.cs +++ b/test/Kestrel.FunctionalTests/RequestTests.cs @@ -603,7 +603,7 @@ namespace Microsoft.AspNetCore.Server.Kestrel.FunctionalTests { var requestTarget = new Uri(requestUrl, UriKind.Absolute); var host = requestTarget.Authority; - if (!requestTarget.IsDefaultPort) + if (requestTarget.IsDefaultPort) { host += ":" + requestTarget.Port; } diff --git a/tools/CodeGenerator/HttpUtilities/HttpUtilities.cs b/tools/CodeGenerator/HttpUtilities/HttpUtilities.cs index 7263d1fd16..0c70c0c188 100644 --- a/tools/CodeGenerator/HttpUtilities/HttpUtilities.cs +++ b/tools/CodeGenerator/HttpUtilities/HttpUtilities.cs @@ -84,6 +84,7 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Infrastructure {{ {4} FillKnownMethodsGaps(); + InitializeHostCharValidity(); {5} }}