Skip to content

Commit 3798fe5

Browse files
authored
Merge pull request from GHSA-qppj-fm5r-hxr3
* Limit rate of permitted RST frames Motivation: Large number of stream reset frames may be used as a DoS (Denial of Service) vector in an attempt to overload the CPU of the handling server. Modifications: Introduce an additional DoS heuristic which evaluates the rate of incoming stream reset frames. If the rate exceeds that which is permitted then the connection is closed and a `GOAWAY` issued. The allowed rate is configurable but defaults to 200 resets within 30 seconds. This should be acceptable for most applications. Result: Excessive reset frames result in the connection being closed. * review comments * further review comments * add integration test
1 parent 2140160 commit 3798fe5

File tree

5 files changed

+309
-13
lines changed

5 files changed

+309
-13
lines changed

Sources/NIOHTTP2/DOSHeuristics.swift

Lines changed: 82 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
//
33
// This source file is part of the SwiftNIO open source project
44
//
5-
// Copyright (c) 2019 Apple Inc. and the SwiftNIO project authors
5+
// Copyright (c) 2019-2023 Apple Inc. and the SwiftNIO project authors
66
// Licensed under Apache License v2.0
77
//
88
// See LICENSE.txt for license information
@@ -12,9 +12,11 @@
1212
//
1313
//===----------------------------------------------------------------------===//
1414

15+
import DequeModule
16+
import NIOCore
1517

1618
/// Implements some simple denial of service heuristics on inbound frames.
17-
struct DOSHeuristics {
19+
struct DOSHeuristics<DeadlineClock: NIODeadlineClock> {
1820
/// The number of "empty" (zero bytes of useful payload) DATA frames we've received since the
1921
/// last useful frame.
2022
///
@@ -26,11 +28,14 @@ struct DOSHeuristics {
2628
/// The maximum number of "empty" data frames we're willing to tolerate.
2729
private let maximumSequentialEmptyDataFrames: Int
2830

29-
internal init(maximumSequentialEmptyDataFrames: Int) {
31+
private var resetFrameRateControlStateMachine: HTTP2ResetFrameRateControlStateMachine
32+
33+
internal init(maximumSequentialEmptyDataFrames: Int, maximumResetFrameCount: Int, resetFrameCounterWindow: TimeAmount, clock: DeadlineClock = RealNIODeadlineClock()) {
3034
precondition(maximumSequentialEmptyDataFrames >= 0,
3135
"maximum sequential empty data frames must be positive, got \(maximumSequentialEmptyDataFrames)")
3236
self.maximumSequentialEmptyDataFrames = maximumSequentialEmptyDataFrames
3337
self.receivedEmptyDataFrames = 0
38+
self.resetFrameRateControlStateMachine = .init(countThreshold: maximumResetFrameCount, timeWindow: resetFrameCounterWindow, clock: clock)
3439
}
3540
}
3641

@@ -48,7 +53,15 @@ extension DOSHeuristics {
4853
}
4954
case .headers:
5055
self.receivedEmptyDataFrames = 0
51-
case .alternativeService, .goAway, .origin, .ping, .priority, .pushPromise, .rstStream, .settings, .windowUpdate:
56+
case .rstStream:
57+
switch self.resetFrameRateControlStateMachine.resetReceived() {
58+
case .rateTooHigh:
59+
throw NIOHTTP2Errors.excessiveRSTFrames()
60+
case .noneReceived, .ratePermitted:
61+
// no risk
62+
()
63+
}
64+
case .alternativeService, .goAway, .origin, .ping, .priority, .pushPromise, .settings, .windowUpdate:
5265
// Currently we don't assess these for DoS risk.
5366
()
5467
}
@@ -58,3 +71,68 @@ extension DOSHeuristics {
5871
}
5972
}
6073
}
74+
75+
extension DOSHeuristics {
76+
// protect against excessive numbers of stream RST frames being issued
77+
struct HTTP2ResetFrameRateControlStateMachine {
78+
79+
enum ResetFrameRateControlState: Hashable {
80+
case noneReceived
81+
case ratePermitted
82+
case rateTooHigh
83+
}
84+
85+
private let countThreshold: Int
86+
private let timeWindow: TimeAmount
87+
private let clock: DeadlineClock
88+
89+
private var resetTimestamps: Deque<NIODeadline>
90+
private var _state: ResetFrameRateControlState = .noneReceived
91+
92+
init(countThreshold: Int, timeWindow: TimeAmount, clock: DeadlineClock = RealNIODeadlineClock()) {
93+
self.countThreshold = countThreshold
94+
self.timeWindow = timeWindow
95+
self.clock = clock
96+
97+
self.resetTimestamps = .init(minimumCapacity: self.countThreshold)
98+
}
99+
100+
mutating func resetReceived() -> ResetFrameRateControlState {
101+
self.garbageCollect()
102+
self.resetTimestamps.append(self.clock.now())
103+
self.evaluateState()
104+
return self._state
105+
}
106+
107+
private mutating func garbageCollect() {
108+
let now = self.clock.now()
109+
while let first = self.resetTimestamps.first, now - first > self.timeWindow {
110+
_ = self.resetTimestamps.popFirst()
111+
}
112+
}
113+
114+
private mutating func evaluateState() {
115+
switch self._state {
116+
case .noneReceived:
117+
self._state = .ratePermitted
118+
case .ratePermitted:
119+
if self.resetTimestamps.count > self.countThreshold {
120+
self._state = .rateTooHigh
121+
}
122+
case .rateTooHigh:
123+
break // no-op, there is no way to de-escalate from an excessive rate
124+
}
125+
}
126+
}
127+
}
128+
129+
// Simple mockable clock protocol
130+
protocol NIODeadlineClock {
131+
func now() -> NIODeadline
132+
}
133+
134+
struct RealNIODeadlineClock: NIODeadlineClock {
135+
func now() -> NIODeadline {
136+
NIODeadline.now()
137+
}
138+
}

