Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,11 @@ enum StateMachineResult {

/// An error that transitions the entire connection into a fatal error state. This should cause
/// emission of GOAWAY frames.
case connectionError(underlyingError: Error, type: HTTP2ErrorCode)
case connectionError(
underlyingError: Error,
type: HTTP2ErrorCode,
isMisbehavingPeer: Bool = false
)

/// The frame itself was not valid, but it is also not an error. Drop the frame.
case ignoreFrame
Expand Down
71 changes: 71 additions & 0 deletions Sources/NIOHTTP2/GlitchesMonitor.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
//===----------------------------------------------------------------------===//
//
// This source file is part of the SwiftNIO open source project
//
// Copyright (c) 2025 Apple Inc. and the SwiftNIO project authors
// Licensed under Apache License v2.0
//
// See LICENSE.txt for license information
// See CONTRIBUTORS.txt for the list of SwiftNIO project authors
//
// SPDX-License-Identifier: Apache-2.0
//
//===----------------------------------------------------------------------===//

struct GlitchesMonitor {
Comment thread
gjcairo marked this conversation as resolved.
static var defaultMaximumGlitches: Int { 200 }
private var stateMachine: GlitchesMonitorStateMachine

init(maximumGlitches: Int = GlitchesMonitor.defaultMaximumGlitches) {
self.stateMachine = GlitchesMonitorStateMachine(maxGlitches: maximumGlitches)
}

mutating func processStreamError() throws {
switch self.stateMachine.recordEvent() {
case .belowLimit:
()

case .exceededLimit:
throw NIOHTTP2Errors.excessiveNumberOfGlitches()
}
}
}

extension GlitchesMonitor {
private struct GlitchesMonitorStateMachine {
enum State {
case monitoring(numberOfGlitches: Int)
case glitchesExceeded
}

private var state: State
private let maxGlitches: Int

init(maxGlitches: Int) {
precondition(maxGlitches >= 0)
self.state = .monitoring(numberOfGlitches: 0)
self.maxGlitches = maxGlitches
}

enum RecordEventAction {
Comment thread
glbrntt marked this conversation as resolved.
case belowLimit
case exceededLimit
}

mutating func recordEvent() -> RecordEventAction {
switch self.state {
case .monitoring(let numberOfGlitches):
if numberOfGlitches < self.maxGlitches {
self.state = .monitoring(numberOfGlitches: numberOfGlitches &+ 1)
return .belowLimit
} else {
self.state = .glitchesExceeded
return .exceededLimit
}

case .glitchesExceeded:
return .exceededLimit
}
}
}
}
96 changes: 83 additions & 13 deletions Sources/NIOHTTP2/HTTP2ChannelHandler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,8 @@ public final class NIOHTTP2Handler: ChannelDuplexHandler {
/// This object deploys heuristics to attempt to detect denial of service attacks.
private var denialOfServiceValidator: DOSHeuristics<RealNIODeadlineClock>

private var glitchesMonitor: GlitchesMonitor

/// The mode this handler is operating in.
private let mode: ParserMode

Expand Down Expand Up @@ -236,6 +238,7 @@ public final class NIOHTTP2Handler: ChannelDuplexHandler {
maximumBufferedControlFrames: 10000,
maximumSequentialContinuationFrames: NIOHTTP2Handler.defaultMaximumSequentialContinuationFrames,
maximumRecentlyResetStreams: Self.defaultMaximumRecentlyResetFrames,
maximumConnectionGlitches: GlitchesMonitor.defaultMaximumGlitches,
maximumResetFrameCount: 200,
resetFrameCounterWindow: .seconds(30),
maximumStreamErrorCount: 200,
Expand Down Expand Up @@ -273,6 +276,7 @@ public final class NIOHTTP2Handler: ChannelDuplexHandler {
maximumBufferedControlFrames: maximumBufferedControlFrames,
maximumSequentialContinuationFrames: NIOHTTP2Handler.defaultMaximumSequentialContinuationFrames,
maximumRecentlyResetStreams: Self.defaultMaximumRecentlyResetFrames,
maximumConnectionGlitches: GlitchesMonitor.defaultMaximumGlitches,
maximumResetFrameCount: 200,
resetFrameCounterWindow: .seconds(30),
maximumStreamErrorCount: 200,
Expand Down Expand Up @@ -302,6 +306,7 @@ public final class NIOHTTP2Handler: ChannelDuplexHandler {
maximumBufferedControlFrames: connectionConfiguration.maximumBufferedControlFrames,
maximumSequentialContinuationFrames: connectionConfiguration.maximumSequentialContinuationFrames,
maximumRecentlyResetStreams: connectionConfiguration.maximumRecentlyResetStreams,
maximumConnectionGlitches: connectionConfiguration.maximumConnectionGlitches,
maximumResetFrameCount: streamConfiguration.streamResetFrameRateLimit.maximumCount,
resetFrameCounterWindow: streamConfiguration.streamResetFrameRateLimit.windowLength,
maximumStreamErrorCount: streamConfiguration.streamErrorRateLimit.maximumCount,
Expand All @@ -319,6 +324,7 @@ public final class NIOHTTP2Handler: ChannelDuplexHandler {
maximumBufferedControlFrames: Int,
maximumSequentialContinuationFrames: Int,
maximumRecentlyResetStreams: Int,
maximumConnectionGlitches: Int,
maximumResetFrameCount: Int,
resetFrameCounterWindow: TimeAmount,
maximumStreamErrorCount: Int,
Expand Down Expand Up @@ -348,6 +354,7 @@ public final class NIOHTTP2Handler: ChannelDuplexHandler {
self.tolerateImpossibleStateTransitionsInDebugMode = false
self.inboundStreamMultiplexerState = .uninitializedLegacy
self.maximumSequentialContinuationFrames = maximumSequentialContinuationFrames
self.glitchesMonitor = GlitchesMonitor(maximumGlitches: maximumConnectionGlitches)
}

/// Constructs a ``NIOHTTP2Handler``.
Expand All @@ -369,6 +376,7 @@ public final class NIOHTTP2Handler: ChannelDuplexHandler {
/// against this DoS vector we put an upper limit on this rate. Defaults to 200.
/// - resetFrameCounterWindow: Controls the sliding window used to enforce the maximum permitted reset frames rate. Too many may exhaust CPU resources. To protect
/// against this DoS vector we put an upper limit on this rate. 30 seconds.
/// - maximumConnectionGlitches: Controls the maximum number of stream errors that can happen on a connection before the connection is reset. Defaults to 200.
internal init(
mode: ParserMode,
initialSettings: HTTP2Settings = nioDefaultSettings,
Expand All @@ -382,7 +390,8 @@ public final class NIOHTTP2Handler: ChannelDuplexHandler {
maximumResetFrameCount: Int = 200,
resetFrameCounterWindow: TimeAmount = .seconds(30),
maximumStreamErrorCount: Int = 200,
streamErrorCounterWindow: TimeAmount = .seconds(30)
streamErrorCounterWindow: TimeAmount = .seconds(30),
maximumConnectionGlitches: Int = GlitchesMonitor.defaultMaximumGlitches
) {
self.stateMachine = HTTP2ConnectionStateMachine(
role: .init(mode),
Expand All @@ -408,6 +417,7 @@ public final class NIOHTTP2Handler: ChannelDuplexHandler {
self.tolerateImpossibleStateTransitionsInDebugMode = tolerateImpossibleStateTransitionsInDebugMode
self.inboundStreamMultiplexerState = .uninitializedLegacy
self.maximumSequentialContinuationFrames = maximumSequentialContinuationFrames
self.glitchesMonitor = GlitchesMonitor(maximumGlitches: maximumConnectionGlitches)
}

public func handlerAdded(context: ChannelHandlerContext) {
Expand Down Expand Up @@ -600,32 +610,41 @@ extension NIOHTTP2Handler {
self.inboundConnectionErrorTriggered(
context: context,
underlyingError: NIOHTTP2Errors.unableToParseFrame(),
reason: code
reason: code,
isMisbehavingPeer: false
)
return nil
} catch is NIOHTTP2Errors.BadClientMagic {
self.inboundConnectionErrorTriggered(
context: context,
underlyingError: NIOHTTP2Errors.badClientMagic(),
reason: .protocolError
reason: .protocolError,
isMisbehavingPeer: false
)
return nil
} catch is NIOHTTP2Errors.ExcessivelyLargeHeaderBlock {
self.inboundConnectionErrorTriggered(
context: context,
underlyingError: NIOHTTP2Errors.excessivelyLargeHeaderBlock(),
reason: .protocolError
reason: .protocolError,
isMisbehavingPeer: false
)
return nil
} catch is NIOHTTP2Errors.ExcessiveContinuationFrames {
self.inboundConnectionErrorTriggered(
context: context,
underlyingError: NIOHTTP2Errors.excessiveContinuationFrames(),
reason: .enhanceYourCalm
reason: .enhanceYourCalm,
isMisbehavingPeer: false
)
return nil
} catch {
self.inboundConnectionErrorTriggered(context: context, underlyingError: error, reason: .internalError)
self.inboundConnectionErrorTriggered(
context: context,
underlyingError: error,
reason: .internalError,
isMisbehavingPeer: false
)
return nil
}
}
Expand Down Expand Up @@ -733,6 +752,7 @@ extension NIOHTTP2Handler {
}

self.processDoSRisk(frame, result: &result)
self.processGlitches(result: &result)
self.processStateChange(result.effect)

let returnValue: FrameProcessResult
Expand All @@ -744,9 +764,14 @@ extension NIOHTTP2Handler {
case .ignoreFrame:
// Frame is good but no action needs to be taken.
returnValue = .continue
case .connectionError(let underlyingError, let errorCode):
case .connectionError(let underlyingError, let errorCode, let isMisbehavingPeer):
// We should stop parsing on received connection errors, the connection is going away anyway.
self.inboundConnectionErrorTriggered(context: context, underlyingError: underlyingError, reason: errorCode)
self.inboundConnectionErrorTriggered(
context: context,
underlyingError: underlyingError,
reason: errorCode,
isMisbehavingPeer: isMisbehavingPeer
)
returnValue = .stop
case .streamError(let streamID, let underlyingError, let errorCode):
// We can continue parsing on stream errors in most cases, the frame is just ignored.
Expand All @@ -770,15 +795,20 @@ extension NIOHTTP2Handler {
private func inboundConnectionErrorTriggered(
context: ChannelHandlerContext,
underlyingError: Error,
reason: HTTP2ErrorCode
reason: HTTP2ErrorCode,
isMisbehavingPeer: Bool
) {
// A connection error brings the entire connection down. We attempt to write a GOAWAY frame, and then report this
// error. It's possible that we'll be unable to write the GOAWAY frame, but that also just logs the error.
// Because we don't know what data the user handled before we got this, we propose that they may have seen all of it.
// The user may choose to fire a more specific error if they wish.

// If the peer is misbehaving, set the stream ID to the minimum allowed value (0).
// This will cause all open streams for this connection to be immediately terminated.
let streamID = isMisbehavingPeer ? HTTP2StreamID(0) : .maxID
let goAwayFrame = HTTP2Frame(
streamID: .rootStream,
payload: .goAway(lastStreamID: .maxID, errorCode: reason, opaqueData: nil)
payload: .goAway(lastStreamID: streamID, errorCode: reason, opaqueData: nil)
)
self.writeUnbufferedFrame(context: context, frame: goAwayFrame)
self.flushIfNecessary(context: context)
Expand Down Expand Up @@ -818,7 +848,29 @@ extension NIOHTTP2Handler {
()
}
} catch {
result.result = StateMachineResult.connectionError(underlyingError: error, type: .enhanceYourCalm)
result.result = StateMachineResult.connectionError(
underlyingError: error,
type: .enhanceYourCalm,
isMisbehavingPeer: true
)
result.effect = nil
}
}

private func processGlitches(result: inout StateMachineResultWithEffect) {
do {
switch result.result {
case .streamError:
try self.glitchesMonitor.processStreamError()
case .succeed, .ignoreFrame, .connectionError:
()
}
} catch {
result.result = .connectionError(
underlyingError: error,
type: .enhanceYourCalm,
isMisbehavingPeer: true
)
result.effect = nil
}
}
Expand Down Expand Up @@ -894,7 +946,12 @@ extension NIOHTTP2Handler {
}
} catch let error where error is NIOHTTP2Errors.ExcessiveOutboundFrameBuffering {
self.inboundStreamMultiplexer?.processedFrame(frame)
self.inboundConnectionErrorTriggered(context: context, underlyingError: error, reason: .enhanceYourCalm)
self.inboundConnectionErrorTriggered(
context: context,
underlyingError: error,
reason: .enhanceYourCalm,
isMisbehavingPeer: false
)
} catch {
self.inboundStreamMultiplexer?.processedFrame(frame)
promise?.fail(error)
Expand Down Expand Up @@ -968,7 +1025,7 @@ extension NIOHTTP2Handler {
switch result.result {
case .ignoreFrame:
preconditionFailure("Cannot be asked to ignore outbound frames.")
case .connectionError(let underlyingError, _):
case .connectionError(let underlyingError, _, _):
self.outboundConnectionErrorTriggered(context: context, promise: promise, underlyingError: underlyingError)
return
case .streamError(let streamID, let underlyingError, _):
Expand Down Expand Up @@ -1330,6 +1387,7 @@ extension NIOHTTP2Handler {
maximumBufferedControlFrames: connectionConfiguration.maximumBufferedControlFrames,
maximumSequentialContinuationFrames: connectionConfiguration.maximumSequentialContinuationFrames,
maximumRecentlyResetStreams: connectionConfiguration.maximumRecentlyResetStreams,
maximumConnectionGlitches: connectionConfiguration.maximumConnectionGlitches,
maximumResetFrameCount: streamConfiguration.streamResetFrameRateLimit.maximumCount,
resetFrameCounterWindow: streamConfiguration.streamResetFrameRateLimit.windowLength,
maximumStreamErrorCount: streamConfiguration.streamErrorRateLimit.maximumCount,
Expand Down Expand Up @@ -1362,6 +1420,7 @@ extension NIOHTTP2Handler {
maximumBufferedControlFrames: connectionConfiguration.maximumBufferedControlFrames,
maximumSequentialContinuationFrames: connectionConfiguration.maximumSequentialContinuationFrames,
maximumRecentlyResetStreams: connectionConfiguration.maximumRecentlyResetStreams,
maximumConnectionGlitches: connectionConfiguration.maximumConnectionGlitches,
maximumResetFrameCount: streamConfiguration.streamResetFrameRateLimit.maximumCount,
resetFrameCounterWindow: streamConfiguration.streamResetFrameRateLimit.windowLength,
maximumStreamErrorCount: streamConfiguration.streamErrorRateLimit.maximumCount,
Expand All @@ -1386,6 +1445,17 @@ extension NIOHTTP2Handler {
public var maximumBufferedControlFrames: Int = 10000
public var maximumSequentialContinuationFrames: Int = NIOHTTP2Handler.defaultMaximumSequentialContinuationFrames
public var maximumRecentlyResetStreams: Int = NIOHTTP2Handler.defaultMaximumRecentlyResetFrames

/// The maximum number of glitches that are allowed on a connection before it's forcefully closed.
///
/// A glitch is defined as some suspicious event on a connection, i.e., similar to a DoS attack.
/// A running count of the number of glitches occurring on each connection will be kept.
/// When the number of glitches reaches this threshold, the connection will be closed.
///
/// For more information, see the relevant presentation of the 2024 HTTP Workshop:
/// https://github.com/HTTPWorkshop/workshop2024/blob/main/talks/1.%20Security/glitches.pdf
public var maximumConnectionGlitches: Int = GlitchesMonitor.defaultMaximumGlitches

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be good, I think, to include narrative docs that explain this field and what it does.


public init() {}
}

Expand Down
37 changes: 37 additions & 0 deletions Sources/NIOHTTP2/HTTP2Error.swift
Original file line number Diff line number Diff line change
Expand Up @@ -487,6 +487,18 @@ public enum NIOHTTP2Errors {
ExcessiveEmptyDataFrames(file: file, line: line)
}

/// Creates a ``ExcessiveNumberOfGlitches`` error with appropriate source context.
///
/// - Parameters:
/// - file: Source file of the caller.
/// - line: Source line number of the caller.
public static func excessiveNumberOfGlitches(
file: String = #fileID,
line: UInt = #line
) -> ExcessiveNumberOfGlitches {
ExcessiveNumberOfGlitches(file: file, line: line)
}

/// Creates a ``ExcessivelyLargeHeaderBlock`` error with appropriate source context.
///
/// - Parameters:
Expand Down Expand Up @@ -1832,6 +1844,31 @@ public enum NIOHTTP2Errors {
}
}

/// The remote peer has triggered too many glitches on this connection.
public struct ExcessiveNumberOfGlitches: NIOHTTP2Error {
private let file: String
private let line: UInt

/// The location where the error was thrown.
public var location: String {
_location(file: self.file, line: self.line)
}

@available(*, deprecated, renamed: "excessiveNumberOfGlitches")
public init() {
self.init(file: #fileID, line: #line)
}

fileprivate init(file: String, line: UInt) {
self.file = file
self.line = line
}

public static func == (lhs: ExcessiveNumberOfGlitches, rhs: ExcessiveNumberOfGlitches) -> Bool {
true
}
}

/// The remote peer has sent a header block so large that ``NIOHTTP2`` refuses to buffer any more data than that.
public struct ExcessivelyLargeHeaderBlock: NIOHTTP2Error {
private let file: String
Expand Down
Loading