VYPR
Medium severity6.9GHSA Advisory· Published Jun 12, 2026· Updated Jun 12, 2026

NIOExtras: NIOHTTPRequestDecompressor ratio limit bypass via inflated Content-Length

CVE-2026-28975

Description

NIOHTTPRequestDecompressor's .ratio(N) limit uses attacker-controlled Content-Length, allowing gzip bomb bypass of decompression limits and memory exhaustion.

AI Insight

LLM-synthesized narrative grounded in this CVE's description and references.

NIOHTTPRequestDecompressor's .ratio(N) limit uses attacker-controlled Content-Length, allowing gzip bomb bypass of decompression limits and memory exhaustion.

Vulnerability

In NIOHTTPRequestDecompressor when configured with .ratio(N), the decompression limit check uses the Content-Length header value from the incoming request rather than the actual number of compressed bytes received from the network. Since Content-Length is attacker-controlled, a malicious client can supply an inflated value that causes the ratio check to always pass, effectively disabling the configured decompression limit. This affects swift-nio-extras versions prior to 1.34.1 [1][2].

Exploitation

An attacker sends a small, highly-compressed payload (a gzip bomb) with a falsified Content-Length header set to match the expected decompressed size. For example, a gzip payload containing highly repetitive data can achieve amplification ratios of several hundred to one. Under .ratio(10) such a payload should be rejected, but if the attacker sets Content-Length to the decompressed size, the check evaluates decompressed > decompressed * 10 which is always false, and the payload is accepted without error. The attacker can repeat this across multiple requests for sustained memory amplification [1][2].

Impact

Successful exploitation allows an attacker to bypass the ratio-based decompression limit entirely, causing the server to decompress the payload without restriction. This consumes unbounded memory, potentially leading to denial of service (DoS). The attacker does not need authentication or special privileges beyond being able to send HTTP requests to the server. This vulnerability is distinct from CVE-2020-9840, which affected the .size limit [1][2].

Mitigation

Fixed in swift-nio-extras version 1.34.1. The fix unifies the request and response decompressor implementations so that both accumulate actual compressed bytes received (compressedLength += part.readableBytes) rather than relying on the Content-Length header. Users should update to swift-nio-extras 1.34.1 or later. There is no known workaround other than upgrading [1][2].

AI Insight generated on Jun 12, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.

Affected products

1

Patches

1
d2eeec033907

Merge commit from fork