Sources/NIOHTTP2/HTTP2ChannelHandler.swift

Lines changed: 56 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ public final class NIOHTTP2Handler: ChannelDuplexHandler {
7373
private var wroteFrame: Bool = false
7474

7575
/// This object deploys heuristics to attempt to detect denial of service attacks.
76-
private var denialOfServiceValidator: DOSHeuristics
76+
private var denialOfServiceValidator: DOSHeuristics<RealNIODeadlineClock>
7777

7878
/// The mode this handler is operating in.
7979
private let mode: ParserMode
@@ -209,7 +209,9 @@ public final class NIOHTTP2Handler: ChannelDuplexHandler {
209209
headerBlockValidation: headerBlockValidation,
210210
contentLengthValidation: contentLengthValidation,
211211
maximumSequentialEmptyDataFrames: 1,
212-
maximumBufferedControlFrames: 10000)
212+
maximumBufferedControlFrames: 10000,
213+
maximumResetFrameCount: 200,
214+
resetFrameCounterWindow: .seconds(30))
213215
}
214216

215217
/// Constructs a ``NIOHTTP2Handler``.
@@ -236,23 +238,47 @@ public final class NIOHTTP2Handler: ChannelDuplexHandler {
236238
headerBlockValidation: headerBlockValidation,
237239
contentLengthValidation: contentLengthValidation,
238240
maximumSequentialEmptyDataFrames: maximumSequentialEmptyDataFrames,
239-
maximumBufferedControlFrames: maximumBufferedControlFrames)
241+
maximumBufferedControlFrames: maximumBufferedControlFrames,
242+
maximumResetFrameCount: 200,
243+
resetFrameCounterWindow: .seconds(30))
240244

241245
}
242246

