Skip to content

Commit 86ce1dc

Browse files
authored
Merge pull request from GHSA-jchv-x857-q8fq
* Forbid excessive sending of empty DATA frames Motivation: One vector for DoS can be sending a large number of essentially "empty" frames, such as empty DATA frames without END_STREAM set. We should forbid peers from doing so excessively, but we want to tolerate implementations that occasionally emit such a frame for buggy reasons. Modifications: - Added verification that too many empty DATA frames are not sent. Result: Better resistance to DoS attacks. * Improved buffering in CompoundOutboundBuffer Motivation: In HTTP/2 it is possible for remote peers to submit work in a way that cause the NIOHTTP2Handler to generate a substantial number of control frames. If the peer is not consuming these frames, we can eventually run out of memory and die, which is not exactly ideal. It's hard for us to know how the buffering is going if we always submit all frames directly to the Channel, so we need to pay attention to whether the Channel is actually writable. If it isn't, rather than issue further writes we need to delay those writes so we can keep track of how big that buffer is getting. If the buffer gets too large we need a circuit breaker to drop the connection, as we're clearly not able to get work done. This patch adds a new buffer for outbound control frames. This new buffer is the last step in the buffering sequence, and the first to be drained when a Channel becomes writable. Additionally, enhancements need to be made to ensure that other buffers behave correctly in the face of this new model. The ConcurrentStreamsBuffer needs to be more aggressive about storing HEADERS frames that cannot be emitted to the wire because it does not tolerate emitting a HEADERS frame that cannot be immediately written to the wire. Additionally, the buffers must not be polled when a Channel is unwritable. This is a large patch, but it does substantially change the buffering logic, and protects NIO against a wide range of denial of service attacks, while providing a clear and coherent defense surface. Note that one thing this patch does not do is constrain the size of the buffers in the ConcurrentStreamsBuffer. This is on purpose: the size of this buffer is controlled entirely by user action. Users that do not wish to buffer excessively in this buffer can avoid doing so simply by respecting the peer's value of SETTINGS_MAX_CONCURRENT_STREAMS. As the entire purpose of this buffer is to tolerate users that do not wish to abide by this setting, it would be unreasonable to provide an upper bound on the value used in this setting. Modifications: - Moved the various buffers into a directory. - Built and implemented ControlFrameBuffer. - Integrated ControlFrameBuffer into CompoundOutboundBuffer. - Added awareness around channel writability to HTTP2Handler - Enhanced ConcurrentStreamsBuffer to be writability-aware. Result: We'll have somewhere to put control frames. * Cleanup API for merged security fixes. Motivation: After merging the security fixes we ended up with a slightly awkward API: the argument names differed slightly for no good reason, and it became impossible to override only one of the two security settings. Modifications: - Renamed `max` to `maximum`. - Gave default values for the security settings. Result: Better user experience. * Prevent DoS with large header lists. Motivation: There are two ways malicious peers could attempt to DoS a SwiftNIO HTTP/2 implementation with header lists. The first would be to attempt to send an enormous header list, ideally with an amplification factor. This approach was possible because SwiftNIO never actually enforced the value of the max header list size it advertised. In principle the peer can still get a good amplification attack effect by sending a long header list that contains zero-length headers. We may as well close this door as well: zero length header field names are unacceptable, and so we should forbid them. Modifications: - Added max header list enforcement. - Forbade zero-length header field names. Result: Peers cannot DoS a SwiftNIO HTTP/2 implementation using large header lists.
1 parent 492a77b commit 86ce1dc

27 files changed

+1292
-160
lines changed

