Skip to content

Commit 26adec8

Browse files
JamesNKTratcher
andauthored
Detect HTTP/1.1 on HTTP/2 connection (#39358)
Co-authored-by: Chris Ross <chrross@microsoft.com>
1 parent d381765 commit 26adec8

File tree

8 files changed

+291
-42
lines changed

8 files changed

+291
-42
lines changed

src/Servers/Kestrel/Core/src/Internal/Http2/Http2Connection.cs

+110-8
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
using System.Net.Http;
99
using System.Net.Http.HPack;
1010
using System.Security.Authentication;
11+
using System.Text;
1112
using Microsoft.AspNetCore.Connections;
1213
using Microsoft.AspNetCore.Connections.Features;
1314
using Microsoft.AspNetCore.Hosting.Server;
@@ -23,6 +24,7 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http2;
2324
internal partial class Http2Connection : IHttp2StreamLifetimeHandler, IHttpStreamHeadersHandler, IRequestProcessor
2425
{
2526
public static ReadOnlySpan<byte> ClientPreface => ClientPrefaceBytes;
27+
public static byte[]? InvalidHttp1xErrorResponseBytes;
2628

2729
private const PseudoHeaderFields _mandatoryRequestPseudoHeaderFields =
2830
PseudoHeaderFields.Method | PseudoHeaderFields.Path | PseudoHeaderFields.Scheme;
@@ -402,8 +404,40 @@ private void ValidateTlsRequirements()
402404
}
403405
}
404406