247+
/// Constructs a ``NIOHTTP2Handler``.
248+
///
249+
/// - Parameters:
250+
/// - mode: The mode for this handler, client or server.
251+
/// - connectionConfiguration: The settings that will be used when establishing the connection.
252+
/// - streamConfiguration: The settings that will be used when establishing new streams.
253+
public convenience init(mode: ParserMode,
254+
connectionConfiguration: ConnectionConfiguration = .init(),
255+
streamConfiguration: StreamConfiguration = .init()) {
256+
self.init(mode: mode,
257+
eventLoop: nil,
258+
initialSettings: connectionConfiguration.initialSettings,
259+
headerBlockValidation: connectionConfiguration.headerBlockValidation,
260+
contentLengthValidation: connectionConfiguration.contentLengthValidation,
261+
maximumSequentialEmptyDataFrames: connectionConfiguration.maximumSequentialEmptyDataFrames,
262+
maximumBufferedControlFrames: connectionConfiguration.maximumBufferedControlFrames,
263+
maximumResetFrameCount: streamConfiguration.streamResetFrameRateLimit.maximumCount,
264+
resetFrameCounterWindow: streamConfiguration.streamResetFrameRateLimit.windowLength)
265+
}
266+
243267
private init(mode: ParserMode,
244268
eventLoop: EventLoop?,
245269
initialSettings: HTTP2Settings,
246270
headerBlockValidation: ValidationState,
247271
contentLengthValidation: ValidationState,
248272
maximumSequentialEmptyDataFrames: Int,
249-
maximumBufferedControlFrames: Int) {
273+
maximumBufferedControlFrames: Int,
274+
maximumResetFrameCount: Int,
275+
resetFrameCounterWindow: TimeAmount) {
250276
self.eventLoop = eventLoop
251277
self.stateMachine = HTTP2ConnectionStateMachine(role: .init(mode), headerBlockValidation: .init(headerBlockValidation), contentLengthValidation: .init(contentLengthValidation))
252278
self.mode = mode
253279
self.initialSettings = initialSettings
254280
self.outboundBuffer = CompoundOutboundBuffer(mode: mode, initialMaxOutboundStreams: 100, maxBufferedControlFrames: maximumBufferedControlFrames)
255-
self.denialOfServiceValidator = DOSHeuristics(maximumSequentialEmptyDataFrames: maximumSequentialEmptyDataFrames)
281+
self.denialOfServiceValidator = DOSHeuristics(maximumSequentialEmptyDataFrames: maximumSequentialEmptyDataFrames, maximumResetFrameCount: maximumResetFrameCount, resetFrameCounterWindow: resetFrameCounterWindow)
256282
self.tolerateImpossibleStateTransitionsInDebugMode = false
257283
self.inboundStreamMultiplexerState = .uninitializedLegacy
258284
}
@@ -271,19 +297,25 @@ public final class NIOHTTP2Handler: ChannelDuplexHandler {
271297
/// upper limit on the depth of this queue. Defaults to 10,000.
272298
/// - tolerateImpossibleStateTransitionsInDebugMode: Whether impossible state transitions should be tolerated
273299
/// in debug mode.
300+
/// - maximumResetFrameCount: Controls the maximum permitted reset frames within a given time window. Too many may exhaust CPU resources. To protect
301+
/// against this DoS vector we put an upper limit on this rate. Defaults to 200.
302+
/// - resetFrameCounterWindow: Controls the sliding window used to enforce the maximum permitted reset frames rate. Too many may exhaust CPU resources. To protect
303+
/// against this DoS vector we put an upper limit on this rate. 30 seconds.
274304
internal init(mode: ParserMode,
275305
initialSettings: HTTP2Settings = nioDefaultSettings,
276306
headerBlockValidation: ValidationState = .enabled,
277307
contentLengthValidation: ValidationState = .enabled,
278308
maximumSequentialEmptyDataFrames: Int = 1,
279309
maximumBufferedControlFrames: Int = 10000,
280-
tolerateImpossibleStateTransitionsInDebugMode: Bool = false) {
310+
tolerateImpossibleStateTransitionsInDebugMode: Bool = false,
311+
maximumResetFrameCount: Int = 200,
312+
resetFrameCounterWindow: TimeAmount = .seconds(30)) {
281313
self.stateMachine = HTTP2ConnectionStateMachine(role: .init(mode), headerBlockValidation: .init(headerBlockValidation), contentLengthValidation: .init(contentLengthValidation))
282314
self.mode = mode
283315
self.eventLoop = nil
284316
self.initialSettings = initialSettings
285317
self.outboundBuffer = CompoundOutboundBuffer(mode: mode, initialMaxOutboundStreams: 100, maxBufferedControlFrames: maximumBufferedControlFrames)
286-
self.denialOfServiceValidator = DOSHeuristics(maximumSequentialEmptyDataFrames: maximumSequentialEmptyDataFrames)
318+
self.denialOfServiceValidator = DOSHeuristics(maximumSequentialEmptyDataFrames: maximumSequentialEmptyDataFrames, maximumResetFrameCount: maximumResetFrameCount, resetFrameCounterWindow: resetFrameCounterWindow)
287319
self.tolerateImpossibleStateTransitionsInDebugMode = tolerateImpossibleStateTransitionsInDebugMode
288320
self.inboundStreamMultiplexerState = .uninitializedLegacy
289321
}
@@ -1040,7 +1072,9 @@ extension NIOHTTP2Handler {
10401072
headerBlockValidation: connectionConfiguration.headerBlockValidation,
10411073
contentLengthValidation: connectionConfiguration.contentLengthValidation,
10421074
maximumSequentialEmptyDataFrames: connectionConfiguration.maximumSequentialEmptyDataFrames,
1043-
maximumBufferedControlFrames: connectionConfiguration.maximumBufferedControlFrames
1075+
maximumBufferedControlFrames: connectionConfiguration.maximumBufferedControlFrames,
1076+
maximumResetFrameCount: streamConfiguration.streamResetFrameRateLimit.maximumCount,
1077+
resetFrameCounterWindow: streamConfiguration.streamResetFrameRateLimit.windowLength
10441078
)
10451079

10461080
self.inboundStreamMultiplexerState = .uninitializedInline(streamConfiguration, inboundStreamInitializer, streamDelegate)
@@ -1061,7 +1095,9 @@ extension NIOHTTP2Handler {
10611095
headerBlockValidation: connectionConfiguration.headerBlockValidation,
10621096
contentLengthValidation: connectionConfiguration.contentLengthValidation,
10631097
maximumSequentialEmptyDataFrames: connectionConfiguration.maximumSequentialEmptyDataFrames,
1064-
maximumBufferedControlFrames: connectionConfiguration.maximumBufferedControlFrames
1098+
maximumBufferedControlFrames: connectionConfiguration.maximumBufferedControlFrames,
1099+
maximumResetFrameCount: streamConfiguration.streamResetFrameRateLimit.maximumCount,
1100+
resetFrameCounterWindow: streamConfiguration.streamResetFrameRateLimit.windowLength
10651101
)
10661102
self.inboundStreamMultiplexerState = .uninitializedAsync(streamConfiguration, inboundStreamInitializerWithAnyOutput, streamDelegate)
10671103
}
@@ -1086,6 +1122,17 @@ extension NIOHTTP2Handler {
10861122
public var targetWindowSize: Int = 65535
10871123
public var outboundBufferSizeHighWatermark: Int = 8196
10881124
public var outboundBufferSizeLowWatermark: Int = 4092
1125+
public var streamResetFrameRateLimit: StreamResetFrameRateLimitConfiguration = .init()
1126+
public init() {}
1127+
}
1128+
1129+
/// Stream reset frame rate limit configuration.
1130+
///
1131+
/// The settings that control the maximum permitted reset frames within a given time window. Too many may exhaust CPU resources.
1132+
/// To protect against this DoS vector we put an upper limit on this rate.
1133+
public struct StreamResetFrameRateLimitConfiguration: Hashable, Sendable {
1134+
public var maximumCount: Int = 200
1135+
public var windowLength: TimeAmount = .seconds(30)
10891136
public init() {}
10901137
}
10911138