Sources/NIOHPACK/HPACKDecoder.swift

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,14 @@ public struct HPACKDecoder {
2929
public static var maxDynamicTableSize: Int {
3030
return DynamicHeaderTable.defaultSize
3131
}
32+
33+
/// The default value of the maximum header list size for the decoder.
34+
///
35+
/// This value is somewhat arbitrary, but 16kB should be sufficiently large to decode all reasonably
36+
/// sized header lists.
37+
public static var defaultMaxHeaderListSize: Int {
38+
return 1<<14
39+
}
3240

3341
// private but tests
3442
var headerTable: IndexedHeaderTable
@@ -43,7 +51,10 @@ public struct HPACKDecoder {
4351
get { return self.headerTable.dynamicTableAllowedLength }
4452
set { self.headerTable.dynamicTableAllowedLength = newValue }
4553
}
46-
54+
55+
/// The maximum size of the header list.
56+
public var maxHeaderListSize: Int
57+
4758
/// A string value discovered in a HPACK buffer. The value can either indicate an entry
4859
/// in the header table index or the start of an inline literal string.
4960
enum HPACKString {
@@ -73,7 +84,17 @@ public struct HPACKDecoder {
7384
///
7485
/// - Parameter maxDynamicTableSize: Maximum allowed size of the dynamic header table.
7586
public init(allocator: ByteBufferAllocator, maxDynamicTableSize: Int = HPACKDecoder.maxDynamicTableSize) {
87+
self.init(allocator: allocator, maxDynamicTableSize: maxDynamicTableSize, maxHeaderListSize: HPACKDecoder.defaultMaxHeaderListSize)
88+
}
89+
90+
/// Creates a new decoder
91+
///
92+
/// - Parameter maxDynamicTableSize: Maximum allowed size of the dynamic header table.
93+
/// - Parameter maxHeaderListSize: Maximum allowed size of a decoded header list.
94+
public init(allocator: ByteBufferAllocator, maxDynamicTableSize: Int, maxHeaderListSize: Int) {
95+
precondition(maxHeaderListSize > 0, "Max header list size must be positive!")
7696
self.headerTable = IndexedHeaderTable(allocator: allocator, maxDynamicTableSize: maxDynamicTableSize)
97+
self.maxHeaderListSize = maxHeaderListSize
7798
}
7899

79100
/// Reads HPACK encoded header data from a `ByteBuffer`.
@@ -91,6 +112,8 @@ public struct HPACKDecoder {
91112
var headers: [HPACKHeader] = []
92113
headers.reserveCapacity(16)
93114

115+
var listSize = 0
116+
94117
while bufCopy.readableBytes > 0 {
95118
switch try self.decodeHeader(from: &bufCopy) {
96119
case .tableSizeChange:
@@ -104,6 +127,11 @@ public struct HPACKDecoder {
104127
throw NIOHPACKErrors.IllegalDynamicTableSizeChange()
105128
}
106129
case .header(let header):
130+
listSize += header.size
131+
guard listSize <= self.maxHeaderListSize else {
132+
throw NIOHPACKErrors.MaxHeaderListSizeViolation()
133+
}
134+
107135
headers.append(header)
108136
}
109137
}
@@ -191,6 +219,10 @@ public struct HPACKDecoder {
191219
(name, _) = try self.headerTable.header(at: idx)
192220
case .literal:
193221
name = try self.readEncodedString(from: &buffer)
222+
guard name.utf8.count > 0 else {
223+
// This isn't explicitly forbidden by RFC 7541, but it *is* forbidden by RFC 7230.
224+
throw NIOHPACKErrors.EmptyLiteralHeaderFieldName()
225+
}
194226
}
195227

196228
let value = try self.readEncodedString(from: &buffer)

Sources/NIOHPACK/HPACKErrors.swift

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,4 +107,15 @@ public enum NIOHPACKErrors {
107107
public struct ZeroHeaderIndex: NIOHPACKError {
108108
public init() { }
109109
}
110+
111+
/// HPACK decoder asked to decode a header list that would violate the configured
112+
/// max header list size.
113+
public struct MaxHeaderListSizeViolation: NIOHPACKError {
114+
public init() { }
115+
}
116+
117+
/// HPACK decoder asked to decode a header field name that was empty.
118+
public struct EmptyLiteralHeaderFieldName: NIOHPACKError {
119+
public init() { }
120+
}
110121
}

Sources/NIOHPACK/HPACKHeader.swift

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,20 @@ internal struct HPACKHeader {
280280
}
281281

282282

283+
extension HPACKHeader {
284+
internal var size: Int {
285+
// RFC 7541 § 4.1:
286+
//
287+
// The size of an entry is the sum of its name's length in octets (as defined in
288+
// Section 5.2), its value's length in octets, and 32.
289+
//
290+
// The size of an entry is calculated using the length of its name and value
291+
// without any Huffman encoding applied.
292+
return name.utf8.count + value.utf8.count + 32
293+
}
294+
}
295+
296+
283297
internal extension UInt8 {
284298
var isASCII: Bool {
285299
return self <= 127

Sources/NIOHTTP2/ConnectionStateMachine/HasLocalSettings.swift

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,9 @@ extension HasLocalSettings {
6363
// respect that possibility.
6464
effect.streamWindowSizeChange += Int(delta)
6565
case .maxFrameSize:
66-
effect.newMaxFrameSize = newValue
66+
effect.newMaxFrameSize = newValue
67+
case .maxHeaderListSize:
68+
effect.newMaxHeaderListSize = newValue
6769
default:
6870
// No operation required
6971
return

Sources/NIOHTTP2/DOSHeuristics.swift

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the SwiftNIO open source project
4+
//
5+
// Copyright (c) 2019 Apple Inc. and the SwiftNIO project authors
6+
// Licensed under Apache License v2.0
7+
//
8+
// See LICENSE.txt for license information
9+
// See CONTRIBUTORS.txt for the list of SwiftNIO project authors
10+
//
11+
// SPDX-License-Identifier: Apache-2.0
12+
//
13+
//===----------------------------------------------------------------------===//
14+
15+
16+
/// Implements some simple denial of service heuristics on inbound frames.
17+
struct DOSHeuristics {
18+
/// The number of "empty" (zero bytes of useful payload) DATA frames we've received since the
19+
/// last useful frame.
20+
///
21+
/// We reset this count each time we see END_STREAM, or a HEADERS frame, both of which we count
22+
/// as doing useful work. We have a small budget for these because we want to tolerate buggy
23+
/// implementations that occasionally emit empty DATA frames, but don't want to drown in them.
24+
private var receivedEmptyDataFrames: Int
25+
26+
/// The maximum number of "empty" data frames we're willing to tolerate.
27+
private let maximumSequentialEmptyDataFrames: Int
28+
29+
internal init(maximumSequentialEmptyDataFrames: Int) {
30+
precondition(maximumSequentialEmptyDataFrames >= 0,
31+
"maximum sequential empty data frames must be positive, got \(maximumSequentialEmptyDataFrames)")
32+
self.maximumSequentialEmptyDataFrames = maximumSequentialEmptyDataFrames
33+
self.receivedEmptyDataFrames = 0
34+
}
35+
}
36+
37+
38+
extension DOSHeuristics {
39+
mutating func process(_ frame: HTTP2Frame) throws {
40+
switch frame.payload {
41+
case .data(let payload):
42+
if payload.data.readableBytes == 0 {
43+
self.receivedEmptyDataFrames += 1
44+
}
45+
46+
if payload.endStream {
47+
self.receivedEmptyDataFrames = 0
48+
}
49+
case .headers:
50+
self.receivedEmptyDataFrames = 0
51+
case .alternativeService, .goAway, .origin, .ping, .priority, .pushPromise, .rstStream, .settings, .windowUpdate:
52+
// Currently we don't assess these for DoS risk.
53+
()
54+
}
55+
56+
if self.receivedEmptyDataFrames > self.maximumSequentialEmptyDataFrames {
57+
throw NIOHTTP2Errors.ExcessiveEmptyDataFrames()
58+
}
59+
}
60+
}

Sources/NIOHTTP2/ConcurrentStreamBuffer.swift renamed to Sources/NIOHTTP2/Frame Buffers/ConcurrentStreamBuffer.swift

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -105,8 +105,9 @@ struct ConcurrentStreamBuffer {
105105
self.bufferedFrames.markFlushPoint()
106106
}
107107

108-
mutating func processOutboundFrame(_ frame: HTTP2Frame, promise: EventLoopPromise<Void>?) throws -> OutboundFrameAction {
109-
// If this frame is not for a locally initiated stream, then that's fine, just pass it on.
108+
mutating func processOutboundFrame(_ frame: HTTP2Frame, promise: EventLoopPromise<Void>?, channelWritable: Bool) throws -> OutboundFrameAction {
109+
// If this frame is not for a locally initiated stream, then that's fine, just pass it on. Even if the channel isn't
110+
// writable, one of the other two buffers should catch this.
110111
guard frame.streamID != .rootStream && frame.streamID.mayBeInitiatedBy(self.mode) else {
111112
return .forward
112113
}
@@ -119,29 +120,44 @@ struct ConcurrentStreamBuffer {
119120
//
120121
// Before we search our buffers for this stream we do a quick sanity check: if its stream ID is lower than the first element in the
121122
// array, it won't be there.
123+
//
124+
// Again, we choose to ignore channel writability here because one of the other buffers should catch this frame.
122125
if let firstElement = self.bufferedFrames.first,
123126
frame.streamID >= firstElement.streamID,
124127
let bufferIndex = self.bufferedFrames.binarySearch(key: { $0.streamID }, needle: frame.streamID) {
125128
return self.bufferFrame(frame, promise: promise, bufferIndex: bufferIndex)
126129
}
127130

128131
// Ok, we're not currently buffering frames for this stream.
132+
//
129133
// Now we need to check if this is for a stream that has already been opened. If it is, and we aren't buffering it, pass
130-
// the frame through.
134+
// the frame through. Again, we ignore channel writability here because we don't need to delay state changes: one of the
135+
// other buffers will catch this and it'll be fine.
131136
if frame.streamID <= self.lastOutboundStream {
132137
return .forward
133138
}
134139

135140
// Now we want to see whether we're allowed to initiate a new stream. If we aren't, then we will buffer this stream.
136-
if self.currentOutboundStreams >= self.maxOutboundStreams {
137-
// Ok, we can't create a new stream. In this case we need to buffer this. We can only have gotten this far if either this stream ID is lower
138-
// than the first stream ID, or if it's higher but doesn't match something in the buffer. As a result, it is an error for this frame to have
139-
// a stream ID lower than or equal to the highest stream ID in the buffer: if it did, we should have found it when we searched above. If that
140-
// constraint is breached, fail the write.
141+
if self.currentOutboundStreams >= self.maxOutboundStreams || !channelWritable {
142+
// Ok, we can't create a new stream, either due to MAX_CONCURRENT_STREAMS limits or because the channel isn't writable. In this case we
143+
// need to buffer this. We can only have gotten this far if either this stream ID is lower than the first stream ID, or if it's higher
144+
// but doesn't match something in the buffer. As a result, it is an error for this frame to have a stream ID lower than or equal to the
145+
// highest stream ID in the buffer: if it did, we should have found it when we searched above. If that constraint is breached, fail the write.
141146
if let lastElement = self.bufferedFrames.last, frame.streamID <= lastElement.streamID {
142147
throw NIOHTTP2Errors.StreamIDTooSmall()
143148
}
144149

150+
// Ok, the stream ID is fine: buffer this frame.
151+
self.bufferFrameForNewStream(frame, promise: promise)
152+
return .nothing
153+
} else if let lastElement = self.bufferedFrames.last, !lastElement.currentlyUnblocking {
154+
// In principle we can create a new stream, and the channel is writable. However, we have at least one stream that is currently buffered and not unblocking.
155+
// This buffer probably has a HEADERS frame in it, and we really don't want to violate the ordering requirements that implies, so we'll buffer this anyway.
156+
// We still want StreamIDTooSmall protection here.
157+
if frame.streamID <= lastElement.streamID {
158+
throw NIOHTTP2Errors.StreamIDTooSmall()
159+
}
160+
145161
// Ok, the stream ID is fine: buffer this frame.
146162
self.bufferFrameForNewStream(frame, promise: promise)
147163
return .nothing
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the SwiftNIO open source project
4+
//
5+
// Copyright (c) 2019 Apple Inc. and the SwiftNIO project authors
6+
// Licensed under Apache License v2.0
7+
//
8+
// See LICENSE.txt for license information
9+
// See CONTRIBUTORS.txt for the list of SwiftNIO project authors
10+
//
11+
// SPDX-License-Identifier: Apache-2.0
12+
//
13+
//===----------------------------------------------------------------------===//
14+
import NIO
15+
16+
17+
/// A buffer that stores outbound control frames.
18+
///
19+
/// In general it is preferential to buffer outbound frames instead of passing them into the channel.
20+
/// This is because once the frame has left the HTTP2 handler and moved into the Channel it is no longer
21+
/// easy for us to tell how much data has been buffered. The larger the buffer grows, the more likely it
22+
/// is that the peer is consuming resources of ours that we need for other use-cases, and in some cases
23+
/// this may amount to an actual denial of service attack.
24+
///
25+
/// We have a number of buffers that handle data frames, but a similar concern applies to control frames
26+
/// too. Control frames need to be emitted with relatively high priority, but they should only be emitted
27+
/// when it will be reasonably possible to write them to the network. As long as it is not possible, we
28+
/// want to store them where we can see them, and use the buffer size to make choices about the connection.
29+
struct ControlFrameBuffer {
30+
/// Any control frame writes that may need to be emitted.
31+
private var pendingControlFrames: MarkedCircularBuffer<PendingControlFrame>
32+
33+
/// The maximum size of the buffer. If we have to buffer more frames than this,
34+
/// we'll kill the connection.
35+
internal var maximumBufferSize: Int
36+
}
37+
38+
39+
// MARK:- ControlFrameBuffer initializers
40+
extension ControlFrameBuffer {
41+
internal init(maximumBufferSize: Int) {
42+
// We allocate a circular buffer of reasonable size to ensure that if we ever do have to
43+
// buffer control frames that we won't need to resize this too aggressively.
44+
self.pendingControlFrames = MarkedCircularBuffer(initialCapacity: 16)
45+
self.maximumBufferSize = maximumBufferSize
46+
}
47+
}
48+
49+
50+
// MARK:- ControlFrameBuffer frame processing
51+
extension ControlFrameBuffer {
52+
internal mutating func processOutboundFrame(_ frame: HTTP2Frame, promise: EventLoopPromise<Void>?, channelWritable: Bool) throws -> OutboundFrameAction {
53+
switch frame.payload {
54+
case .data:
55+
// These frames are not buffered here. If it reached us, it's because we believe the channel is writable,
56+
// and we must have no control frames buffered.
57+
assert(channelWritable, "Received flushed data frame without writable channel")
58+
assert(self.pendingControlFrames.count == 0, "Received flush data frames while buffering control frames")
59+
return .forward
60+
default:
61+
// Control frames. These are what we buffer. We buffer them in two cases: either we have something else
62+
// already buffered (to ensure ordering stays correct), or the channel isn't writable.
63+
if !channelWritable || self.pendingControlFrames.count > 0 {
64+
try self.bufferFrame(frame, promise: promise)
65+
return .nothing
66+
} else {
67+
return .forward
68+
}
69+
}
70+
}
71+
72+
internal mutating func flushReceived() {
73+
self.pendingControlFrames.mark()
74+
}
75+
76+
internal mutating func nextFlushedWritableFrame() -> PendingControlFrame? {
77+
if self.pendingControlFrames.hasMark && self.pendingControlFrames.count > 0 {
78+
return self.pendingControlFrames.removeFirst()
79+
} else {
80+
return nil
81+
}
82+
}
83+
84+
internal func invalidateBuffer(reason: ChannelError) {
85+
for write in self.pendingControlFrames {
86+
write.promise?.fail(reason)
87+
}
88+
}
89+
90+
private mutating func bufferFrame(_ frame: HTTP2Frame, promise: EventLoopPromise<Void>?) throws {
91+
guard self.pendingControlFrames.count < self.maximumBufferSize else {
92+
// Appending another frame would violate the maximum buffer size. We're storing too many frames here,
93+
// we gotta move on.
94+
throw NIOHTTP2Errors.ExcessiveOutboundFrameBuffering()
95+
}
96+
self.pendingControlFrames.append(PendingControlFrame(frame: frame, promise: promise))
97+
}
98+
}
99+
100+
101+
// MARK:- ControlFrameBuffer.PendingControlFrame definition
102+
extension ControlFrameBuffer {
103+
/// A buffered control frame write and its associated promise.
104+
struct PendingControlFrame {
105+
var frame: HTTP2Frame
106+
var promise: EventLoopPromise<Void>?
107+
}
108+
}

Sources/NIOHTTP2/OutboundFlowControlBuffer.swift renamed to Sources/NIOHTTP2/Frame Buffers/OutboundFlowControlBuffer.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,7 @@ internal struct OutboundFlowControlBuffer {
143143
}
144144

145145
internal mutating func nextFlushedWritableFrame() -> (HTTP2Frame, EventLoopPromise<Void>?)? {
146+
// If the channel isn't writable, we don't want to send anything.
146147
guard let nextStreamID = self.nextStreamToSend(), self.connectionWindowSize > 0 else {
147148
return nil
148149
}

0 commit comments

Comments
 (0)