407+
[Flags]
408+
private enum ReadPrefaceState
409+
{
410+
None = 0,
411+
Preface = 1,
412+
Http1x = 2,
413+
All = Preface | Http1x
414+
}
415+
405416
private async Task<bool> TryReadPrefaceAsync()
406417
{
418+
// HTTP/1.x and HTTP/2 support connections without TLS. That means ALPN hasn't been used to ensure both sides are
419+
// using the same protocol. A common problem is someone using HTTP/1.x to talk to a HTTP/2 only endpoint.
420+
//
421+
// HTTP/2 starts a connection with a preface. This method reads and validates it. If the connection doesn't start
422+
// with the preface, and it isn't using TLS, then we attempt to detect what the client is trying to do and send
423+
// back a friendly error message.
424+
//
425+
// Outcomes from this method:
426+
// 1. Successfully read HTTP/2 preface. Connection continues to be established.
427+
// 2. Detect HTTP/1.x request. Send back HTTP/1.x 400 response.
428+
// 3. Unknown content. Report HTTP/2 PROTOCOL_ERROR to client.
429+
// 4. Timeout while waiting for content.
430+
//
431+
// Future improvement: Detect TLS frame. Useful for people starting TLS connection with a non-TLS endpoint.
432+
var state = ReadPrefaceState.All;
433+
434+
// With TLS, ALPN should have already errored if the wrong HTTP version is used.
435+
// Only perform additional validation if endpoint doesn't use TLS.
436+
if (ConnectionFeatures.Get<ITlsHandshakeFeature>() != null)
437+
{
438+
state ^= ReadPrefaceState.Http1x;
439+
}
440+
407441
while (_isClosed == 0)
408442
{
409443
var result = await Input.ReadAsync();
@@ -415,9 +449,55 @@ private async Task<bool> TryReadPrefaceAsync()
415449
{
416450
if (!readableBuffer.IsEmpty)
417451
{
418-
if (ParsePreface(readableBuffer, out consumed, out examined))
452+
if (state.HasFlag(ReadPrefaceState.Preface))
453+
{
454+
if (readableBuffer.Length >= ClientPreface.Length)
455+
{
456+
if (IsPreface(readableBuffer, out consumed, out examined))
457+
{
458+
return true;
459+
}
460+
else
461+
{
462+
state ^= ReadPrefaceState.Preface;
463+
}
464+
}
465+
}
466+
467+
if (state.HasFlag(ReadPrefaceState.Http1x))
468+
{
469+
if (ParseHttp1x(readableBuffer, out var detectedVersion))
470+
{
471+
if (detectedVersion == HttpVersion.Http10 || detectedVersion == HttpVersion.Http11)
472+
{
473+
Log.PossibleInvalidHttpVersionDetected(ConnectionId, HttpVersion.Http2, detectedVersion);
474+
475+
var responseBytes = InvalidHttp1xErrorResponseBytes ??= Encoding.ASCII.GetBytes(
476+
"HTTP/1.1 400 Bad Request\r\n" +
477+
"Connection: close\r\n" +
478+
"Content-Type: text/plain\r\n" +
479+
"Content-Length: 56\r\n" +
480+
"\r\n" +
481+
"An HTTP/1.x request was sent to an HTTP/2 only endpoint.");
482+
483+
await _context.Transport.Output.WriteAsync(responseBytes);
484+
485+
// Close connection here so a GOAWAY frame isn't written.
486+
TryClose();
487+
488+
return false;
489+
}
490+
else
491+
{
492+
state ^= ReadPrefaceState.Http1x;
493+
}
494+
}
495+
}
496+
497+
// Tested all states. Return HTTP/2 protocol error.
498+
if (state == ReadPrefaceState.None)
419499
{
420-
return true;
500+
throw new Http2ConnectionErrorException(CoreStrings.Http2ErrorInvalidPreface, Http2ErrorCode.PROTOCOL_ERROR);
421501
}
422502
}
423503

@@ -437,22 +517,44 @@ private async Task<bool> TryReadPrefaceAsync()
437517
return false;
438518
}
439519

440-
private static bool ParsePreface(in ReadOnlySequence<byte> buffer, out SequencePosition consumed, out SequencePosition examined)
520+
private bool ParseHttp1x(ReadOnlySequence<byte> buffer, out HttpVersion httpVersion)
441521
{
442-
consumed = buffer.Start;
443-
examined = buffer.End;
522+
httpVersion = HttpVersion.Unknown;
444523

445-
if (buffer.Length < ClientPreface.Length)
524+
var reader = new SequenceReader<byte>(buffer.Length > Limits.MaxRequestLineSize ? buffer.Slice(0, Limits.MaxRequestLineSize) : buffer);
525+
if (reader.TryReadTo(out ReadOnlySpan<byte> requestLine, (byte)'\n'))
446526
{
447-
return false;
527+
// Line should be long enough for HTTP/1.X and end with \r\n
528+
if (requestLine.Length > 10 && requestLine[requestLine.Length - 1] == (byte)'\r')
529+
{
530+
httpVersion = HttpUtilities.GetKnownVersion(requestLine.Slice(requestLine.Length - 9, 8));
531+
}
532+
533+
return true;
534+
}
535+
536+
// Couldn't find newline within max request line size so this isn't valid HTTP/1.x.
537+
if (buffer.Length > Limits.MaxRequestLineSize)
538+
{
539+
return true;
448540
}
449541

542+
return false;
543+
}
544+
545+
private static bool IsPreface(in ReadOnlySequence<byte> buffer, out SequencePosition consumed, out SequencePosition examined)
546+
{
547+
consumed = buffer.Start;
548+
examined = buffer.End;
549+
550+
Debug.Assert(buffer.Length >= ClientPreface.Length, "Not enough content to match preface.");
551+
450552
var preface = buffer.Slice(0, ClientPreface.Length);
451553
var span = preface.ToSpan();
452554

453555
if (!span.SequenceEqual(ClientPreface))
454556
{
455-
throw new Http2ConnectionErrorException(CoreStrings.Http2ErrorInvalidPreface, Http2ErrorCode.PROTOCOL_ERROR);
557+
return false;
456558
}
457559

458560
consumed = examined = preface.End;

src/Servers/Kestrel/Core/src/Internal/Infrastructure/KestrelTrace.cs

+12
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
using System.Net.Http;
55
using Microsoft.AspNetCore.Connections;
6+
using Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http;
67
using Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http2;
78
using Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http3;
89
using Microsoft.Extensions.Logging;
@@ -394,6 +395,17 @@ public void Http3GoAwayStreamId(string connectionId, long goAwayStreamId)
394395
Http3GoAwayStreamId(_http3Logger, connectionId, goAwayStreamId);
395396
}
396397

398+
[LoggerMessage(54, LogLevel.Debug, @"Connection id ""{ConnectionId}"": Invalid content received on connection. Possible incorrect HTTP version detected. Expected {ExpectedHttpVersion} but received {DetectedHttpVersion}.", EventName = "PossibleInvalidHttpVersionDetected", SkipEnabledCheck = true)]
399+
private static partial void PossibleInvalidHttpVersionDetected(ILogger logger, string connectionId, string expectedHttpVersion, string detectedHttpVersion);
400+
401+
public void PossibleInvalidHttpVersionDetected(string connectionId, HttpVersion expectedHttpVersion, HttpVersion detectedHttpVersion)
402+
{
403+
if (_generalLogger.IsEnabled(LogLevel.Debug))
404+
{
405+
PossibleInvalidHttpVersionDetected(_badRequestsLogger, connectionId, HttpUtilities.VersionToString(expectedHttpVersion), HttpUtilities.VersionToString(detectedHttpVersion));
406+
}
407+
}
408+
397409
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
398410
=> _generalLogger.Log(logLevel, eventId, state, exception, formatter);
399411

src/Servers/Kestrel/test/InMemory.FunctionalTests/Http2/Http2ConnectionTests.cs

+68-1
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,14 @@
66
using System.Globalization;
77
using System.Net.Http;
88
using System.Net.Http.HPack;
9+
using System.Net.Security;
10+
using System.Security.Authentication;
911
using System.Text;
1012
using Microsoft.AspNetCore.Connections;
1113
using Microsoft.AspNetCore.Connections.Features;
1214
using Microsoft.AspNetCore.Http;
1315
using Microsoft.AspNetCore.Http.Features;
1416
using Microsoft.AspNetCore.Server.Kestrel.Core.Features;
15-
using Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http;
1617
using Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http2;
1718
using Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Infrastructure;
1819
using Microsoft.AspNetCore.Testing;
@@ -5211,6 +5212,72 @@ await WaitForConnectionErrorAsync<Http2ConnectionErrorException>(
52115212
});
52125213
}
52135214