https://github.com/apple/swift-nio-extrasFabian FettMay 20, 2026Fixed in 1.34.1via ghsa-release-walk
3 files changed · +168 142
  • Sources/NIOHTTPCompression/HTTPDecompressor.swift+78 29 renamed
    @@ -2,7 +2,7 @@
     //
     // This source file is part of the SwiftNIO open source project
     //
    -// Copyright (c) 2019-2021 Apple Inc. and the SwiftNIO project authors
    +// Copyright (c) 2019-2026 Apple Inc. and the SwiftNIO project authors
     // Licensed under Apache License v2.0
     //
     // See LICENSE.txt for license information
    @@ -15,6 +15,33 @@
     import NIOCore
     import NIOHTTP1
     
    +/// Channel hander to decompress incoming HTTP data.
    +public final class NIOHTTPRequestDecompressor: ChannelDuplexHandler, RemovableChannelHandler {
    +    /// Expect to receive `HTTPServerRequestPart` from the network
    +    public typealias InboundIn = HTTPServerRequestPart
    +    /// Pass `HTTPServerRequestPart` to the next pipeline state in an inbound direction.
    +    public typealias InboundOut = HTTPServerRequestPart
    +    /// Pass through `HTTPServerResponsePart` outbound.
    +    public typealias OutboundIn = HTTPServerResponsePart
    +    /// Pass through `HTTPServerResponsePart` outbound.
    +    public typealias OutboundOut = HTTPServerResponsePart
    +
    +    private var decompressor: Decompressor<HTTPRequestHead>
    +
    +    /// Initialise
    +    /// - Parameter limit: Limit on the amount of decompression allowed.
    +    public init(limit: NIOHTTPDecompression.DecompressionLimit) {
    +        self.decompressor = Decompressor(limit: limit)
    +    }
    +
    +    public func channelRead(context: ChannelHandlerContext, data: NIOAny) {
    +        self.decompressor.channelRead(context: context, part: Self.unwrapInboundIn(data))
    +    }
    +}
    +
    +@available(*, unavailable)
    +extension NIOHTTPRequestDecompressor: Sendable {}
    +
     /// Duplex channel handler which will accept deflate and gzip encoded responses and decompress them.
     public final class NIOHTTPResponseDecompressor: ChannelDuplexHandler, RemovableChannelHandler {
         /// Expect `HTTPClientResponsePart` inbound.
    @@ -26,25 +53,12 @@ public final class NIOHTTPResponseDecompressor: ChannelDuplexHandler, RemovableC
         /// Send `HTTPClientRequestPart` to the next stage outbound.
         public typealias OutboundOut = HTTPClientRequestPart
     
    -    /// this struct encapsulates the state of a single http response decompression
    -    private struct Compression {
    -
    -        /// the used algorithm
    -        var algorithm: NIOHTTPDecompression.CompressionAlgorithm
    -
    -        /// the number of already consumed compressed bytes
    -        var compressedLength: Int
    -    }
    -
    -    private var compression: Compression? = nil
    -    private var decompressor: NIOHTTPDecompression.Decompressor
    -    private var decompressionComplete: Bool
    +    private var decompressor: Decompressor<HTTPResponseHead>
     
         /// Initialise
         /// - Parameter limit: Limit on the amount of decompression allowed.
         public init(limit: NIOHTTPDecompression.DecompressionLimit) {
    -        self.decompressor = NIOHTTPDecompression.Decompressor(limit: limit)
    -        self.decompressionComplete = false
    +        self.decompressor = Decompressor(limit: limit)
         }
     
         public func write(context: ChannelHandlerContext, data: NIOAny, promise: EventLoopPromise<Void>?) {
    @@ -63,7 +77,45 @@ public final class NIOHTTPResponseDecompressor: ChannelDuplexHandler, RemovableC
         }
     
         public func channelRead(context: ChannelHandlerContext, data: NIOAny) {
    -        switch self.unwrapInboundIn(data) {
    +        self.decompressor.channelRead(context: context, part: Self.unwrapInboundIn(data))
    +    }
    +}
    +
    +@available(*, unavailable)
    +extension NIOHTTPResponseDecompressor: Sendable {}
    +
    +// MARK: - Shared implementation -
    +
    +private protocol HTTPHead: Equatable {
    +    var headers: HTTPHeaders { get }
    +}
    +
    +extension HTTPRequestHead: HTTPHead {}
    +extension HTTPResponseHead: HTTPHead {}
    +
    +private struct Decompressor<InboundHead: HTTPHead> {
    +    typealias Inbound = HTTPPart<InboundHead, ByteBuffer>
    +
    +    /// this struct encapsulates the state of a single http response decompression
    +    private struct Compression {
    +        /// the used algorithm
    +        var algorithm: NIOHTTPDecompression.CompressionAlgorithm
    +
    +        /// the number of already consumed compressed bytes
    +        var compressedLength: Int
    +    }
    +
    +    private var compression: Compression? = nil
    +    private var decompressor: NIOHTTPDecompression.Decompressor
    +    private var decompressionComplete: Bool
    +
    +    init(limit: NIOHTTPDecompression.DecompressionLimit) {
    +        self.decompressor = NIOHTTPDecompression.Decompressor(limit: limit)
    +        self.decompressionComplete = false
    +    }
    +
    +    mutating func channelRead(context: ChannelHandlerContext, part: Inbound) {
    +        switch part {
             case .head(let head):
                 let contentType = head.headers[canonicalForm: "Content-Encoding"].first?.lowercased()
                 let algorithm = NIOHTTPDecompression.CompressionAlgorithm(header: contentType)
    @@ -74,35 +126,35 @@ public final class NIOHTTPResponseDecompressor: ChannelDuplexHandler, RemovableC
                         try self.decompressor.initializeDecoder()
                     }
     
    -                context.fireChannelRead(data)
    +                context.fireChannelRead(NIOAny(part))
                 } catch {
                     context.fireErrorCaught(error)
                 }
    -        case .body(var part):
    +        case .body(var content):
                 guard var compression = self.compression else {
    -                context.fireChannelRead(data)
    +                context.fireChannelRead(NIOAny(part))
                     return
                 }
     
                 do {
    -                compression.compressedLength += part.readableBytes
    -                while part.readableBytes > 0 && !self.decompressionComplete {
    +                compression.compressedLength += content.readableBytes
    +                while content.readableBytes > 0 && !self.decompressionComplete {
                         var buffer = context.channel.allocator.buffer(capacity: 16384)
                         let result = try self.decompressor.decompress(
    -                        part: &part,
    +                        part: &content,
                             buffer: &buffer,
                             compressedLength: compression.compressedLength
                         )
                         if result.complete {
                             self.decompressionComplete = true
                         }
    -                    context.fireChannelRead(self.wrapInboundOut(.body(buffer)))
    +                    context.fireChannelRead(NIOAny(Inbound.body(buffer)))
                     }
     
                     // assign the changed local property back to the class state
                     self.compression = compression
     
    -                if part.readableBytes > 0 {
    +                if content.readableBytes > 0 {
                         context.fireErrorCaught(NIOHTTPDecompression.ExtraDecompressionError.invalidTrailingData)
                     }
                 } catch {
    @@ -120,10 +172,7 @@ public final class NIOHTTPResponseDecompressor: ChannelDuplexHandler, RemovableC
                         context.fireErrorCaught(NIOHTTPDecompression.ExtraDecompressionError.truncatedData)
                     }
                 }
    -            context.fireChannelRead(data)
    +            context.fireChannelRead(NIOAny(part))
             }
         }
     }
    -
    -@available(*, unavailable)
    -extension NIOHTTPResponseDecompressor: Sendable {}
    
  • Sources/NIOHTTPCompression/HTTPRequestDecompressor.swift+0 113 removed
    @@ -1,113 +0,0 @@
    -//===----------------------------------------------------------------------===//
    -//
    -// This source file is part of the SwiftNIO open source project
    -//
    -// Copyright (c) 2019-2021 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
    -//
    -//===----------------------------------------------------------------------===//
    -
    -import CNIOExtrasZlib
    -import NIOCore
    -import NIOHTTP1
    -
    -/// Channel hander to decompress incoming HTTP data.
    -public final class NIOHTTPRequestDecompressor: ChannelDuplexHandler, RemovableChannelHandler {
    -    /// Expect to receive `HTTPServerRequestPart` from the network
    -    public typealias InboundIn = HTTPServerRequestPart
    -    /// Pass `HTTPServerRequestPart` to the next pipeline state in an inbound direction.
    -    public typealias InboundOut = HTTPServerRequestPart
    -    /// Pass through `HTTPServerResponsePart` outbound.
    -    public typealias OutboundIn = HTTPServerResponsePart
    -    /// Pass through `HTTPServerResponsePart` outbound.
    -    public typealias OutboundOut = HTTPServerResponsePart
    -
    -    private struct Compression {
    -        let algorithm: NIOHTTPDecompression.CompressionAlgorithm
    -        let contentLength: Int
    -    }
    -
    -    private var decompressor: NIOHTTPDecompression.Decompressor
    -    private var compression: Compression?
    -    private var decompressionComplete: Bool
    -
    -    /// Initialise with limits.
    -    /// - Parameter limit: Limit to how much inflation can occur to protect against bad cases.
    -    public init(limit: NIOHTTPDecompression.DecompressionLimit) {
    -        self.decompressor = NIOHTTPDecompression.Decompressor(limit: limit)
    -        self.compression = nil
    -        self.decompressionComplete = false
    -    }
    -
    -    public func channelRead(context: ChannelHandlerContext, data: NIOAny) {
    -        let request = self.unwrapInboundIn(data)
    -
    -        switch request {
    -        case .head(let head):
    -            if let encoding = head.headers[canonicalForm: "Content-Encoding"].first?.lowercased(),
    -                let algorithm = NIOHTTPDecompression.CompressionAlgorithm(header: encoding),
    -                let length = head.headers[canonicalForm: "Content-Length"].first.flatMap({ Int($0) })
    -            {
    -                do {
    -                    try self.decompressor.initializeDecoder()
    -                    self.compression = Compression(algorithm: algorithm, contentLength: length)
    -                } catch let error {
    -                    context.fireErrorCaught(error)
    -                    return
    -                }
    -            }
    -
    -            context.fireChannelRead(data)
    -        case .body(var part):
    -            guard let compression = self.compression else {
    -                context.fireChannelRead(data)
    -                return
    -            }
    -
    -            while part.readableBytes > 0 && !self.decompressionComplete {
    -                do {
    -                    var buffer = context.channel.allocator.buffer(capacity: 16384)
    -                    let result = try self.decompressor.decompress(
    -                        part: &part,
    -                        buffer: &buffer,
    -                        compressedLength: compression.contentLength
    -                    )
    -                    if result.complete {
    -                        self.decompressionComplete = true
    -                    }
    -
    -                    context.fireChannelRead(self.wrapInboundOut(.body(buffer)))
    -                } catch let error {
    -                    context.fireErrorCaught(error)
    -                    return
    -                }
    -            }
    -
    -            if part.readableBytes > 0 {
    -                context.fireErrorCaught(NIOHTTPDecompression.ExtraDecompressionError.invalidTrailingData)
    -            }
    -        case .end:
    -            if self.compression != nil {
    -                let wasDecompressionComplete = self.decompressionComplete
    -
    -                self.decompressor.deinitializeDecoder()
    -                self.compression = nil
    -                self.decompressionComplete = false
    -
    -                if !wasDecompressionComplete {
    -                    context.fireErrorCaught(NIOHTTPDecompression.ExtraDecompressionError.truncatedData)
    -                }
    -            }
    -
    -            context.fireChannelRead(data)
    -        }
    -    }
    -}
    -
    -@available(*, unavailable)
    -extension NIOHTTPRequestDecompressor: Sendable {}
    
  • Tests/NIOHTTPCompressionTests/HTTPRequestDecompressorTest.swift+90 0 modified
    @@ -192,6 +192,96 @@ class HTTPRequestDecompressorTest: XCTestCase {
             XCTAssertThrowsError(try channel.writeInbound(HTTPServerRequestPart.body(compressed)))
         }
     
    +    func testRatioLimitFiresWithHonestContentLength() throws {
    +        let channel = EmbeddedChannel()
    +        try channel.pipeline.syncOperations.addHandler(
    +            NIOHTTPRequestDecompressor(limit: .ratio(10))
    +        )
    +        let decompressed = ByteBuffer.of(bytes: Array(repeating: 0, count: 500))
    +        let compressed = compress(decompressed, "gzip")
    +        let headers = HTTPHeaders([
    +            ("Content-Encoding", "gzip"),
    +            ("Content-Length", "\(compressed.readableBytes)"),
    +        ])
    +        try channel.writeInbound(HTTPServerRequestPart.head(.init(
    +            version: .init(major: 1, minor: 1), method: .POST, uri: "/", headers: headers
    +        )))
    +        XCTAssertThrowsError(try channel.writeInbound(HTTPServerRequestPart.body(compressed)))
    +    }
    +
    +    func testRatioLimitFiresWithInflatedContentLength() throws {
    +        let channel = EmbeddedChannel()
    +        try channel.pipeline.syncOperations.addHandler(
    +            NIOHTTPRequestDecompressor(limit: .ratio(10))
    +        )
    +        let decompressed = ByteBuffer.of(bytes: Array(repeating: 0, count: 100_000))
    +        let compressed = compress(decompressed, "gzip")
    +        let headers = HTTPHeaders([
    +            ("Content-Encoding", "gzip"),
    +            ("Content-Length", "100000"),
    +        ])
    +        try channel.writeInbound(HTTPServerRequestPart.head(.init(
    +            version: .init(major: 1, minor: 1), method: .POST, uri: "/", headers: headers
    +        )))
    +        XCTAssertThrowsError(try channel.writeInbound(HTTPServerRequestPart.body(compressed)))
    +    }
    +
    +    func testSizeLimitUnaffectedByInflatedContentLength() throws {
    +        let channel = EmbeddedChannel()
    +        try channel.pipeline.syncOperations.addHandler(
    +            NIOHTTPRequestDecompressor(limit: .size(50_000))
    +        )
    +        let decompressed = ByteBuffer.of(bytes: Array(repeating: 0, count: 100_000))
    +        let compressed = compress(decompressed, "gzip")
    +        let headers = HTTPHeaders([
    +            ("Content-Encoding", "gzip"),
    +            ("Content-Length", "100000"),
    +        ])
    +        try channel.writeInbound(HTTPServerRequestPart.head(.init(
    +            version: .init(major: 1, minor: 1), method: .POST, uri: "/", headers: headers
    +        )))
    +        XCTAssertThrowsError(try channel.writeInbound(HTTPServerRequestPart.body(compressed)))
    +    }
    +
    +    func testMultiRequestRatioLimitWithInflatedContentLength() throws {
    +        let requestCount = 50
    +        let channel = EmbeddedChannel()
    +        try channel.pipeline.syncOperations.addHandler(
    +            NIOHTTPRequestDecompressor(limit: .ratio(10))
    +        )
    +        let rawPayload = ByteBuffer.of(bytes: Array(repeating: 0, count: 100_000))
    +        let compressed = compress(rawPayload, "gzip")
    +        let compressedSize = compressed.readableBytes
    +        var totalDecompressedBytes = 0
    +        var ratioLimitFired = false
    +
    +        for _ in 0..<requestCount {
    +            let headers = HTTPHeaders([
    +                ("Content-Encoding", "gzip"),
    +                ("Content-Length", "100000"),
    +            ])
    +            try channel.writeInbound(HTTPServerRequestPart.head(.init(
    +                version: .init(major: 1, minor: 1), method: .POST, uri: "/", headers: headers
    +            )))
    +            do {
    +                try channel.writeInbound(HTTPServerRequestPart.body(compressed))
    +            } catch is NIOHTTPDecompression.DecompressionError {
    +                ratioLimitFired = true
    +            }
    +            _ = try? channel.writeInbound(HTTPServerRequestPart.end(nil))
    +            while let part: HTTPServerRequestPart = try channel.readInbound() {
    +                if case .body(let buf) = part { totalDecompressedBytes += buf.readableBytes }
    +            }
    +        }
    +
    +        let configuredAllowance = requestCount * compressedSize * 10
    +        XCTAssertTrue(ratioLimitFired, "Ratio limit must fire — actual amplification far exceeds ratio(10)")
    +        XCTAssertLessThanOrEqual(
    +            totalDecompressedBytes, configuredAllowance,
    +            "Total decompressed bytes (\(totalDecompressedBytes)) must not exceed configured allowance (\(configuredAllowance))"
    +        )
    +    }
    +
         func testDecompressionTruncatedInput() throws {
             // Truncated compressed data
             let compressed = ByteBuffer(bytes: [120, 156, 99, 0])
    

Vulnerability mechanics

Synthesis attempt was rejected by the grounding validator. Re-run pending.

References

2

News mentions

0

No linked articles in our index yet.