diff --git a/src/Kestrel.Core/CoreStrings.resx b/src/Kestrel.Core/CoreStrings.resx index 6fea320658..7127c41905 100644 --- a/src/Kestrel.Core/CoreStrings.resx +++ b/src/Kestrel.Core/CoreStrings.resx @@ -360,4 +360,28 @@ HTTP/2 over TLS was not negotiated on an HTTP/2-only endpoint. + + A dynamic table size of {size} octets is greater than the configured maximum size of {maxSize} octets. + + + Index {index} is outside the bounds of the header field table. + + + Input data could not be fully decoded. + + + Input data contains the EOS symbol. + + + The destination buffer is not large enough to store the decoded data. + + + Huffman decoding error. + + + Decoded string length of {length} octets is greater than the configured maximum length of {maxStringLength} octets. + + + The header block was incomplete and could not be fully decoded. + \ 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 9eb06465c1..9357a9d742 100644 --- a/src/Kestrel.Core/Internal/Http/Http1Connection.cs +++ b/src/Kestrel.Core/Internal/Http/Http1Connection.cs @@ -344,18 +344,6 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http } } - public void OnHeader(Span name, Span value) - { - _requestHeadersParsed++; - if (_requestHeadersParsed > ServerOptions.Limits.MaxRequestHeaderCount) - { - ThrowRequestRejected(RequestRejectionReason.TooManyHeaders); - } - var valueString = value.GetAsciiStringNonNullCharacters(); - - HttpRequestHeaders.Append(name, valueString); - } - protected void EnsureHostHeaderExists() { if (_httpVersion == Http.HttpVersion.Http10) diff --git a/src/Kestrel.Core/Internal/Http/HttpProtocol.cs b/src/Kestrel.Core/Internal/Http/HttpProtocol.cs index e99f929a1f..7ac194dd6e 100644 --- a/src/Kestrel.Core/Internal/Http/HttpProtocol.cs +++ b/src/Kestrel.Core/Internal/Http/HttpProtocol.cs @@ -418,6 +418,18 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http } } + public void OnHeader(Span name, Span value) + { + _requestHeadersParsed++; + if (_requestHeadersParsed > ServerOptions.Limits.MaxRequestHeaderCount) + { + ThrowRequestRejected(RequestRejectionReason.TooManyHeaders); + } + var valueString = value.GetAsciiStringNonNullCharacters(); + + HttpRequestHeaders.Append(name, valueString); + } + public async Task ProcessRequestsAsync() { try diff --git a/src/Kestrel.Core/Internal/Http2/HPack/DynamicTable.cs b/src/Kestrel.Core/Internal/Http2/HPack/DynamicTable.cs index 0b58c69ba6..6a13e49b82 100644 --- a/src/Kestrel.Core/Internal/Http2/HPack/DynamicTable.cs +++ b/src/Kestrel.Core/Internal/Http2/HPack/DynamicTable.cs @@ -7,8 +7,8 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http2.HPack { public class DynamicTable { - private readonly HeaderField[] _buffer; - private int _maxSize = 4096; + private HeaderField[] _buffer; + private int _maxSize; private int _size; private int _count; private int _insertIndex; @@ -16,7 +16,7 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http2.HPack public DynamicTable(int maxSize) { - _buffer = new HeaderField[maxSize]; + _buffer = new HeaderField[maxSize / HeaderField.RfcOverhead]; _maxSize = maxSize; } @@ -24,6 +24,8 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http2.HPack public int Size => _size; + public int MaxSize => _maxSize; + public HeaderField this[int index] { get @@ -37,33 +39,53 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http2.HPack } } - public void Insert(string name, string value) + public void Insert(Span name, Span value) { - var entrySize = name.Length + value.Length + 32; - EnsureSize(_maxSize - entrySize); + var entryLength = HeaderField.GetLength(name.Length, value.Length); + EnsureAvailable(entryLength); - if (_maxSize < entrySize) + if (entryLength > _maxSize) { - throw new InvalidOperationException($"Unable to add entry of size {entrySize} to dynamic table of size {_maxSize}."); + // http://httpwg.org/specs/rfc7541.html#rfc.section.4.4 + // It is not an error to attempt to add an entry that is larger than the maximum size; + // an attempt to add an entry larger than the maximum size causes the table to be emptied + // of all existing entries and results in an empty table. + return; } - _buffer[_insertIndex] = new HeaderField(name, value); + var entry = new HeaderField(name, value); + _buffer[_insertIndex] = entry; _insertIndex = (_insertIndex + 1) % _buffer.Length; - _size += entrySize; + _size += entry.Length; _count++; } public void Resize(int maxSize) { - _maxSize = maxSize; - EnsureSize(_maxSize); + if (maxSize > _maxSize) + { + var newBuffer = new HeaderField[maxSize / HeaderField.RfcOverhead]; + + for (var i = 0; i < Count; i++) + { + newBuffer[i] = _buffer[i]; + } + + _buffer = newBuffer; + _maxSize = maxSize; + } + else + { + _maxSize = maxSize; + EnsureAvailable(0); + } } - public void EnsureSize(int size) + private void EnsureAvailable(int available) { - while (_count > 0 && _size > size) + while (_count > 0 && _maxSize - _size < available) { - _size -= _buffer[_removeIndex].Name.Length + _buffer[_removeIndex].Value.Length + 32; + _size -= _buffer[_removeIndex].Length; _count--; _removeIndex = (_removeIndex + 1) % _buffer.Length; } diff --git a/src/Kestrel.Core/Internal/Http2/HPack/HPackDecoder.cs b/src/Kestrel.Core/Internal/Http2/HPack/HPackDecoder.cs index 33a1e014ad..3df0d7af86 100644 --- a/src/Kestrel.Core/Internal/Http2/HPack/HPackDecoder.cs +++ b/src/Kestrel.Core/Internal/Http2/HPack/HPackDecoder.cs @@ -2,10 +2,7 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; -using System.Collections.Generic; -using System.Text; -using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.Logging; +using Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http; namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http2.HPack { @@ -22,14 +19,57 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http2.HPack HeaderValueLength, HeaderValueLengthContinue, HeaderValue, - DynamicTableSize + DynamicTableSizeUpdate } + // TODO: add new configurable limit + public const int MaxStringOctets = 4096; + + // http://httpwg.org/specs/rfc7541.html#rfc.section.6.1 + // 0 1 2 3 4 5 6 7 + // +---+---+---+---+---+---+---+---+ + // | 1 | Index (7+) | + // +---+---------------------------+ private const byte IndexedHeaderFieldMask = 0x80; - private const byte LiteralHeaderFieldWithIncrementalIndexingMask = 0x40; - private const byte LiteralHeaderFieldWithoutIndexingMask = 0x00; - private const byte LiteralHeaderFieldNeverIndexedMask = 0x10; - private const byte DynamicTableSizeUpdateMask = 0x20; + private const byte IndexedHeaderFieldRepresentation = 0x80; + + // http://httpwg.org/specs/rfc7541.html#rfc.section.6.2.1 + // 0 1 2 3 4 5 6 7 + // +---+---+---+---+---+---+---+---+ + // | 0 | 1 | Index (6+) | + // +---+---+-----------------------+ + private const byte LiteralHeaderFieldWithIncrementalIndexingMask = 0xc0; + private const byte LiteralHeaderFieldWithIncrementalIndexingRepresentation = 0x40; + + // http://httpwg.org/specs/rfc7541.html#rfc.section.6.2.2 + // 0 1 2 3 4 5 6 7 + // +---+---+---+---+---+---+---+---+ + // | 0 | 0 | 0 | 0 | Index (4+) | + // +---+---+-----------------------+ + private const byte LiteralHeaderFieldWithoutIndexingMask = 0xf0; + private const byte LiteralHeaderFieldWithoutIndexingRepresentation = 0x00; + + // http://httpwg.org/specs/rfc7541.html#rfc.section.6.2.3 + // 0 1 2 3 4 5 6 7 + // +---+---+---+---+---+---+---+---+ + // | 0 | 0 | 0 | 1 | Index (4+) | + // +---+---+-----------------------+ + private const byte LiteralHeaderFieldNeverIndexedMask = 0xf0; + private const byte LiteralHeaderFieldNeverIndexedRepresentation = 0x10; + + // http://httpwg.org/specs/rfc7541.html#rfc.section.6.3 + // 0 1 2 3 4 5 6 7 + // +---+---+---+---+---+---+---+---+ + // | 0 | 0 | 1 | Max size (5+) | + // +---+---------------------------+ + private const byte DynamicTableSizeUpdateMask = 0xe0; + private const byte DynamicTableSizeUpdateRepresentation = 0x20; + + // http://httpwg.org/specs/rfc7541.html#rfc.section.5.2 + // 0 1 2 3 4 5 6 7 + // +---+---+---+---+---+---+---+---+ + // | H | String Length (7+) | + // +---+---------------------------+ private const byte HuffmanMask = 0x80; private const int IndexedHeaderFieldPrefix = 7; @@ -39,44 +79,67 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http2.HPack private const int DynamicTableSizeUpdatePrefix = 5; private const int StringLengthPrefix = 7; - private readonly DynamicTable _dynamicTable = new DynamicTable(4096); + private readonly int _maxDynamicTableSize; + private readonly DynamicTable _dynamicTable; private readonly IntegerDecoder _integerDecoder = new IntegerDecoder(); + private readonly byte[] _stringOctets = new byte[MaxStringOctets]; + private readonly byte[] _headerNameOctets = new byte[MaxStringOctets]; + private readonly byte[] _headerValueOctets = new byte[MaxStringOctets]; private State _state = State.Ready; - // TODO: add new HTTP/2 header size limit and allocate accordingly - private byte[] _stringOctets = new byte[Http2Frame.MinAllowedMaxFrameSize]; - private string _headerName = string.Empty; - private string _headerValue = string.Empty; - private int _stringLength; + private byte[] _headerName; private int _stringIndex; + private int _stringLength; + private int _headerNameLength; + private int _headerValueLength; private bool _index; private bool _huffman; - public void Decode(Span data, IHeaderDictionary headers) + public HPackDecoder(int maxDynamicTableSize) + : this(maxDynamicTableSize, new DynamicTable(maxDynamicTableSize)) + { + _maxDynamicTableSize = maxDynamicTableSize; + } + + // For testing. + internal HPackDecoder(int maxDynamicTableSize, DynamicTable dynamicTable) + { + _maxDynamicTableSize = maxDynamicTableSize; + _dynamicTable = dynamicTable; + } + + public void Decode(Span data, bool endHeaders, IHttpHeadersHandler handler) { for (var i = 0; i < data.Length; i++) { - OnByte(data[i], headers); + OnByte(data[i], handler); + } + + if (endHeaders && _state != State.Ready) + { + throw new HPackDecodingException(CoreStrings.HPackErrorIncompleteHeaderBlock); } } - public void OnByte(byte b, IHeaderDictionary headers) + public void OnByte(byte b, IHttpHeadersHandler handler) { switch (_state) { case State.Ready: - if ((b & IndexedHeaderFieldMask) == IndexedHeaderFieldMask) + if ((b & IndexedHeaderFieldMask) == IndexedHeaderFieldRepresentation) { - if (_integerDecoder.BeginDecode((byte)(b & ~IndexedHeaderFieldMask), IndexedHeaderFieldPrefix)) + var val = b & ~IndexedHeaderFieldMask; + + if (_integerDecoder.BeginDecode((byte)val, IndexedHeaderFieldPrefix)) { - OnIndexedHeaderField(_integerDecoder.Value, headers); + OnIndexedHeaderField(_integerDecoder.Value, handler); } else { _state = State.HeaderFieldIndex; } } - else if ((b & LiteralHeaderFieldWithIncrementalIndexingMask) == LiteralHeaderFieldWithIncrementalIndexingMask) + else if ((b & LiteralHeaderFieldWithIncrementalIndexingMask) == LiteralHeaderFieldWithIncrementalIndexingRepresentation) { _index = true; var val = b & ~LiteralHeaderFieldWithIncrementalIndexingMask; @@ -94,7 +157,7 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http2.HPack _state = State.HeaderNameIndex; } } - else if ((b & LiteralHeaderFieldWithoutIndexingMask) == LiteralHeaderFieldWithoutIndexingMask) + else if ((b & LiteralHeaderFieldWithoutIndexingMask) == LiteralHeaderFieldWithoutIndexingRepresentation) { _index = false; var val = b & ~LiteralHeaderFieldWithoutIndexingMask; @@ -112,7 +175,7 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http2.HPack _state = State.HeaderNameIndex; } } - else if ((b & LiteralHeaderFieldNeverIndexedMask) == LiteralHeaderFieldNeverIndexedMask) + else if ((b & LiteralHeaderFieldNeverIndexedMask) == LiteralHeaderFieldNeverIndexedRepresentation) { _index = false; var val = b & ~LiteralHeaderFieldNeverIndexedMask; @@ -130,7 +193,7 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http2.HPack _state = State.HeaderNameIndex; } } - else if ((b & DynamicTableSizeUpdateMask) == DynamicTableSizeUpdateMask) + else if ((b & DynamicTableSizeUpdateMask) == DynamicTableSizeUpdateRepresentation) { if (_integerDecoder.BeginDecode((byte)(b & ~DynamicTableSizeUpdateMask), DynamicTableSizeUpdatePrefix)) { @@ -139,19 +202,20 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http2.HPack } else { - _state = State.DynamicTableSize; + _state = State.DynamicTableSizeUpdate; } } else { - throw new InvalidOperationException(); + // Can't happen + throw new HPackDecodingException($"Byte value {b} does not encode a valid header field representation."); } break; case State.HeaderFieldIndex: if (_integerDecoder.Decode(b)) { - OnIndexedHeaderField(_integerDecoder.Value, headers); + OnIndexedHeaderField(_integerDecoder.Value, handler); } break; @@ -163,7 +227,7 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http2.HPack break; case State.HeaderNameLength: - _huffman = (b & HuffmanMask) == HuffmanMask; + _huffman = (b & HuffmanMask) != 0; if (_integerDecoder.BeginDecode((byte)(b & ~HuffmanMask), StringLengthPrefix)) { @@ -187,12 +251,12 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http2.HPack if (_stringIndex == _stringLength) { - _headerName = OnString(nextState: State.HeaderValueLength); + OnString(nextState: State.HeaderValueLength); } break; case State.HeaderValueLength: - _huffman = (b & HuffmanMask) == HuffmanMask; + _huffman = (b & HuffmanMask) != 0; if (_integerDecoder.BeginDecode((byte)(b & ~HuffmanMask), StringLengthPrefix)) { @@ -216,20 +280,29 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http2.HPack if (_stringIndex == _stringLength) { - _headerValue = OnString(nextState: State.Ready); - headers.Append(_headerName, _headerValue); + 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(_headerName, _headerValue); + _dynamicTable.Insert(headerNameSpan, headerValueSpan); } } break; - case State.DynamicTableSize: + case State.DynamicTableSizeUpdate: if (_integerDecoder.Decode(b)) { - // TODO: validate that it's less than what's defined via SETTINGS + if (_integerDecoder.Value > _maxDynamicTableSize) + { + throw new HPackDecodingException( + CoreStrings.FormatHPackErrorDynamicTableSizeUpdateTooLarge(_integerDecoder.Value, _maxDynamicTableSize)); + } + _dynamicTable.Resize(_integerDecoder.Value); _state = State.Ready; } @@ -237,14 +310,14 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http2.HPack break; default: // Can't happen - throw new InvalidOperationException(); + throw new HPackDecodingException("The HPACK decoder reached an invalid state."); } } - private void OnIndexedHeaderField(int index, IHeaderDictionary headers) + private void OnIndexedHeaderField(int index, IHttpHeadersHandler handler) { var header = GetHeader(index); - headers.Append(header.Name, header.Value); + handler.OnHeader(new Span(header.Name), new Span(header.Value)); _state = State.Ready; } @@ -252,26 +325,69 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http2.HPack { var header = GetHeader(index); _headerName = header.Name; + _headerNameLength = header.Name.Length; _state = State.HeaderValueLength; } private void OnStringLength(int length, State nextState) { + if (length > _stringOctets.Length) + { + throw new HPackDecodingException(CoreStrings.FormatHPackStringLengthTooLarge(length, _stringOctets.Length)); + } + _stringLength = length; _stringIndex = 0; _state = nextState; } - private string OnString(State nextState) + private void OnString(State nextState) { + int Decode(byte[] dst) + { + if (_huffman) + { + return Huffman.Decode(_stringOctets, 0, _stringLength, dst); + } + else + { + Buffer.BlockCopy(_stringOctets, 0, dst, 0, _stringLength); + return _stringLength; + } + } + + try + { + if (_state == State.HeaderName) + { + _headerName = _headerNameOctets; + _headerNameLength = Decode(_headerNameOctets); + } + else + { + _headerValueLength = Decode(_headerValueOctets); + } + } + catch (HuffmanDecodingException ex) + { + throw new HPackDecodingException(CoreStrings.HPackHuffmanError, ex); + } + _state = nextState; - return _huffman - ? Huffman.Decode(_stringOctets, 0, _stringLength) - : Encoding.ASCII.GetString(_stringOctets, 0, _stringLength); } - private HeaderField GetHeader(int index) => index <= StaticTable.Instance.Length - ? StaticTable.Instance[index - 1] - : _dynamicTable[index - StaticTable.Instance.Length - 1]; + private HeaderField GetHeader(int index) + { + try + { + return index <= StaticTable.Instance.Count + ? StaticTable.Instance[index - 1] + : _dynamicTable[index - StaticTable.Instance.Count - 1]; + } + catch (IndexOutOfRangeException ex) + { + throw new HPackDecodingException(CoreStrings.FormatHPackErrorIndexOutOfRange(index), ex); + } + } } } diff --git a/src/Kestrel.Core/Internal/Http2/HPack/HPackDecodingException.cs b/src/Kestrel.Core/Internal/Http2/HPack/HPackDecodingException.cs new file mode 100644 index 0000000000..7ae0ddddf5 --- /dev/null +++ b/src/Kestrel.Core/Internal/Http2/HPack/HPackDecodingException.cs @@ -0,0 +1,19 @@ +// 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; + +namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http2.HPack +{ + public class HPackDecodingException : Exception + { + public HPackDecodingException(string message) + : base(message) + { + } + public HPackDecodingException(string message, Exception innerException) + : base(message, innerException) + { + } + } +} diff --git a/src/Kestrel.Core/Internal/Http2/HPack/HeaderField.cs b/src/Kestrel.Core/Internal/Http2/HPack/HeaderField.cs index 9c3872cad2..73eb4d726e 100644 --- a/src/Kestrel.Core/Internal/Http2/HPack/HeaderField.cs +++ b/src/Kestrel.Core/Internal/Http2/HPack/HeaderField.cs @@ -1,17 +1,30 @@ // 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; + namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http2.HPack { public struct HeaderField { - public HeaderField(string name, string value) + // http://httpwg.org/specs/rfc7541.html#rfc.section.4.1 + public const int RfcOverhead = 32; + + public HeaderField(Span name, Span value) { - Name = name; - Value = value; + Name = new byte[name.Length]; + name.CopyTo(Name); + + Value = new byte[value.Length]; + value.CopyTo(Value); } - public string Name { get; } - public string Value { get; } + public byte[] Name { get; } + + public byte[] Value { get; } + + public int Length => GetLength(Name.Length, Value.Length); + + public static int GetLength(int nameLength, int valueLenth) => nameLength + valueLenth + 32; } } diff --git a/src/Kestrel.Core/Internal/Http2/HPack/Huffman.cs b/src/Kestrel.Core/Internal/Http2/HPack/Huffman.cs index 7c9e52f446..f0d489c952 100644 --- a/src/Kestrel.Core/Internal/Http2/HPack/Huffman.cs +++ b/src/Kestrel.Core/Internal/Http2/HPack/Huffman.cs @@ -2,8 +2,6 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; -using System.Collections.Generic; -using System.Text; namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http2.HPack { @@ -301,45 +299,108 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http2.HPack return _encodingTable[data]; } - public static string Decode(byte[] data, int offset, int count) + /// + /// Decodes a Huffman encoded string from a byte array. + /// + /// The source byte array containing the encoded data. + /// The offset in the byte array where the coded data starts. + /// The number of bytes to decode. + /// The destination byte array to store the decoded data. + /// The number of decoded symbols. + public static int Decode(byte[] src, int offset, int count, byte[] dst) { - var sb = new StringBuilder(); - var i = offset; + var j = 0; var lastDecodedBits = 0; while (i < count) { - var next = (uint)(data[i] << 24 + lastDecodedBits); - next |= (i + 1 < data.Length ? (uint)(data[i + 1] << 16 + lastDecodedBits) : 0); - next |= (i + 2 < data.Length ? (uint)(data[i + 2] << 8 + lastDecodedBits) : 0); - next |= (i + 3 < data.Length ? (uint)(data[i + 3] << lastDecodedBits) : 0); + var next = (uint)(src[i] << 24 + lastDecodedBits); + next |= (i + 1 < src.Length ? (uint)(src[i + 1] << 16 + lastDecodedBits) : 0); + next |= (i + 2 < src.Length ? (uint)(src[i + 2] << 8 + lastDecodedBits) : 0); + next |= (i + 3 < src.Length ? (uint)(src[i + 3] << lastDecodedBits) : 0); var ones = (uint)(int.MinValue >> (8 - lastDecodedBits - 1)); - if (i == count - 1 && (next & ones) == ones) + if (i == count - 1 && lastDecodedBits > 0 && (next & ones) == ones) { - // Padding + // The remaining 7 or less bits are all 1, which is padding. + // We specifically check that lastDecodedBits > 0 because padding + // longer than 7 bits should be treated as a decoding error. + // http://httpwg.org/specs/rfc7541.html#rfc.section.5.2 break; } - var ch = Decode(next, out var decodedBits); - sb.Append((char)ch); + // The longest possible symbol size is 30 bits. If we're at the last 4 bytes + // of the input, we need to make sure we pass the correct number of valid bits + // left, otherwise the trailing 0s in next may form a valid symbol. + var validBits = Math.Min(30, (8 - lastDecodedBits) + (count - i - 1) * 8); + var ch = Decode(next, validBits, out var decodedBits); + if (ch == -1) + { + // No valid symbol could be decoded with the bits in next + throw new HuffmanDecodingException(CoreStrings.HPackHuffmanErrorIncomplete); + } + else if (ch == 256) + { + // A Huffman-encoded string literal containing the EOS symbol MUST be treated as a decoding error. + // http://httpwg.org/specs/rfc7541.html#rfc.section.5.2 + throw new HuffmanDecodingException(CoreStrings.HPackHuffmanErrorEOS); + } + + if (j == dst.Length) + { + throw new HuffmanDecodingException(CoreStrings.HPackHuffmanErrorDestinationTooSmall); + } + + dst[j++] = (byte)ch; + + // If we crossed a byte boundary, advance i so we start at the next byte that's not fully decoded. lastDecodedBits += decodedBits; i += lastDecodedBits / 8; + + // Modulo 8 since we only care about how many bits were decoded in the last byte that we processed. lastDecodedBits %= 8; } - return sb.ToString(); + return j; } - public static int Decode(uint data, out int decodedBits) + /// + /// Decodes a single symbol from a 32-bit word. + /// + /// A 32-bit word containing a Huffman encoded symbol. + /// + /// The number of bits in that may contain an encoded symbol. + /// This is not the exact number of bits that encode the symbol. Instead, it prevents + /// decoding the lower bits of if they don't contain any + /// encoded data. + /// + /// The number of bits decoded from . + /// The decoded symbol. + public static int Decode(uint data, int validBits, out int decodedBits) { + // The code below implements the decoding logic for a canonical Huffman code. + // + // To decode a symbol, we scan the decoding table, which is sorted by ascending symbol bit length. + // For each bit length b, we determine the maximum b-bit encoded value, plus one (that is codeMax). + // This is done with the following logic: + // + // if we're at the first entry in the table, + // codeMax = the # of symbols encoded in b bits + // else, + // left-shift codeMax by the difference between b and the previous entry's bit length, + // then increment codeMax by the # of symbols encoded in b bits + // + // Next, we look at the value v encoded in the highest b bits of data. If v is less than codeMax, + // those bits correspond to a Huffman encoded symbol. We find the corresponding decoded + // symbol in the list of values associated with bit length b in the decoding table by indexing it + // with codeMax - v. + var codeMax = 0; - for (var i = 0; i < _decodingTable.Length; i++) + for (var i = 0; i < _decodingTable.Length && _decodingTable[i].codeLength <= validBits; i++) { var (codeLength, codes) = _decodingTable[i]; - var mask = int.MinValue >> (codeLength - 1); if (i > 0) { @@ -348,6 +409,7 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http2.HPack codeMax += codes.Length; + var mask = int.MinValue >> (codeLength - 1); var masked = (data & mask) >> (32 - codeLength); if (masked < codeMax) @@ -357,7 +419,8 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http2.HPack } } - throw new Exception(); + decodedBits = 0; + return -1; } } } diff --git a/src/Kestrel.Core/Internal/Http2/HPack/HuffmanDecodingException.cs b/src/Kestrel.Core/Internal/Http2/HPack/HuffmanDecodingException.cs new file mode 100644 index 0000000000..3bd992ab4b --- /dev/null +++ b/src/Kestrel.Core/Internal/Http2/HPack/HuffmanDecodingException.cs @@ -0,0 +1,15 @@ +// 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; + +namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http2.HPack +{ + public class HuffmanDecodingException : Exception + { + public HuffmanDecodingException(string message) + : base(message) + { + } + } +} diff --git a/src/Kestrel.Core/Internal/Http2/HPack/IntegerDecoder.cs b/src/Kestrel.Core/Internal/Http2/HPack/IntegerDecoder.cs index c3bac3eb09..5bc051a9a3 100644 --- a/src/Kestrel.Core/Internal/Http2/HPack/IntegerDecoder.cs +++ b/src/Kestrel.Core/Internal/Http2/HPack/IntegerDecoder.cs @@ -1,9 +1,6 @@ // 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.Collections.Generic; - namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http2.HPack { public class IntegerDecoder diff --git a/src/Kestrel.Core/Internal/Http2/HPack/StaticTable.cs b/src/Kestrel.Core/Internal/Http2/HPack/StaticTable.cs index 06612cc778..c28a78ff8d 100644 --- a/src/Kestrel.Core/Internal/Http2/HPack/StaticTable.cs +++ b/src/Kestrel.Core/Internal/Http2/HPack/StaticTable.cs @@ -1,8 +1,8 @@ // 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.Collections.Generic; +using System.Text; namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http2.HPack { @@ -27,7 +27,7 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http2.HPack public static StaticTable Instance => _instance; - public int Length => _staticTable.Length; + public int Count => _staticTable.Length; public HeaderField this[int index] => _staticTable[index]; @@ -35,67 +35,70 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http2.HPack private readonly HeaderField[] _staticTable = new HeaderField[] { - new HeaderField(":authority", ""), - new HeaderField(":method", "GET"), - new HeaderField(":method", "POST"), - new HeaderField(":path", "/"), - new HeaderField(":path", "/index.html"), - new HeaderField(":scheme", "http"), - new HeaderField(":scheme", "https"), - new HeaderField(":status", "200"), - new HeaderField(":status", "204"), - new HeaderField(":status", "206"), - new HeaderField(":status", "304"), - new HeaderField(":status", "400"), - new HeaderField(":status", "404"), - new HeaderField(":status", "500"), - new HeaderField("accept-charset", ""), - new HeaderField("accept-encoding", "gzip, deflate"), - new HeaderField("accept-language", ""), - new HeaderField("accept-ranges", ""), - new HeaderField("accept", ""), - new HeaderField("access-control-allow-origin", ""), - new HeaderField("age", ""), - new HeaderField("allow", ""), - new HeaderField("authorization", ""), - new HeaderField("cache-control", ""), - new HeaderField("content-disposition", ""), - new HeaderField("content-encoding", ""), - new HeaderField("content-language", ""), - new HeaderField("content-length", ""), - new HeaderField("content-location", ""), - new HeaderField("content-range", ""), - new HeaderField("content-type", ""), - new HeaderField("cookie", ""), - new HeaderField("date", ""), - new HeaderField("etag", ""), - new HeaderField("expect", ""), - new HeaderField("expires", ""), - new HeaderField("from", ""), - new HeaderField("host", ""), - new HeaderField("if-match", ""), - new HeaderField("if-modified-since", ""), - new HeaderField("if-none-match", ""), - new HeaderField("if-range", ""), - new HeaderField("if-unmodifiedsince", ""), - new HeaderField("last-modified", ""), - new HeaderField("link", ""), - new HeaderField("location", ""), - new HeaderField("max-forwards", ""), - new HeaderField("proxy-authenticate", ""), - new HeaderField("proxy-authorization", ""), - new HeaderField("range", ""), - new HeaderField("referer", ""), - new HeaderField("refresh", ""), - new HeaderField("retry-after", ""), - new HeaderField("server", ""), - new HeaderField("set-cookie", ""), - new HeaderField("strict-transport-security", ""), - new HeaderField("transfer-encoding", ""), - new HeaderField("user-agent", ""), - new HeaderField("vary", ""), - new HeaderField("via", ""), - new HeaderField("www-authenticate", "") + CreateHeaderField(":authority", ""), + CreateHeaderField(":method", "GET"), + CreateHeaderField(":method", "POST"), + CreateHeaderField(":path", "/"), + CreateHeaderField(":path", "/index.html"), + CreateHeaderField(":scheme", "http"), + CreateHeaderField(":scheme", "https"), + CreateHeaderField(":status", "200"), + CreateHeaderField(":status", "204"), + CreateHeaderField(":status", "206"), + CreateHeaderField(":status", "304"), + CreateHeaderField(":status", "400"), + CreateHeaderField(":status", "404"), + CreateHeaderField(":status", "500"), + CreateHeaderField("accept-charset", ""), + CreateHeaderField("accept-encoding", "gzip, deflate"), + CreateHeaderField("accept-language", ""), + CreateHeaderField("accept-ranges", ""), + CreateHeaderField("accept", ""), + CreateHeaderField("access-control-allow-origin", ""), + CreateHeaderField("age", ""), + CreateHeaderField("allow", ""), + CreateHeaderField("authorization", ""), + CreateHeaderField("cache-control", ""), + CreateHeaderField("content-disposition", ""), + CreateHeaderField("content-encoding", ""), + CreateHeaderField("content-language", ""), + CreateHeaderField("content-length", ""), + CreateHeaderField("content-location", ""), + CreateHeaderField("content-range", ""), + CreateHeaderField("content-type", ""), + CreateHeaderField("cookie", ""), + CreateHeaderField("date", ""), + CreateHeaderField("etag", ""), + CreateHeaderField("expect", ""), + CreateHeaderField("expires", ""), + CreateHeaderField("from", ""), + CreateHeaderField("host", ""), + CreateHeaderField("if-match", ""), + CreateHeaderField("if-modified-since", ""), + CreateHeaderField("if-none-match", ""), + CreateHeaderField("if-range", ""), + CreateHeaderField("if-unmodifiedsince", ""), + CreateHeaderField("last-modified", ""), + CreateHeaderField("link", ""), + CreateHeaderField("location", ""), + CreateHeaderField("max-forwards", ""), + CreateHeaderField("proxy-authenticate", ""), + CreateHeaderField("proxy-authorization", ""), + CreateHeaderField("range", ""), + CreateHeaderField("referer", ""), + CreateHeaderField("refresh", ""), + CreateHeaderField("retry-after", ""), + CreateHeaderField("server", ""), + CreateHeaderField("set-cookie", ""), + CreateHeaderField("strict-transport-security", ""), + CreateHeaderField("transfer-encoding", ""), + CreateHeaderField("user-agent", ""), + CreateHeaderField("vary", ""), + CreateHeaderField("via", ""), + CreateHeaderField("www-authenticate", "") }; + + private static HeaderField CreateHeaderField(string name, string value) + => new HeaderField(Encoding.ASCII.GetBytes(name), Encoding.ASCII.GetBytes(value)); } } diff --git a/src/Kestrel.Core/Internal/Http2/Http2Connection.cs b/src/Kestrel.Core/Internal/Http2/Http2Connection.cs index 7baa89c2c7..b4e54e7b01 100644 --- a/src/Kestrel.Core/Internal/Http2/Http2Connection.cs +++ b/src/Kestrel.Core/Internal/Http2/Http2Connection.cs @@ -16,7 +16,7 @@ using Microsoft.Extensions.Logging; namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http2 { - public class Http2Connection : ITimeoutControl, IHttp2StreamLifetimeHandler + public class Http2Connection : ITimeoutControl, IHttp2StreamLifetimeHandler, IHttpHeadersHandler { public static byte[] ClientPreface { get; } = Encoding.ASCII.GetBytes("PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n"); @@ -40,7 +40,7 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http2 { _context = context; _frameWriter = new Http2FrameWriter(context.Transport.Output, context.Application.Input); - _hpackDecoder = new HPackDecoder(); + _hpackDecoder = new HPackDecoder((int)_serverSettings.HeaderTableSize); } public string ConnectionId => _context.ConnectionId; @@ -141,6 +141,12 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http2 error = ex; errorCode = ex.ErrorCode; } + catch (HPackDecodingException ex) + { + // TODO: log + error = ex; + errorCode = Http2ErrorCode.COMPRESSION_ERROR; + } catch (Exception ex) { // TODO: log @@ -350,9 +356,10 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http2 _streams[_incomingFrame.StreamId] = _currentHeadersStream; - _hpackDecoder.Decode(_incomingFrame.HeadersPayload, _currentHeadersStream.RequestHeaders); + var endHeaders = (_incomingFrame.HeadersFlags & Http2HeadersFrameFlags.END_HEADERS) == Http2HeadersFrameFlags.END_HEADERS; + _hpackDecoder.Decode(_incomingFrame.HeadersPayload, endHeaders, handler: this); - if ((_incomingFrame.HeadersFlags & Http2HeadersFrameFlags.END_HEADERS) == Http2HeadersFrameFlags.END_HEADERS) + if (endHeaders) { _highestOpenedStreamId = _incomingFrame.StreamId; _ = _currentHeadersStream.ProcessRequestsAsync(); @@ -525,9 +532,10 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http2 throw new Http2ConnectionErrorException(Http2ErrorCode.PROTOCOL_ERROR); } - _hpackDecoder.Decode(_incomingFrame.HeadersPayload, _currentHeadersStream.RequestHeaders); + var endHeaders = (_incomingFrame.ContinuationFlags & Http2ContinuationFrameFlags.END_HEADERS) == Http2ContinuationFrameFlags.END_HEADERS; + _hpackDecoder.Decode(_incomingFrame.HeadersPayload, endHeaders, handler: this); - if ((_incomingFrame.ContinuationFlags & Http2ContinuationFrameFlags.END_HEADERS) == Http2ContinuationFrameFlags.END_HEADERS) + if (endHeaders) { _highestOpenedStreamId = _currentHeadersStream.StreamId; _ = _currentHeadersStream.ProcessRequestsAsync(); @@ -571,6 +579,11 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http2 _streams.TryRemove(streamId, out _); } + public void OnHeader(Span name, Span value) + { + _currentHeadersStream.OnHeader(name, value); + } + void ITimeoutControl.SetTimeout(long ticks, TimeoutAction timeoutAction) { } diff --git a/src/Kestrel.Core/Properties/CoreStrings.Designer.cs b/src/Kestrel.Core/Properties/CoreStrings.Designer.cs index 2fa3c085eb..7084b94b0c 100644 --- a/src/Kestrel.Core/Properties/CoreStrings.Designer.cs +++ b/src/Kestrel.Core/Properties/CoreStrings.Designer.cs @@ -1144,6 +1144,118 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core internal static string FormatEndPointHttp2NotNegotiated() => GetString("EndPointHttp2NotNegotiated"); + /// + /// A dynamic table size of {size} octets is greater than the configured maximum size of {maxSize} octets. + /// + internal static string HPackErrorDynamicTableSizeUpdateTooLarge + { + get => GetString("HPackErrorDynamicTableSizeUpdateTooLarge"); + } + + /// + /// A dynamic table size of {size} octets is greater than the configured maximum size of {maxSize} octets. + /// + internal static string FormatHPackErrorDynamicTableSizeUpdateTooLarge(object size, object maxSize) + => string.Format(CultureInfo.CurrentCulture, GetString("HPackErrorDynamicTableSizeUpdateTooLarge", "size", "maxSize"), size, maxSize); + + /// + /// Index {index} is outside the bounds of the header field table. + /// + internal static string HPackErrorIndexOutOfRange + { + get => GetString("HPackErrorIndexOutOfRange"); + } + + /// + /// Index {index} is outside the bounds of the header field table. + /// + internal static string FormatHPackErrorIndexOutOfRange(object index) + => string.Format(CultureInfo.CurrentCulture, GetString("HPackErrorIndexOutOfRange", "index"), index); + + /// + /// Input data could not be fully decoded. + /// + internal static string HPackHuffmanErrorIncomplete + { + get => GetString("HPackHuffmanErrorIncomplete"); + } + + /// + /// Input data could not be fully decoded. + /// + internal static string FormatHPackHuffmanErrorIncomplete() + => GetString("HPackHuffmanErrorIncomplete"); + + /// + /// Input data contains the EOS symbol. + /// + internal static string HPackHuffmanErrorEOS + { + get => GetString("HPackHuffmanErrorEOS"); + } + + /// + /// Input data contains the EOS symbol. + /// + internal static string FormatHPackHuffmanErrorEOS() + => GetString("HPackHuffmanErrorEOS"); + + /// + /// The destination buffer is not large enough to store the decoded data. + /// + internal static string HPackHuffmanErrorDestinationTooSmall + { + get => GetString("HPackHuffmanErrorDestinationTooSmall"); + } + + /// + /// The destination buffer is not large enough to store the decoded data. + /// + internal static string FormatHPackHuffmanErrorDestinationTooSmall() + => GetString("HPackHuffmanErrorDestinationTooSmall"); + + /// + /// Huffman decoding error. + /// + internal static string HPackHuffmanError + { + get => GetString("HPackHuffmanError"); + } + + /// + /// Huffman decoding error. + /// + internal static string FormatHPackHuffmanError() + => GetString("HPackHuffmanError"); + + /// + /// Decoded string length of {length} octets is greater than the configured maximum length of {maxStringLength} octets. + /// + internal static string HPackStringLengthTooLarge + { + get => GetString("HPackStringLengthTooLarge"); + } + + /// + /// Decoded string length of {length} octets is greater than the configured maximum length of {maxStringLength} octets. + /// + internal static string FormatHPackStringLengthTooLarge(object length, object maxStringLength) + => string.Format(CultureInfo.CurrentCulture, GetString("HPackStringLengthTooLarge", "length", "maxStringLength"), length, maxStringLength); + + /// + /// The header block was incomplete and could not be fully decoded. + /// + internal static string HPackErrorIncompleteHeaderBlock + { + get => GetString("HPackErrorIncompleteHeaderBlock"); + } + + /// + /// The header block was incomplete and could not be fully decoded. + /// + internal static string FormatHPackErrorIncompleteHeaderBlock() + => GetString("HPackErrorIncompleteHeaderBlock"); + private static string GetString(string name, params string[] formatterNames) { var value = _resourceManager.GetString(name); diff --git a/test/Kestrel.Core.Tests/DynamicTableTests.cs b/test/Kestrel.Core.Tests/DynamicTableTests.cs new file mode 100644 index 0000000000..0943272c72 --- /dev/null +++ b/test/Kestrel.Core.Tests/DynamicTableTests.cs @@ -0,0 +1,158 @@ +// 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.Linq; +using System.Text; +using Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http2.HPack; +using Xunit; + +namespace Microsoft.AspNetCore.Server.Kestrel.Core.Tests +{ + public class DynamicTableTests + { + private readonly HeaderField _header1 = new HeaderField(Encoding.ASCII.GetBytes("header-1"), Encoding.ASCII.GetBytes("value1")); + private readonly HeaderField _header2 = new HeaderField(Encoding.ASCII.GetBytes("header-02"), Encoding.ASCII.GetBytes("value_2")); + + [Fact] + public void DynamicTableIsInitiallyEmpty() + { + var dynamicTable = new DynamicTable(4096); + Assert.Equal(0, dynamicTable.Count); + Assert.Equal(0, dynamicTable.Size); + Assert.Equal(4096, dynamicTable.MaxSize); + } + + [Fact] + public void CountIsNumberOfEntriesInDynamicTable() + { + var dynamicTable = new DynamicTable(4096); + + dynamicTable.Insert(_header1.Name, _header1.Value); + Assert.Equal(1, dynamicTable.Count); + + dynamicTable.Insert(_header2.Name, _header2.Value); + Assert.Equal(2, dynamicTable.Count); + } + + [Fact] + public void SizeIsCurrentDynamicTableSize() + { + var dynamicTable = new DynamicTable(4096); + Assert.Equal(0, dynamicTable.Size); + + dynamicTable.Insert(_header1.Name, _header1.Value); + Assert.Equal(_header1.Length, dynamicTable.Size); + + dynamicTable.Insert(_header2.Name, _header2.Value); + Assert.Equal(_header1.Length + _header2.Length, dynamicTable.Size); + } + + [Fact] + public void FirstEntryIsMostRecentEntry() + { + var dynamicTable = new DynamicTable(4096); + dynamicTable.Insert(_header1.Name, _header1.Value); + dynamicTable.Insert(_header2.Name, _header2.Value); + + VerifyTableEntries(dynamicTable, _header2, _header1); + } + + [Fact] + public void ThrowsIndexOutOfRangeException() + { + var dynamicTable = new DynamicTable(4096); + Assert.Throws(() => dynamicTable[0]); + + dynamicTable.Insert(_header1.Name, _header1.Value); + Assert.Throws(() => dynamicTable[1]); + } + + [Fact] + public void NoOpWhenInsertingEntryLargerThanMaxSize() + { + var dynamicTable = new DynamicTable(_header1.Length - 1); + dynamicTable.Insert(_header1.Name, _header1.Value); + + Assert.Equal(0, dynamicTable.Count); + Assert.Equal(0, dynamicTable.Size); + } + + [Fact] + public void NoOpWhenInsertingEntryLargerThanRemainingSpace() + { + var dynamicTable = new DynamicTable(_header1.Length); + dynamicTable.Insert(_header1.Name, _header1.Value); + + VerifyTableEntries(dynamicTable, _header1); + + dynamicTable.Insert(_header2.Name, _header2.Value); + + Assert.Equal(0, dynamicTable.Count); + Assert.Equal(0, dynamicTable.Size); + } + + [Fact] + public void ResizingEvictsOldestEntries() + { + var dynamicTable = new DynamicTable(4096); + dynamicTable.Insert(_header1.Name, _header1.Value); + dynamicTable.Insert(_header2.Name, _header2.Value); + + VerifyTableEntries(dynamicTable, _header2, _header1); + + dynamicTable.Resize(_header2.Length); + + VerifyTableEntries(dynamicTable, _header2); + } + + [Fact] + public void ResizingToZeroEvictsAllEntries() + { + var dynamicTable = new DynamicTable(4096); + dynamicTable.Insert(_header1.Name, _header1.Value); + dynamicTable.Insert(_header2.Name, _header2.Value); + + dynamicTable.Resize(0); + + Assert.Equal(0, dynamicTable.Count); + Assert.Equal(0, dynamicTable.Size); + } + + [Fact] + public void CanBeResizedToLargerMaxSize() + { + var dynamicTable = new DynamicTable(_header1.Length); + dynamicTable.Insert(_header1.Name, _header1.Value); + dynamicTable.Insert(_header2.Name, _header2.Value); + + // _header2 is larger than _header1, so an attempt at inserting it + // would first clear the table then return without actually inserting it, + // given it is larger than the current max size. + Assert.Equal(0, dynamicTable.Count); + Assert.Equal(0, dynamicTable.Size); + + dynamicTable.Resize(dynamicTable.MaxSize + _header2.Length); + dynamicTable.Insert(_header2.Name, _header2.Value); + + VerifyTableEntries(dynamicTable, _header2); + } + + private void VerifyTableEntries(DynamicTable dynamicTable, params HeaderField[] entries) + { + Assert.Equal(entries.Length, dynamicTable.Count); + Assert.Equal(entries.Sum(e => e.Length), dynamicTable.Size); + + for (var i = 0; i < entries.Length; i++) + { + var headerField = dynamicTable[i]; + + Assert.NotSame(entries[i].Name, headerField.Name); + Assert.Equal(entries[i].Name, headerField.Name); + + Assert.NotSame(entries[i].Value, headerField.Value); + Assert.Equal(entries[i].Value, headerField.Value); + } + } + } +} diff --git a/test/Kestrel.Core.Tests/HPackDecoderTests.cs b/test/Kestrel.Core.Tests/HPackDecoderTests.cs new file mode 100644 index 0000000000..a20c0be120 --- /dev/null +++ b/test/Kestrel.Core.Tests/HPackDecoderTests.cs @@ -0,0 +1,560 @@ +// 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.Collections.Generic; +using System.Linq; +using System.Text; +using Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http; +using Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http2.HPack; +using Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Infrastructure; +using Xunit; + +namespace Microsoft.AspNetCore.Server.Kestrel.Core.Tests +{ + public class HPackDecoderTests : IHttpHeadersHandler + { + private const int DynamicTableInitialMaxSize = 4096; + + // Indexed Header Field Representation - Static Table - Index 2 (:method: GET) + private static readonly byte[] _indexedHeaderStatic = new byte[] { 0x82 }; + + // Indexed Header Field Representation - Dynamic Table - Index 62 (first index in dynamic table) + private static readonly byte[] _indexedHeaderDynamic = new byte[] { 0xbe }; + + // Literal Header Field with Incremental Indexing Representation - New Name + private static readonly byte[] _literalHeaderFieldWithIndexingNewName = new byte[] { 0x40 }; + + // Literal Header Field with Incremental Indexing Representation - Indexed Name - Index 58 (user-agent) + private static readonly byte[] _literalHeaderFieldWithIndexingIndexedName = new byte[] { 0x7a }; + + // Literal Header Field without Indexing Representation - New Name + private static readonly byte[] _literalHeaderFieldWithoutIndexingNewName = new byte[] { 0x00 }; + + // Literal Header Field without Indexing Representation - Indexed Name - Index 58 (user-agent) + private static readonly byte[] _literalHeaderFieldWithoutIndexingIndexedName = new byte[] { 0x0f, 0x2b }; + + // Literal Header Field Never Indexed Representation - New Name + private static readonly byte[] _literalHeaderFieldNeverIndexedNewName = new byte[] { 0x10 }; + + // Literal Header Field Never Indexed Representation - Indexed Name - Index 58 (user-agent) + private static readonly byte[] _literalHeaderFieldNeverIndexedIndexedName = new byte[] { 0x1f, 0x2b }; + + private const string _userAgentString = "user-agent"; + + private static readonly byte[] _userAgentBytes = Encoding.ASCII.GetBytes(_userAgentString); + + private const string _headerNameString = "new-header"; + + private static readonly byte[] _headerNameBytes = Encoding.ASCII.GetBytes(_headerNameString); + + // n e w - h e a d e r * + // 10101000 10111110 00010110 10011100 10100011 10010000 10110110 01111111 + private static readonly byte[] _headerNameHuffmanBytes = new byte[] { 0xa8, 0xbe, 0x16, 0x9c, 0xa3, 0x90, 0xb6, 0x7f }; + + private const string _headerValueString = "value"; + + private static readonly byte[] _headerValueBytes = Encoding.ASCII.GetBytes(_headerValueString); + + // v a l u e * + // 11101110 00111010 00101101 00101111 + private static readonly byte[] _headerValueHuffmanBytes = new byte [] { 0xee, 0x3a, 0x2d, 0x2f }; + + private static readonly byte[] _headerName = new byte[] { (byte)_headerNameBytes.Length } + .Concat(_headerNameBytes) + .ToArray(); + + private static readonly byte[] _headerNameHuffman = new byte[] { (byte)(0x80 | _headerNameHuffmanBytes.Length) } + .Concat(_headerNameHuffmanBytes) + .ToArray(); + + private static readonly byte[] _headerValue = new byte[] { (byte)_headerValueBytes.Length } + .Concat(_headerValueBytes) + .ToArray(); + + private static readonly byte[] _headerValueHuffman = new byte[] { (byte)(0x80 | _headerValueHuffmanBytes.Length) } + .Concat(_headerValueHuffmanBytes) + .ToArray(); + + // & * + // 11111000 11111111 + private static readonly byte[] _huffmanLongPadding = new byte[] { 0x82, 0xf8, 0xff }; + + // EOS * + // 11111111 11111111 11111111 11111111 + private static readonly byte[] _huffmanEos = new byte[] { 0x84, 0xff, 0xff, 0xff, 0xff }; + + private readonly DynamicTable _dynamicTable; + private readonly HPackDecoder _decoder; + + private readonly Dictionary _decodedHeaders = new Dictionary(); + + public HPackDecoderTests() + { + _dynamicTable = new DynamicTable(DynamicTableInitialMaxSize); + _decoder = new HPackDecoder(DynamicTableInitialMaxSize, _dynamicTable); + } + + void IHttpHeadersHandler.OnHeader(Span name, Span value) + { + _decodedHeaders[name.GetAsciiStringNonNullCharacters()] = value.GetAsciiStringNonNullCharacters(); + } + + [Fact] + public void DecodesIndexedHeaderField_StaticTable() + { + _decoder.Decode(_indexedHeaderStatic, endHeaders: true, handler: this); + Assert.Equal("GET", _decodedHeaders[":method"]); + } + + [Fact] + public void DecodesIndexedHeaderField_DynamicTable() + { + // Add the header to the dynamic table + _dynamicTable.Insert(_headerNameBytes, _headerValueBytes); + + // Index it + _decoder.Decode(_indexedHeaderDynamic, endHeaders: true, handler: this); + Assert.Equal(_headerValueString, _decodedHeaders[_headerNameString]); + } + + [Fact] + public void DecodesIndexedHeaderField_OutOfRange_Error() + { + var exception = Assert.Throws(() => _decoder.Decode(_indexedHeaderDynamic, endHeaders: true, handler: this)); + Assert.Equal(CoreStrings.FormatHPackErrorIndexOutOfRange(62), exception.Message); + Assert.Empty(_decodedHeaders); + } + + [Fact] + public void DecodesLiteralHeaderFieldWithIncrementalIndexing_NewName() + { + var encoded = _literalHeaderFieldWithIndexingNewName + .Concat(_headerName) + .Concat(_headerValue) + .ToArray(); + + TestDecodeWithIndexing(encoded, _headerNameString, _headerValueString); + } + + [Fact] + public void DecodesLiteralHeaderFieldWithIncrementalIndexing_NewName_HuffmanEncodedName() + { + var encoded = _literalHeaderFieldWithIndexingNewName + .Concat(_headerNameHuffman) + .Concat(_headerValue) + .ToArray(); + + TestDecodeWithIndexing(encoded, _headerNameString, _headerValueString); + } + + [Fact] + public void DecodesLiteralHeaderFieldWithIncrementalIndexing_NewName_HuffmanEncodedValue() + { + var encoded = _literalHeaderFieldWithIndexingNewName + .Concat(_headerName) + .Concat(_headerValueHuffman) + .ToArray(); + + TestDecodeWithIndexing(encoded, _headerNameString, _headerValueString); + } + + [Fact] + public void DecodesLiteralHeaderFieldWithIncrementalIndexing_NewName_HuffmanEncodedNameAndValue() + { + var encoded = _literalHeaderFieldWithIndexingNewName + .Concat(_headerNameHuffman) + .Concat(_headerValueHuffman) + .ToArray(); + + TestDecodeWithIndexing(encoded, _headerNameString, _headerValueString); + } + + [Fact] + public void DecodesLiteralHeaderFieldWithIncrementalIndexing_IndexedName() + { + var encoded = _literalHeaderFieldWithIndexingIndexedName + .Concat(_headerValue) + .ToArray(); + + TestDecodeWithIndexing(encoded, _userAgentString, _headerValueString); + } + + [Fact] + public void DecodesLiteralHeaderFieldWithIncrementalIndexing_IndexedName_HuffmanEncodedValue() + { + var encoded = _literalHeaderFieldWithIndexingIndexedName + .Concat(_headerValueHuffman) + .ToArray(); + + TestDecodeWithIndexing(encoded, _userAgentString, _headerValueString); + } + + [Fact] + public void DecodesLiteralHeaderFieldWithIncrementalIndexing_IndexedName_OutOfRange_Error() + { + // 01 (Literal Header Field without Indexing Representation) + // 11 1110 (Indexed Name - Index 62 encoded with 6-bit prefix - see http://httpwg.org/specs/rfc7541.html#integer.representation) + // Index 62 is the first entry in the dynamic table. If there's nothing there, the decoder should throw. + + var exception = Assert.Throws(() => _decoder.Decode(new byte[] { 0x7e }, endHeaders: true, handler: this)); + Assert.Equal(CoreStrings.FormatHPackErrorIndexOutOfRange(62), exception.Message); + Assert.Empty(_decodedHeaders); + } + + [Fact] + public void DecodesLiteralHeaderFieldWithoutIndexing_NewName() + { + var encoded = _literalHeaderFieldWithoutIndexingNewName + .Concat(_headerName) + .Concat(_headerValue) + .ToArray(); + + TestDecodeWithoutIndexing(encoded, _headerNameString, _headerValueString); + } + + [Fact] + public void DecodesLiteralHeaderFieldWithoutIndexing_NewName_HuffmanEncodedName() + { + var encoded = _literalHeaderFieldWithoutIndexingNewName + .Concat(_headerNameHuffman) + .Concat(_headerValue) + .ToArray(); + + TestDecodeWithoutIndexing(encoded, _headerNameString, _headerValueString); + } + + [Fact] + public void DecodesLiteralHeaderFieldWithoutIndexing_NewName_HuffmanEncodedValue() + { + var encoded = _literalHeaderFieldWithoutIndexingNewName + .Concat(_headerName) + .Concat(_headerValueHuffman) + .ToArray(); + + TestDecodeWithoutIndexing(encoded, _headerNameString, _headerValueString); + } + + [Fact] + public void DecodesLiteralHeaderFieldWithoutIndexing_NewName_HuffmanEncodedNameAndValue() + { + var encoded = _literalHeaderFieldWithoutIndexingNewName + .Concat(_headerNameHuffman) + .Concat(_headerValueHuffman) + .ToArray(); + + TestDecodeWithoutIndexing(encoded, _headerNameString, _headerValueString); + } + + [Fact] + public void DecodesLiteralHeaderFieldWithoutIndexing_IndexedName() + { + var encoded = _literalHeaderFieldWithoutIndexingIndexedName + .Concat(_headerValue) + .ToArray(); + + TestDecodeWithoutIndexing(encoded, _userAgentString, _headerValueString); + } + + [Fact] + public void DecodesLiteralHeaderFieldWithoutIndexing_IndexedName_HuffmanEncodedValue() + { + var encoded = _literalHeaderFieldWithoutIndexingIndexedName + .Concat(_headerValueHuffman) + .ToArray(); + + TestDecodeWithoutIndexing(encoded, _userAgentString, _headerValueString); + } + + [Fact] + public void DecodesLiteralHeaderFieldWithoutIndexing_IndexedName_OutOfRange_Error() + { + // 0000 (Literal Header Field without Indexing Representation) + // 1111 0010 1111 (Indexed Name - Index 62 encoded with 4-bit prefix - see http://httpwg.org/specs/rfc7541.html#integer.representation) + // Index 62 is the first entry in the dynamic table. If there's nothing there, the decoder should throw. + + var exception = Assert.Throws(() => _decoder.Decode(new byte[] { 0x0f, 0x2f }, endHeaders: true, handler: this)); + Assert.Equal(CoreStrings.FormatHPackErrorIndexOutOfRange(62), exception.Message); + Assert.Empty(_decodedHeaders); + } + + [Fact] + public void DecodesLiteralHeaderFieldNeverIndexed_NewName() + { + var encoded = _literalHeaderFieldNeverIndexedNewName + .Concat(_headerName) + .Concat(_headerValue) + .ToArray(); + + TestDecodeWithoutIndexing(encoded, _headerNameString, _headerValueString); + } + + [Fact] + public void DecodesLiteralHeaderFieldNeverIndexed_NewName_HuffmanEncodedName() + { + var encoded = _literalHeaderFieldNeverIndexedNewName + .Concat(_headerNameHuffman) + .Concat(_headerValue) + .ToArray(); + + TestDecodeWithoutIndexing(encoded, _headerNameString, _headerValueString); + } + + [Fact] + public void DecodesLiteralHeaderFieldNeverIndexed_NewName_HuffmanEncodedValue() + { + var encoded = _literalHeaderFieldNeverIndexedNewName + .Concat(_headerName) + .Concat(_headerValueHuffman) + .ToArray(); + + TestDecodeWithoutIndexing(encoded, _headerNameString, _headerValueString); + } + + [Fact] + public void DecodesLiteralHeaderFieldNeverIndexed_NewName_HuffmanEncodedNameAndValue() + { + var encoded = _literalHeaderFieldNeverIndexedNewName + .Concat(_headerNameHuffman) + .Concat(_headerValueHuffman) + .ToArray(); + + TestDecodeWithoutIndexing(encoded, _headerNameString, _headerValueString); + } + + [Fact] + public void DecodesLiteralHeaderFieldNeverIndexed_IndexedName() + { + // 0001 (Literal Header Field Never Indexed Representation) + // 1111 0010 1011 (Indexed Name - Index 58 encoded with 4-bit prefix - see http://httpwg.org/specs/rfc7541.html#integer.representation) + // Concatenated with value bytes + var encoded = _literalHeaderFieldNeverIndexedIndexedName + .Concat(_headerValue) + .ToArray(); + + TestDecodeWithoutIndexing(encoded, _userAgentString, _headerValueString); + } + + [Fact] + public void DecodesLiteralHeaderFieldNeverIndexed_IndexedName_HuffmanEncodedValue() + { + // 0001 (Literal Header Field Never Indexed Representation) + // 1111 0010 1011 (Indexed Name - Index 58 encoded with 4-bit prefix - see http://httpwg.org/specs/rfc7541.html#integer.representation) + // Concatenated with Huffman encoded value bytes + var encoded = _literalHeaderFieldNeverIndexedIndexedName + .Concat(_headerValueHuffman) + .ToArray(); + + TestDecodeWithoutIndexing(encoded, _userAgentString, _headerValueString); + } + + [Fact] + public void DecodesLiteralHeaderFieldNeverIndexed_IndexedName_OutOfRange_Error() + { + // 0001 (Literal Header Field Never Indexed Representation) + // 1111 0010 1111 (Indexed Name - Index 62 encoded with 4-bit prefix - see http://httpwg.org/specs/rfc7541.html#integer.representation) + // Index 62 is the first entry in the dynamic table. If there's nothing there, the decoder should throw. + + var exception = Assert.Throws(() => _decoder.Decode(new byte[] { 0x1f, 0x2f }, endHeaders: true, handler: this)); + Assert.Equal(CoreStrings.FormatHPackErrorIndexOutOfRange(62), exception.Message); + Assert.Empty(_decodedHeaders); + } + + [Fact] + public void DecodesDynamicTableSizeUpdate() + { + // 001 (Dynamic Table Size Update) + // 11110 (30 encoded with 5-bit prefix - see http://httpwg.org/specs/rfc7541.html#integer.representation) + + Assert.Equal(DynamicTableInitialMaxSize, _dynamicTable.MaxSize); + + _decoder.Decode(new byte[] { 0x3e }, endHeaders: true, handler: this); + + Assert.Equal(30, _dynamicTable.MaxSize); + Assert.Empty(_decodedHeaders); + } + + [Fact] + public void DecodesDynamicTableSizeUpdate_GreaterThanLimit_Error() + { + // 001 (Dynamic Table Size Update) + // 11111 11100010 00011111 (4097 encoded with 5-bit prefix - see http://httpwg.org/specs/rfc7541.html#integer.representation) + + Assert.Equal(DynamicTableInitialMaxSize, _dynamicTable.MaxSize); + + var exception = Assert.Throws(() => _decoder.Decode(new byte[] { 0x3f, 0xe2, 0x1f }, endHeaders: true, handler: this)); + Assert.Equal(CoreStrings.FormatHPackErrorDynamicTableSizeUpdateTooLarge(4097, DynamicTableInitialMaxSize), exception.Message); + Assert.Empty(_decodedHeaders); + } + + [Fact] + public void DecodesStringLength_GreaterThanLimit_Error() + { + var encoded = _literalHeaderFieldWithoutIndexingNewName + .Concat(new byte[] { 0xff, 0x82, 0x1f }) // 4097 encoded with 7-bit prefix + .ToArray(); + + var exception = Assert.Throws(() => _decoder.Decode(encoded, endHeaders: true, handler: this)); + Assert.Equal(CoreStrings.FormatHPackStringLengthTooLarge(4097, HPackDecoder.MaxStringOctets), exception.Message); + Assert.Empty(_decodedHeaders); + } + + public static readonly TheoryData _incompleteHeaderBlockData = new TheoryData + { + // Indexed Header Field Representation - incomplete index encoding + new byte[] { 0xff }, + + // Literal Header Field with Incremental Indexing Representation - New Name - incomplete header name length encoding + new byte[] { 0x40, 0x7f }, + + // Literal Header Field with Incremental Indexing Representation - New Name - incomplete header name + new byte[] { 0x40, 0x01 }, + new byte[] { 0x40, 0x02, 0x61 }, + + // Literal Header Field with Incremental Indexing Representation - New Name - incomplete header value length encoding + new byte[] { 0x40, 0x01, 0x61, 0x7f }, + + // Literal Header Field with Incremental Indexing Representation - New Name - incomplete header value + new byte[] { 0x40, 0x01, 0x61, 0x01 }, + new byte[] { 0x40, 0x01, 0x61, 0x02, 0x61 }, + + // Literal Header Field with Incremental Indexing Representation - Indexed Name - incomplete index encoding + new byte[] { 0x7f }, + + // Literal Header Field with Incremental Indexing Representation - Indexed Name - incomplete header value length encoding + new byte[] { 0x7a, 0xff }, + + // Literal Header Field with Incremental Indexing Representation - Indexed Name - incomplete header value + new byte[] { 0x7a, 0x01 }, + new byte[] { 0x7a, 0x02, 0x61 }, + + // Literal Header Field without Indexing - New Name - incomplete header name length encoding + new byte[] { 0x00, 0xff }, + + // Literal Header Field without Indexing - New Name - incomplete header name + new byte[] { 0x00, 0x01 }, + new byte[] { 0x00, 0x02, 0x61 }, + + // Literal Header Field without Indexing - New Name - incomplete header value length encoding + new byte[] { 0x00, 0x01, 0x61, 0xff }, + + // Literal Header Field without Indexing - New Name - incomplete header value + new byte[] { 0x00, 0x01, 0x61, 0x01 }, + new byte[] { 0x00, 0x01, 0x61, 0x02, 0x61 }, + + // Literal Header Field without Indexing Representation - Indexed Name - incomplete index encoding + new byte[] { 0x0f }, + + // Literal Header Field without Indexing Representation - Indexed Name - incomplete header value length encoding + new byte[] { 0x02, 0xff }, + + // Literal Header Field without Indexing Representation - Indexed Name - incomplete header value + new byte[] { 0x02, 0x01 }, + new byte[] { 0x02, 0x02, 0x61 }, + + // Literal Header Field Never Indexed - New Name - incomplete header name length encoding + new byte[] { 0x10, 0xff }, + + // Literal Header Field Never Indexed - New Name - incomplete header name + new byte[] { 0x10, 0x01 }, + new byte[] { 0x10, 0x02, 0x61 }, + + // Literal Header Field Never Indexed - New Name - incomplete header value length encoding + new byte[] { 0x10, 0x01, 0x61, 0xff }, + + // Literal Header Field Never Indexed - New Name - incomplete header value + new byte[] { 0x10, 0x01, 0x61, 0x01 }, + new byte[] { 0x10, 0x01, 0x61, 0x02, 0x61 }, + + // Literal Header Field Never Indexed Representation - Indexed Name - incomplete index encoding + new byte[] { 0x1f }, + + // Literal Header Field Never Indexed Representation - Indexed Name - incomplete header value length encoding + new byte[] { 0x12, 0xff }, + + // Literal Header Field Never Indexed Representation - Indexed Name - incomplete header value + new byte[] { 0x12, 0x01 }, + new byte[] { 0x12, 0x02, 0x61 }, + + // Dynamic Table Size Update - incomplete max size encoding + new byte[] { 0x3f } + }; + + [Theory] + [MemberData(nameof(_incompleteHeaderBlockData))] + public void DecodesIncompleteHeaderBlock_Error(byte[] encoded) + { + var exception = Assert.Throws(() => _decoder.Decode(encoded, endHeaders: true, handler: this)); + Assert.Equal(CoreStrings.HPackErrorIncompleteHeaderBlock, exception.Message); + Assert.Empty(_decodedHeaders); + } + + public static readonly TheoryData _huffmanDecodingErrorData = new TheoryData + { + // Invalid Huffman encoding in header name + + _literalHeaderFieldWithIndexingNewName.Concat(_huffmanLongPadding).ToArray(), + _literalHeaderFieldWithIndexingNewName.Concat(_huffmanEos).ToArray(), + + _literalHeaderFieldWithoutIndexingNewName.Concat(_huffmanLongPadding).ToArray(), + _literalHeaderFieldWithoutIndexingNewName.Concat(_huffmanEos).ToArray(), + + _literalHeaderFieldNeverIndexedNewName.Concat(_huffmanLongPadding).ToArray(), + _literalHeaderFieldNeverIndexedNewName.Concat(_huffmanEos).ToArray(), + + // Invalid Huffman encoding in header value + + _literalHeaderFieldWithIndexingIndexedName.Concat(_huffmanLongPadding).ToArray(), + _literalHeaderFieldWithIndexingIndexedName.Concat(_huffmanEos).ToArray(), + + _literalHeaderFieldWithoutIndexingIndexedName.Concat(_huffmanLongPadding).ToArray(), + _literalHeaderFieldWithoutIndexingIndexedName.Concat(_huffmanEos).ToArray(), + + _literalHeaderFieldNeverIndexedIndexedName.Concat(_huffmanLongPadding).ToArray(), + _literalHeaderFieldNeverIndexedIndexedName.Concat(_huffmanEos).ToArray() + }; + + [Theory] + [MemberData(nameof(_huffmanDecodingErrorData))] + public void WrapsHuffmanDecodingExceptionInHPackDecodingException(byte[] encoded) + { + var exception = Assert.Throws(() => _decoder.Decode(encoded, endHeaders: true, handler: this)); + Assert.Equal(CoreStrings.HPackHuffmanError, exception.Message); + Assert.IsType(exception.InnerException); + Assert.Empty(_decodedHeaders); + } + + private void TestDecodeWithIndexing(byte[] encoded, string expectedHeaderName, string expectedHeaderValue) + { + TestDecode(encoded, expectedHeaderName, expectedHeaderValue, expectDynamicTableEntry: true); + } + + private void TestDecodeWithoutIndexing(byte[] encoded, string expectedHeaderName, string expectedHeaderValue) + { + TestDecode(encoded, expectedHeaderName, expectedHeaderValue, expectDynamicTableEntry: false); + } + + private void TestDecode(byte[] encoded, string expectedHeaderName, string expectedHeaderValue, bool expectDynamicTableEntry) + { + Assert.Equal(0, _dynamicTable.Count); + Assert.Equal(0, _dynamicTable.Size); + + _decoder.Decode(encoded, endHeaders: true, handler: this); + + Assert.Equal(expectedHeaderValue, _decodedHeaders[expectedHeaderName]); + + if (expectDynamicTableEntry) + { + Assert.Equal(1, _dynamicTable.Count); + Assert.Equal(expectedHeaderName, Encoding.ASCII.GetString(_dynamicTable[0].Name)); + Assert.Equal(expectedHeaderValue, Encoding.ASCII.GetString(_dynamicTable[0].Value)); + Assert.Equal(expectedHeaderName.Length + expectedHeaderValue.Length + 32, _dynamicTable.Size); + } + else + { + Assert.Equal(0, _dynamicTable.Count); + Assert.Equal(0, _dynamicTable.Size); + } + } + } +} diff --git a/test/Kestrel.Core.Tests/Http2ConnectionTests.cs b/test/Kestrel.Core.Tests/Http2ConnectionTests.cs index 873233df34..3851a86dbd 100644 --- a/test/Kestrel.Core.Tests/Http2ConnectionTests.cs +++ b/test/Kestrel.Core.Tests/Http2ConnectionTests.cs @@ -4,7 +4,6 @@ using System; using System.Collections.Concurrent; using System.Collections.Generic; -using System.IO; using System.IO.Pipelines; using System.Linq; using System.Text; @@ -15,19 +14,18 @@ using Microsoft.AspNetCore.Server.Kestrel.Core.Features; using Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http; using Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http2; using Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http2.HPack; +using Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Infrastructure; using Microsoft.AspNetCore.Testing; using Microsoft.Extensions.Primitives; using Xunit; namespace Microsoft.AspNetCore.Server.Kestrel.Core.Tests { - public class Http2ConnectionTests : IDisposable + public class Http2ConnectionTests : IDisposable, IHttpHeadersHandler { - private static readonly string _largeHeaderA = new string('a', Http2Frame.MinAllowedMaxFrameSize - Http2Frame.HeaderLength - 8); + private static readonly string _largeHeaderValue = new string('a', HPackDecoder.MaxStringOctets); - private static readonly string _largeHeaderB = new string('b', Http2Frame.MinAllowedMaxFrameSize - Http2Frame.HeaderLength - 8); - - private static readonly IEnumerable> _postRequestHeaders = new [] + private static readonly IEnumerable> _postRequestHeaders = new[] { new KeyValuePair(":method", "POST"), new KeyValuePair(":path", "/"), @@ -35,7 +33,7 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Tests new KeyValuePair(":scheme", "https"), }; - private static readonly IEnumerable> _browserRequestHeaders = new [] + private static readonly IEnumerable> _browserRequestHeaders = new[] { new KeyValuePair(":method", "GET"), new KeyValuePair(":path", "/"), @@ -48,23 +46,32 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Tests new KeyValuePair("upgrade-insecure-requests", "1"), }; - private static readonly IEnumerable> _oneContinuationRequestHeaders = new [] + private static readonly IEnumerable> _oneContinuationRequestHeaders = new[] { new KeyValuePair(":method", "GET"), new KeyValuePair(":path", "/"), new KeyValuePair(":authority", "127.0.0.1"), new KeyValuePair(":scheme", "https"), - new KeyValuePair("a", _largeHeaderA) + new KeyValuePair("a", _largeHeaderValue), + new KeyValuePair("b", _largeHeaderValue), + new KeyValuePair("c", _largeHeaderValue), + new KeyValuePair("d", _largeHeaderValue) }; - private static readonly IEnumerable> _twoContinuationsRequestHeaders = new [] + private static readonly IEnumerable> _twoContinuationsRequestHeaders = new[] { new KeyValuePair(":method", "GET"), new KeyValuePair(":path", "/"), new KeyValuePair(":authority", "127.0.0.1"), new KeyValuePair(":scheme", "https"), - new KeyValuePair("a", _largeHeaderA), - new KeyValuePair("b", _largeHeaderB) + new KeyValuePair("a", _largeHeaderValue), + new KeyValuePair("b", _largeHeaderValue), + new KeyValuePair("c", _largeHeaderValue), + new KeyValuePair("d", _largeHeaderValue), + new KeyValuePair("e", _largeHeaderValue), + new KeyValuePair("f", _largeHeaderValue), + new KeyValuePair("g", _largeHeaderValue), + new KeyValuePair("h", _largeHeaderValue) }; private static readonly byte[] _helloBytes = Encoding.ASCII.GetBytes("hello"); @@ -77,12 +84,13 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Tests private readonly (IPipeConnection Transport, IPipeConnection Application) _pair; private readonly Http2ConnectionContext _connectionContext; private readonly Http2Connection _connection; - private readonly HPackEncoder _hpackEncoder = new HPackEncoder(); - private readonly HPackDecoder _hpackDecoder = new HPackDecoder(); private readonly Http2PeerSettings _clientSettings = new Http2PeerSettings(); + private readonly HPackEncoder _hpackEncoder = new HPackEncoder(); + private readonly HPackDecoder _hpackDecoder; private readonly ConcurrentDictionary> _runningStreams = new ConcurrentDictionary>(); private readonly Dictionary _receivedHeaders = new Dictionary(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _decodedHeaders = new Dictionary(StringComparer.OrdinalIgnoreCase); private readonly HashSet _abortedStreamIds = new HashSet(); private readonly object _abortedStreamIdsLock = new object(); @@ -160,8 +168,10 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Tests _largeHeadersApplication = context => { - context.Response.Headers["a"] = _largeHeaderA; - context.Response.Headers["b"] = _largeHeaderB; + foreach (var name in new[] { "a", "b", "c", "d", "e", "f", "g", "h" }) + { + context.Response.Headers[name] = _largeHeaderValue; + } return Task.CompletedTask; }; @@ -208,6 +218,8 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Tests _runningStreams[streamIdFeature.StreamId].TrySetResult(null); }; + _hpackDecoder = new HPackDecoder((int)_clientSettings.HeaderTableSize); + _connectionContext = new Http2ConnectionContext { ServiceContext = new TestServiceContext(), @@ -223,6 +235,11 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Tests _pipeFactory.Dispose(); } + void IHttpHeadersHandler.OnHeader(Span name, Span value) + { + _decodedHeaders[name.GetAsciiStringNonNullCharacters()] = value.GetAsciiStringNonNullCharacters(); + } + [Fact] public async Task DATA_Received_ReadByStream() { @@ -752,6 +769,16 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Tests await WaitForConnectionErrorAsync(expectedLastStreamId: 0, expectedErrorCode: Http2ErrorCode.PROTOCOL_ERROR, ignoreNonGoAwayFrames: false); } + [Fact] + public async Task HEADERS_Received_IncompleteHeaderBlockFragment_ConnectionError() + { + await InitializeConnectionAsync(_noopApplication); + + await SendIncompleteHeadersFrameAsync(streamId: 1); + + await WaitForConnectionErrorAsync(expectedLastStreamId: 0, expectedErrorCode: Http2ErrorCode.COMPRESSION_ERROR, ignoreNonGoAwayFrames: false); + } + [Fact] public async Task PRIORITY_Received_StreamIdZero_ConnectionError() { @@ -1192,6 +1219,17 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Tests await WaitForConnectionErrorAsync(expectedLastStreamId: 0, expectedErrorCode: Http2ErrorCode.PROTOCOL_ERROR, ignoreNonGoAwayFrames: false); } + [Fact] + public async Task CONTINUATION_Received_IncompleteHeaderBlockFragment_ConnectionError() + { + await InitializeConnectionAsync(_noopApplication); + + await SendHeadersAsync(1, Http2HeadersFrameFlags.NONE, _postRequestHeaders); + await SendIncompleteContinuationFrameAsync(streamId: 1); + + await WaitForConnectionErrorAsync(expectedLastStreamId: 0, expectedErrorCode: Http2ErrorCode.COMPRESSION_ERROR, ignoreNonGoAwayFrames: false); + } + [Fact] public async Task CONTINUATION_Sent_WhenHeadersLargerThanFrameLength() { @@ -1200,15 +1238,15 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Tests await StartStreamAsync(1, _browserRequestHeaders, endStream: true); var headersFrame = await ExpectAsync(Http2FrameType.HEADERS, - withLength: 55, + withLength: 12361, withFlags: (byte)Http2HeadersFrameFlags.NONE, withStreamId: 1); var continuationFrame1 = await ExpectAsync(Http2FrameType.CONTINUATION, - withLength: 16373, + withLength: 12306, withFlags: (byte)Http2ContinuationFrameFlags.NONE, withStreamId: 1); var continuationFrame2 = await ExpectAsync(Http2FrameType.CONTINUATION, - withLength: 16373, + withLength: 8204, withFlags: (byte)Http2ContinuationFrameFlags.END_HEADERS, withStreamId: 1); await ExpectAsync(Http2FrameType.DATA, @@ -1218,18 +1256,22 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Tests await StopConnectionAsync(expectedLastStreamId: 1, ignoreNonGoAwayFrames: false); - var responseHeaders = new HttpResponseHeaders(); - _hpackDecoder.Decode(headersFrame.HeadersPayload, responseHeaders); - _hpackDecoder.Decode(continuationFrame1.HeadersPayload, responseHeaders); - _hpackDecoder.Decode(continuationFrame2.HeadersPayload, responseHeaders); + _hpackDecoder.Decode(headersFrame.HeadersPayload, endHeaders: false, handler: this); + _hpackDecoder.Decode(continuationFrame1.HeadersPayload, endHeaders: false, handler: this); + _hpackDecoder.Decode(continuationFrame2.HeadersPayload, endHeaders: true, handler: this); - var responseHeadersDictionary = (IDictionary)responseHeaders; - Assert.Equal(5, responseHeadersDictionary.Count); - Assert.Contains("date", responseHeadersDictionary.Keys, StringComparer.OrdinalIgnoreCase); - Assert.Equal("200", responseHeadersDictionary[":status"]); - Assert.Equal("0", responseHeadersDictionary["content-length"]); - Assert.Equal(_largeHeaderA, responseHeadersDictionary["a"]); - Assert.Equal(_largeHeaderB, responseHeadersDictionary["b"]); + Assert.Equal(11, _decodedHeaders.Count); + Assert.Contains("date", _decodedHeaders.Keys, StringComparer.OrdinalIgnoreCase); + Assert.Equal("200", _decodedHeaders[":status"]); + Assert.Equal("0", _decodedHeaders["content-length"]); + Assert.Equal(_largeHeaderValue, _decodedHeaders["a"]); + Assert.Equal(_largeHeaderValue, _decodedHeaders["b"]); + Assert.Equal(_largeHeaderValue, _decodedHeaders["c"]); + Assert.Equal(_largeHeaderValue, _decodedHeaders["d"]); + Assert.Equal(_largeHeaderValue, _decodedHeaders["e"]); + Assert.Equal(_largeHeaderValue, _decodedHeaders["f"]); + Assert.Equal(_largeHeaderValue, _decodedHeaders["g"]); + Assert.Equal(_largeHeaderValue, _decodedHeaders["h"]); } [Fact] @@ -1525,12 +1567,28 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Tests return SendAsync(frame.Raw); } + private Task SendIncompleteHeadersFrameAsync(int streamId) + { + var frame = new Http2Frame(); + + frame.PrepareHeaders(Http2HeadersFrameFlags.END_HEADERS, streamId); + frame.Length = 3; + + // Set up an incomplete Literal Header Field w/ Incremental Indexing frame, + // with an incomplete new name + frame.Payload[0] = 0; + frame.Payload[1] = 2; + frame.Payload[2] = (byte)'a'; + + return SendAsync(frame.Raw); + } + private async Task SendContinuationAsync(int streamId, Http2ContinuationFrameFlags flags) { var frame = new Http2Frame(); frame.PrepareContinuation(flags, streamId); - var done =_hpackEncoder.Encode(frame.Payload, out var length); + var done = _hpackEncoder.Encode(frame.Payload, out var length); frame.Length = length; await SendAsync(frame.Raw); @@ -1538,6 +1596,22 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Tests return done; } + private Task SendIncompleteContinuationFrameAsync(int streamId) + { + var frame = new Http2Frame(); + + frame.PrepareContinuation(Http2ContinuationFrameFlags.END_HEADERS, streamId); + frame.Length = 3; + + // Set up an incomplete Literal Header Field w/ Incremental Indexing frame, + // with an incomplete new name + frame.Payload[0] = 0; + frame.Payload[1] = 2; + frame.Payload[2] = (byte)'a'; + + return SendAsync(frame.Raw); + } + private Task SendDataAsync(int streamId, Span data, bool endStream) { var frame = new Http2Frame(); diff --git a/test/Kestrel.Core.Tests/HuffmanTests.cs b/test/Kestrel.Core.Tests/HuffmanTests.cs index f075bbc7ba..cbe87cd4d9 100644 --- a/test/Kestrel.Core.Tests/HuffmanTests.cs +++ b/test/Kestrel.Core.Tests/HuffmanTests.cs @@ -1,7 +1,7 @@ // 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.Text; using Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http2.HPack; using Xunit; @@ -9,28 +9,143 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Tests { public class HuffmanTests { - [Fact] - public void HuffmanDecodeString() + public static readonly TheoryData _validData = new TheoryData { - // h e.......e l........l l o.......o - var encodedHello = new byte[] { 0b100111_00, 0b101_10100, 0b0_101000_0, 0b0111_1111 }; + // Single 5-bit symbol + { new byte[] { 0x07 }, Encoding.ASCII.GetBytes("0") }, + // Single 6-bit symbol + { new byte[] { 0x57 }, Encoding.ASCII.GetBytes("%") }, + // Single 7-bit symbol + { new byte[] { 0xb9 }, Encoding.ASCII.GetBytes(":") }, + // Single 8-bit symbol + { new byte[] { 0xf8 }, Encoding.ASCII.GetBytes("&") }, + // Single 10-bit symbol + { new byte[] { 0xfe, 0x3f }, Encoding.ASCII.GetBytes("!") }, + // Single 11-bit symbol + { new byte[] { 0xff, 0x7f }, Encoding.ASCII.GetBytes("+") }, + // Single 12-bit symbol + { new byte[] { 0xff, 0xaf }, Encoding.ASCII.GetBytes("#") }, + // Single 13-bit symbol + { new byte[] { 0xff, 0xcf }, Encoding.ASCII.GetBytes("$") }, + // Single 14-bit symbol + { new byte[] { 0xff, 0xf3 }, Encoding.ASCII.GetBytes("^") }, + // Single 15-bit symbol + { new byte[] { 0xff, 0xf9 }, Encoding.ASCII.GetBytes("<") }, + // Single 19-bit symbol + { new byte[] { 0xff, 0xfe, 0x1f }, Encoding.ASCII.GetBytes("\\") }, + // Single 20-bit symbol + { new byte[] { 0xff, 0xfe, 0x6f }, new byte[] { 0x80 } }, + // Single 21-bit symbol + { new byte[] { 0xff, 0xfe, 0xe7 }, new byte[] { 0x99 } }, + // Single 22-bit symbol + { new byte[] { 0xff, 0xff, 0x4b }, new byte[] { 0x81 } }, + // Single 23-bit symbol + { new byte[] { 0xff, 0xff, 0xb1 }, new byte[] { 0x01 } }, + // Single 24-bit symbol + { new byte[] { 0xff, 0xff, 0xea }, new byte[] { 0x09 } }, + // Single 25-bit symbol + { new byte[] { 0xff, 0xff, 0xf6, 0x7f }, new byte[] { 0xc7 } }, + // Single 26-bit symbol + { new byte[] { 0xff, 0xff, 0xf8, 0x3f }, new byte[] { 0xc0 } }, + // Single 27-bit symbol + { new byte[] { 0xff, 0xff, 0xfb, 0xdf }, new byte[] { 0xcb } }, + // Single 28-bit symbol + { new byte[] { 0xff, 0xff, 0xfe, 0x2f }, new byte[] { 0x02 } }, + // Single 30-bit symbol + { new byte[] { 0xff, 0xff, 0xff, 0xf3 }, new byte[] { 0x0a } }, - Assert.Equal("hello", Huffman.Decode(encodedHello, 0, encodedHello.Length)); + // h e l l o * + { new byte[] { 0b100111_00, 0b101_10100, 0b0_101000_0, 0b0111_1111 }, Encoding.ASCII.GetBytes("hello") }, - var encodedHeader = new byte[] - { - 0xb6, 0xb9, 0xac, 0x1c, 0x85, 0x58, 0xd5, 0x20, 0xa4, 0xb6, 0xc2, 0xad, 0x61, 0x7b, 0x5a, 0x54, 0x25, 0x1f - }; + // Sequences that uncovered errors + { new byte[] { 0xb6, 0xb9, 0xac, 0x1c, 0x85, 0x58, 0xd5, 0x20, 0xa4, 0xb6, 0xc2, 0xad, 0x61, 0x7b, 0x5a, 0x54, 0x25, 0x1f }, Encoding.ASCII.GetBytes("upgrade-insecure-requests") }, + { new byte[] { 0xfe, 0x53 }, Encoding.ASCII.GetBytes("\"t") } + }; - Assert.Equal("upgrade-insecure-requests", Huffman.Decode(encodedHeader, 0, encodedHeader.Length)); + [Theory] + [MemberData(nameof(_validData))] + public void HuffmanDecodeArray(byte[] encoded, byte[] expected) + { + var dst = new byte[expected.Length]; + Assert.Equal(expected.Length, Huffman.Decode(encoded, 0, encoded.Length, dst)); + Assert.Equal(expected, dst); + } - encodedHeader = new byte[] - { - // "t - 0xfe, 0x53 - }; + public static readonly TheoryData _longPaddingData = new TheoryData + { + // h e l l o * + new byte[] { 0b100111_00, 0b101_10100, 0b0_101000_0, 0b0111_1111, 0b11111111 }, - Assert.Equal("\"t", Huffman.Decode(encodedHeader, 0, encodedHeader.Length)); + // '&' (8 bits) + 8 bit padding + new byte[] { 0xf8, 0xff }, + + // ':' (7 bits) + 9 bit padding + new byte[] { 0xb9, 0xff } + }; + + [Theory] + [MemberData(nameof(_longPaddingData))] + public void ThrowsOnPaddingLongerThanSevenBits(byte[] encoded) + { + var exception = Assert.Throws(() => Huffman.Decode(encoded, 0, encoded.Length, new byte[encoded.Length * 2])); + Assert.Equal(CoreStrings.HPackHuffmanErrorIncomplete, exception.Message); + } + + public static readonly TheoryData _eosData = new TheoryData + { + // EOS + new byte[] { 0xff, 0xff, 0xff, 0xff }, + // '&' + EOS + '0' + new byte[] { 0xf8, 0xff, 0xff, 0xff, 0xfc, 0x1f } + }; + + [Theory] + [MemberData(nameof(_eosData))] + public void ThrowsOnEOS(byte[] encoded) + { + var exception = Assert.Throws(() => Huffman.Decode(encoded, 0, encoded.Length, new byte[encoded.Length * 2])); + Assert.Equal(CoreStrings.HPackHuffmanErrorEOS, exception.Message); + } + + [Fact] + public void ThrowsOnDestinationBufferTooSmall() + { + // h e l l o * + var encoded = new byte[] { 0b100111_00, 0b101_10100, 0b0_101000_0, 0b0111_1111 }; + var exception = Assert.Throws(() => Huffman.Decode(encoded, 0, encoded.Length, new byte[encoded.Length])); + Assert.Equal(CoreStrings.HPackHuffmanErrorDestinationTooSmall, exception.Message); + } + + public static readonly TheoryData _incompleteSymbolData = new TheoryData + { + // h e l l o (incomplete) + new byte[] { 0b100111_00, 0b101_10100, 0b0_101000_0 }, + + // Non-zero padding will be seen as incomplete symbol + // h e l l o * + new byte[] { 0b100111_00, 0b101_10100, 0b0_101000_0, 0b0111_0000 }, + new byte[] { 0b100111_00, 0b101_10100, 0b0_101000_0, 0b0111_0001 }, + new byte[] { 0b100111_00, 0b101_10100, 0b0_101000_0, 0b0111_0010 }, + new byte[] { 0b100111_00, 0b101_10100, 0b0_101000_0, 0b0111_0011 }, + new byte[] { 0b100111_00, 0b101_10100, 0b0_101000_0, 0b0111_0100 }, + new byte[] { 0b100111_00, 0b101_10100, 0b0_101000_0, 0b0111_0101 }, + new byte[] { 0b100111_00, 0b101_10100, 0b0_101000_0, 0b0111_0110 }, + new byte[] { 0b100111_00, 0b101_10100, 0b0_101000_0, 0b0111_0111 }, + new byte[] { 0b100111_00, 0b101_10100, 0b0_101000_0, 0b0111_1000 }, + new byte[] { 0b100111_00, 0b101_10100, 0b0_101000_0, 0b0111_1001 }, + new byte[] { 0b100111_00, 0b101_10100, 0b0_101000_0, 0b0111_1010 }, + new byte[] { 0b100111_00, 0b101_10100, 0b0_101000_0, 0b0111_1011 }, + new byte[] { 0b100111_00, 0b101_10100, 0b0_101000_0, 0b0111_1100 }, + new byte[] { 0b100111_00, 0b101_10100, 0b0_101000_0, 0b0111_1101 }, + new byte[] { 0b100111_00, 0b101_10100, 0b0_101000_0, 0b0111_1110 } + }; + + [Theory] + [MemberData(nameof(_incompleteSymbolData))] + public void ThrowsOnIncompleteSymbol(byte[] encoded) + { + var exception = Assert.Throws(() => Huffman.Decode(encoded, 0, encoded.Length, new byte[encoded.Length * 2])); + Assert.Equal(CoreStrings.HPackHuffmanErrorIncomplete, exception.Message); } [Theory] @@ -46,7 +161,7 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Tests [MemberData(nameof(HuffmanData))] public void HuffmanDecode(int code, uint encoded, int bitLength) { - Assert.Equal(code, Huffman.Decode(encoded, out var decodedBits)); + Assert.Equal(code, Huffman.Decode(encoded, bitLength, out var decodedBits)); Assert.Equal(bitLength, decodedBits); } @@ -61,7 +176,7 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Tests #pragma warning restore xUnit1026 int bitLength) { - Assert.Equal(code, Huffman.Decode(Huffman.Encode(code).encoded, out var decodedBits)); + Assert.Equal(code, Huffman.Decode(Huffman.Encode(code).encoded, bitLength, out var decodedBits)); Assert.Equal(bitLength, decodedBits); }