5215+
[Fact]
5216+
public async Task StartConnection_SendPreface_ReturnSettings()
5217+
{
5218+
await InitializeConnectionWithoutPrefaceAsync(_noopApplication);
5219+
5220+
await SendAsync(Http2Connection.ClientPreface);
5221+
5222+
await ExpectAsync(Http2FrameType.SETTINGS,
5223+
withLength: 3 * Http2FrameReader.SettingSize,
5224+
withFlags: 0,
5225+
withStreamId: 0);
5226+
5227+
await StopConnectionAsync(expectedLastStreamId: 0, ignoreNonGoAwayFrames: true);
5228+
}
5229+
5230+
[Fact]
5231+
public async Task StartConnection_SendHttp1xRequest_ReturnHttp11Status400()
5232+
{
5233+
await InitializeConnectionWithoutPrefaceAsync(_noopApplication);
5234+
5235+
await SendAsync(Encoding.ASCII.GetBytes("GET / HTTP/1.1\r\n"));
5236+
5237+
var data = await ReadAllAsync();
5238+
5239+
Assert.NotNull(Http2Connection.InvalidHttp1xErrorResponseBytes);
5240+
Assert.Equal(Http2Connection.InvalidHttp1xErrorResponseBytes, data);
5241+
}
5242+
5243+
[Fact]
5244+
public async Task StartConnection_SendHttp1xRequest_ExceedsRequestLineLimit_ProtocolError()
5245+
{
5246+
await InitializeConnectionWithoutPrefaceAsync(_noopApplication);
5247+
5248+
await SendAsync(Encoding.ASCII.GetBytes($"GET /{new string('a', _connection.Limits.MaxRequestLineSize)} HTTP/1.1\r\n"));
5249+
5250+
await WaitForConnectionErrorAsync<Http2ConnectionErrorException>(
5251+
ignoreNonGoAwayFrames: false,
5252+
expectedLastStreamId: 0,
5253+
expectedErrorCode: Http2ErrorCode.PROTOCOL_ERROR,
5254+
expectedErrorMessage: CoreStrings.Http2ErrorInvalidPreface);
5255+
}
5256+
5257+
[Fact]
5258+
public async Task StartTlsConnection_SendHttp1xRequest_NoError()
5259+
{
5260+
CreateConnection();
5261+
5262+
var tlsHandshakeMock = new Mock<ITlsHandshakeFeature>();
5263+
tlsHandshakeMock.SetupGet(m => m.Protocol).Returns(SslProtocols.Tls12);
5264+
_connection.ConnectionFeatures.Set<ITlsHandshakeFeature>(tlsHandshakeMock.Object);
5265+
5266+
await InitializeConnectionWithoutPrefaceAsync(_noopApplication);
5267+
5268+
await SendAsync(Encoding.ASCII.GetBytes("GET / HTTP/1.1\r\n"));
5269+
5270+
await StopConnectionAsync(expectedLastStreamId: 0, ignoreNonGoAwayFrames: false);
5271+
}
5272+
5273+
[Fact]
5274+
public async Task StartConnection_SendNothing_NoError()
5275+
{
5276+
await InitializeConnectionWithoutPrefaceAsync(_noopApplication);
5277+
5278+
await StopConnectionAsync(expectedLastStreamId: 0, ignoreNonGoAwayFrames: false);
5279+
}
5280+
52145281
public static TheoryData<byte[]> UpperCaseHeaderNameData
52155282
{
52165283
get

src/Servers/Kestrel/test/InMemory.FunctionalTests/Http2/Http2TestBase.cs

+22-1
Original file line numberDiff line numberDiff line change
@@ -470,7 +470,7 @@ protected void CreateConnection()
470470
_timeoutControl.Initialize(_serviceContext.SystemClock.UtcNow.Ticks);
471471
}
472472