Sources/NIOHTTP2/HTTP2Error.swift

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,11 @@ public enum NIOHTTP2Errors {
289289
return MissingMultiplexer(file: file, line: line)
290290
}
291291

292+
/// Creates a ``ExcessiveRSTFrames`` error with appropriate source context.
293+
public static func excessiveRSTFrames(file: String = #fileID, line: UInt = #line) -> ExcessiveRSTFrames {
294+
return ExcessiveRSTFrames(file: file, line: line)
295+
}
296+
292297
/// Creates a ``StreamError`` error with appropriate source context.
293298
///
294299
/// - Parameters:
@@ -1666,6 +1671,27 @@ public enum NIOHTTP2Errors {
16661671
return true
16671672
}
16681673
}
1674+
1675+
1676+
/// The client has issued RST frames at an excessive rate resulting in the connection being defensively closed.
1677+
public struct ExcessiveRSTFrames: NIOHTTP2Error {
1678+
private let file: String
1679+
private let line: UInt
1680+
1681+
/// The location where the error was thrown.
1682+
public var location: String {
1683+
return _location(file: self.file, line: self.line)
1684+
}
1685+
1686+
fileprivate init(file: String, line: UInt) {
1687+
self.file = file
1688+
self.line = line
1689+
}
1690+
1691+
public static func ==(lhs: Self, rhs: Self) -> Bool {
1692+
return true
1693+
}
1694+
}
16691695
}
16701696

16711697

0 commit comments

Comments
 (0)