473-
protected async Task InitializeConnectionAsync(RequestDelegate application, int expectedSettingsCount = 3, bool expectedWindowUpdate = true)
473+
protected async Task InitializeConnectionWithoutPrefaceAsync(RequestDelegate application)
474474
{
475475
if (_connection == null)
476476
{
@@ -496,6 +496,11 @@ async Task CompletePipeOnTaskCompletion()
496496

497497
// Lose xUnit's AsyncTestSyncContext so middleware always runs inline for better determinism.
498498
await ThreadPoolAwaitable.Instance;
499+
}
500+
501+
protected async Task InitializeConnectionAsync(RequestDelegate application, int expectedSettingsCount = 3, bool expectedWindowUpdate = true)
502+
{
503+
await InitializeConnectionWithoutPrefaceAsync(application);
499504
await SendPreambleAsync();
500505
await SendSettingsAsync();
501506

@@ -1109,6 +1114,22 @@ protected Task SendUnknownFrameTypeAsync(int streamId, int frameType)
11091114
return FlushAsync(outputWriter);
11101115
}
11111116

1117+
internal async Task<byte[]> ReadAllAsync()
1118+
{
1119+
while (true)
1120+
{
1121+
var result = await _pair.Application.Input.ReadAsync().AsTask().DefaultTimeout();
1122+
1123+
if (result.IsCompleted)
1124+
{
1125+
return result.Buffer.ToArray();
1126+
}
1127+
1128+
// Consume nothing, just wait for everything
1129+
_pair.Application.Input.AdvanceTo(result.Buffer.Start, result.Buffer.End);
1130+
}
1131+
}
1132+
11121133
internal async Task<Http2FrameWithPayload> ReceiveFrameAsync(uint maxFrameSize = Http2PeerSettings.DefaultMaxFrameSize)
11131134
{
11141135
var frame = new Http2FrameWithPayload();
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using System.Net;
5+
using System.Net.Http;
6+
using Microsoft.AspNetCore.Hosting;
7+
using Microsoft.AspNetCore.Http;
8+
using Microsoft.AspNetCore.Server.Kestrel.Core;
9+
using Microsoft.AspNetCore.Testing;
10+
using Microsoft.Extensions.Hosting;
11+
12+
namespace Interop.FunctionalTests.Http2;
13+
14+
public class Http2RequestTests : LoggedTest
15+
{
16+
[Fact]
17+
public async Task GET_NoTLS_Http11RequestToHttp2Endpoint_400Result()
18+
{
19+
// Arrange
20+
var builder = CreateHostBuilder(c => Task.CompletedTask, protocol: HttpProtocols.Http2, plaintext: true);
21+
22+
using (var host = builder.Build())
23+
using (var client = HttpHelpers.CreateClient())
24+
{
25+
await host.StartAsync().DefaultTimeout();
26+
27+
var request = new HttpRequestMessage(HttpMethod.Get, $"http://127.0.0.1:{host.GetPort()}/");
28+
request.Version = HttpVersion.Version11;
29+
request.VersionPolicy = HttpVersionPolicy.RequestVersionExact;
30+
31+
// Act
32+
var responseMessage = await client.SendAsync(request, CancellationToken.None).DefaultTimeout();
33+
34+
// Assert
35+
Assert.Equal(HttpStatusCode.BadRequest, responseMessage.StatusCode);
36+
Assert.Equal("An HTTP/1.x request was sent to an HTTP/2 only endpoint.", await responseMessage.Content.ReadAsStringAsync());
37+
}
38+
}
39+
40+
private IHostBuilder CreateHostBuilder(RequestDelegate requestDelegate, HttpProtocols? protocol = null, Action<KestrelServerOptions> configureKestrel = null, bool? plaintext = null)
41+
{
42+
return HttpHelpers.CreateHostBuilder(AddTestLogging, requestDelegate, protocol, configureKestrel, plaintext);
43+
}
44+
}

0 commit comments

Comments
 (0)