VYPR
Moderate severityNVD Advisory· Published Feb 26, 2021· Updated Aug 3, 2024

Denial of Service

CVE-2021-21328

Description

A DoS vulnerability in Vapor before 4.40.1 allows attackers to exhaust system resources by sending requests to undefined routes, creating unlimited metrics counters and timers.

AI Insight

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

A DoS vulnerability in Vapor before 4.40.1 allows attackers to exhaust system resources by sending requests to undefined routes, creating unlimited metrics counters and timers.

Vulnerability

Description CVE-2021-21328 is a denial-of-service (DoS) vulnerability in the Vapor web framework for Swift, affecting versions prior to 4.40.1 [1]. The root cause is that when a metrics backend is bootstrapped, every HTTP request to an undefined or dynamic route path creates a new counter and timer within the metrics system [1][2]. An attacker can exploit this by sending a large number of requests with unique, arbitrary paths, which leads to the creation of unlimited metrics entries, eventually exhausting system memory and CPU [1].

Attack

Vector and Exploitation Exploitation requires no authentication and can be performed over the network. The attacker only needs to send HTTP requests to the target Vapor application with distinct, nonexistent route paths [1]. Each such request triggers the creation of new metrics objects (counters and timers), as the default responder previously did not normalize or limit the paths before recording [2]. This attack can be conducted with minimal effort, using simple scripting or tools to generate unique paths [1].

Impact

The primary impact is resource exhaustion on the Vapor server, leading to a denial of service. As memory and CPU resources are drained by the growing number of metrics objects, the application may become unresponsive [1]. Additionally, downstream services that receive error logs or metrics from the affected application could be spammed with excessive error paths, potentially impacting their operation as well [1]. There is no data corruption or unauthorized access, but the availability of the service is compromised.

Mitigation

The vulnerability has been patched in Vapor version 4.40.1 [1][4]. The fix involves modifying the DefaultResponder to rewrite any undefined route paths to vapor_route_undefined, thus preventing the creation of unlimited counters and timers [1][2]. Users are strongly advised to update to version 4.40.1 or later. No workarounds are mentioned, and the vendor has confirmed the fix in the official release notes [4].

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

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
github.com/vapor/vaporSwiftURL
< 4.40.14.40.1

Affected products

2

Patches

1
e3aa712508db

Merge pull request from GHSA-gcj9-jj38-hwmc

https://github.com/vapor/vaporTim CondonFeb 22, 2021via ghsa
4 files changed · +349 14
  • NOTICES.txt+21 0 added
    @@ -0,0 +1,21 @@
    +
    +//===----------------------------------------------------------------------===//
    +//
    +// This source file is part of the Vapor open source project
    +//
    +// Copyright (c) 2017-2021 Vapor project authors
    +// Licensed under MIT
    +//
    +// See LICENSE for license information
    +//
    +// SPDX-License-Identifier: MIT
    +//
    +//===----------------------------------------------------------------------===//
    +
    +This product contains a derivation of the TestMetrics test implementation
    +from Swift Metrics.
    +
    +  * LICENSE (Apache License 2.0):
    +    * https://www.apache.org/licenses/LICENSE-2.0
    +  * HOMEPAGE:
    +    * https://github.com/apple/swift-metrics
    
  • Sources/Vapor/Responder/DefaultResponder.swift+24 14 modified
    @@ -41,13 +41,10 @@ internal struct DefaultResponder: Responder {
         public func respond(to request: Request) -> EventLoopFuture<Response> {
             let startTime = DispatchTime.now().uptimeNanoseconds
             let response: EventLoopFuture<Response>
    -        let path: String
             if let cachedRoute = self.getRoute(for: request) {
    -            path = cachedRoute.route.description
                 request.route = cachedRoute.route
                 response = cachedRoute.responder.respond(to: request)
             } else {
    -            path = request.url.path
                 response = self.notFoundResponder.respond(to: request)
             }
             return response.always { result in
    @@ -60,7 +57,6 @@ internal struct DefaultResponder: Responder {
                 }
                 self.updateMetrics(
                     for: request,
    -                path: path,
                     startTime: startTime,
                     statusCode: status.code
                 )
    @@ -83,25 +79,39 @@ internal struct DefaultResponder: Responder {
         /// Records the requests metrics.
         private func updateMetrics(
             for request: Request,
    -        path: String,
             startTime: UInt64,
             statusCode: UInt
         ) {
    -        let counterDimensions = [
    -            ("method", request.method.string),
    -            ("path", path),
    +        let pathForMetrics: String
    +        if let route = request.route {
    +            // We don't use route.description here to avoid duplicating the method in the path
    +            pathForMetrics = "/\(route.path.map { "\($0)" }.joined(separator: "/"))"
    +        } else {
    +            // If the route is undefined (i.e. a 404 and not something like /users/:userID
    +            // We rewrite the path and the method to undefined to avoid DOSing the
    +            // application and any downstream metrics systems. Otherwise an attacker
    +            // could spam the service with unlimited requests and exhaust the system
    +            // with unlimited timers/counters
    +            pathForMetrics = "vapor_route_undefined"
    +        }
    +        let methodForMetrics: String
    +        if request.route == nil {
    +            methodForMetrics = "undefined"
    +        } else {
    +            methodForMetrics = request.method.string
    +        }
    +        let dimensions = [
    +            ("method", methodForMetrics),
    +            ("path", pathForMetrics),
                 ("status", statusCode.description),
             ]
    -        Counter(label: "http_requests_total", dimensions: counterDimensions).increment()
    +        Counter(label: "http_requests_total", dimensions: dimensions).increment()
             if statusCode >= 500 {
    -            Counter(label: "http_request_errors_total", dimensions: counterDimensions).increment()
    +            Counter(label: "http_request_errors_total", dimensions: dimensions).increment()
             }
             Timer(
                 label: "http_request_duration_seconds",
    -            dimensions: [
    -                ("method", request.method.string),
    -                ("path", path)
    -            ],
    +            dimensions: dimensions,
                 preferredDisplayUnit: .seconds
             ).recordNanoseconds(DispatchTime.now().uptimeNanoseconds - startTime)
         }
    
  • Tests/VaporTests/MetricsTests.swift+126 0 added
    @@ -0,0 +1,126 @@
    +import XCTVapor
    +import Vapor
    +import Metrics
    +@testable import CoreMetrics
    +
    +class MetricsTests: XCTestCase {
    +    func testMetricsIncreasesCounter() {
    +        let metrics = CapturingMetricsSystem()
    +        MetricsSystem.bootstrapInternal(metrics)
    +
    +        let app = Application(.testing)
    +        defer { app.shutdown() }
    +
    +        struct User: Content {
    +            let id: Int
    +            let name: String
    +        }
    +
    +        app.routes.get("users", ":userID") { req -> User in
    +            let userID = try req.parameters.require("userID", as: Int.self)
    +            if userID == 1 {
    +                return User(id: 1, name: "Tim")
    +            } else {
    +                throw Abort(.notFound)
    +            }
    +        }
    +
    +        XCTAssertNoThrow(try app.testable().test(.GET, "/users/1") { res in
    +            XCTAssertEqual(res.status, .ok)
    +            let resData = try res.content.decode(User.self)
    +            XCTAssertEqual(resData.id, 1)
    +            XCTAssertEqual(metrics.counters.count, 1)
    +            let counter = metrics.counters["http_requests_total"] as! TestCounter
    +            print(counter.dimensions)
    +            let pathDimension = try XCTUnwrap(counter.dimensions.first(where: { $0.0 == "path"}))
    +            XCTAssertEqual(pathDimension.1, "/users/:userID")
    +            XCTAssertNil(counter.dimensions.first(where: { $0.0 == "path" && $0.1 == "/users/1" }))
    +            let methodDimension = try XCTUnwrap(counter.dimensions.first(where: { $0.0 == "method"}))
    +            XCTAssertEqual(methodDimension.1, "GET")
    +            let status = try XCTUnwrap(counter.dimensions.first(where: { $0.0 == "status"}))
    +            XCTAssertEqual(status.1, "200")
    +
    +            let timer = metrics.timers["http_request_duration_seconds"] as! TestTimer
    +            let timerPathDimension = try XCTUnwrap(timer.dimensions.first(where: { $0.0 == "path"}))
    +            XCTAssertEqual(timerPathDimension.1, "/users/:userID")
    +            let timerMethodDimension = try XCTUnwrap(timer.dimensions.first(where: { $0.0 == "method"}))
    +            XCTAssertEqual(timerMethodDimension.1, "GET")
    +            let timerStatusDimension = try XCTUnwrap(timer.dimensions.first(where: { $0.0 == "status"}))
    +            XCTAssertEqual(timerStatusDimension.1, "200")
    +        })
    +    }
    +
    +    func testID404DoesntSpamMetrics() {
    +        let metrics = CapturingMetricsSystem()
    +        MetricsSystem.bootstrapInternal(metrics)
    +
    +        let app = Application(.testing)
    +        defer { app.shutdown() }
    +
    +        struct User: Content {
    +            let id: Int
    +            let name: String
    +        }
    +
    +        app.routes.get("users", ":userID") { req -> User in
    +            let userID = try req.parameters.require("userID", as: Int.self)
    +            if userID == 1 {
    +                return User(id: 1, name: "Tim")
    +            } else {
    +                throw Abort(.notFound)
    +            }
    +        }
    +
    +        XCTAssertNoThrow(try app.testable().test(.GET, "/users/2") { res in
    +            XCTAssertEqual(res.status, .notFound)
    +            let counter = metrics.counters["http_requests_total"] as! TestCounter
    +            let pathDimension = try XCTUnwrap(counter.dimensions.first(where: { $0.0 == "path"}))
    +            XCTAssertEqual(pathDimension.1, "/users/:userID")
    +            let methodDimension = try XCTUnwrap(counter.dimensions.first(where: { $0.0 == "method"}))
    +            XCTAssertEqual(methodDimension.1, "GET")
    +            let status = try XCTUnwrap(counter.dimensions.first(where: { $0.0 == "status"}))
    +            XCTAssertEqual(status.1, "404")
    +            XCTAssertNil(counter.dimensions.first(where: { $0.1 == "200" }))
    +            XCTAssertNil(counter.dimensions.first(where: { $0.0 == "path" && $0.1 == "/users/1" }))
    +
    +            let timer = metrics.timers["http_request_duration_seconds"] as! TestTimer
    +            let timerPathDimension = try XCTUnwrap(timer.dimensions.first(where: { $0.0 == "path"}))
    +            XCTAssertEqual(timerPathDimension.1, "/users/:userID")
    +            let timerMethodDimension = try XCTUnwrap(timer.dimensions.first(where: { $0.0 == "method"}))
    +            XCTAssertEqual(timerMethodDimension.1, "GET")
    +            let timerStatusDimension = try XCTUnwrap(timer.dimensions.first(where: { $0.0 == "status"}))
    +            XCTAssertEqual(timerStatusDimension.1, "404")
    +            XCTAssertNil(timer.dimensions.first(where: { $0.1 == "200" }))
    +        })
    +    }
    +
    +    func test404RewritesPathForMetricsToAvoidDOSAttack()  {
    +        let metrics = CapturingMetricsSystem()
    +        MetricsSystem.bootstrapInternal(metrics)
    +
    +        let app = Application(.testing)
    +        defer { app.shutdown() }
    +
    +        XCTAssertNoThrow(try app.testable().test(.GET, "/not/found") { res in
    +            XCTAssertEqual(res.status, .notFound)
    +            XCTAssertEqual(metrics.counters.count, 1)
    +            let counter = metrics.counters["http_requests_total"] as! TestCounter
    +            let pathDimension = try XCTUnwrap(counter.dimensions.first(where: { $0.0 == "path"}))
    +            XCTAssertEqual(pathDimension.1, "vapor_route_undefined")
    +            let methodDimension = try XCTUnwrap(counter.dimensions.first(where: { $0.0 == "method"}))
    +            XCTAssertEqual(methodDimension.1, "undefined")
    +            let status = try XCTUnwrap(counter.dimensions.first(where: { $0.0 == "status"}))
    +            XCTAssertEqual(status.1, "404")
    +
    +            let timer = metrics.timers["http_request_duration_seconds"] as! TestTimer
    +            let timerPathDimension = try XCTUnwrap(timer.dimensions.first(where: { $0.0 == "path"}))
    +            XCTAssertEqual(timerPathDimension.1, "vapor_route_undefined")
    +            let timerMethodDimension = try XCTUnwrap(timer.dimensions.first(where: { $0.0 == "method"}))
    +            XCTAssertEqual(timerMethodDimension.1, "undefined")
    +            let timerStatusDimension = try XCTUnwrap(timer.dimensions.first(where: { $0.0 == "status"}))
    +            XCTAssertEqual(timerStatusDimension.1, "404")
    +            XCTAssertNil(timer.dimensions.first(where: { $0.1 == "200" }))
    +        })
    +    }
    +}
    +
    
  • Tests/VaporTests/Utilities/CapturingMetricsSystem.swift+178 0 added
    @@ -0,0 +1,178 @@
    +// ===----------------------------------------------------------------------===##
    +//
    +//  This source file is part of the Vapor open source project
    +//
    +//  Copyright (c) 2017-2021 Vapor project authors
    +//  Licensed under MIT
    +//
    +//  See LICENSE for license information
    +//
    +//  SPDX-License-Identifier: MIT
    +//
    +// ===----------------------------------------------------------------------===##
    +// This was adapted from Swift Metrics's TestMetrics.swift code.
    +// The license for the original work is reproduced below. See NOTICES.txt for
    +// more.
    +
    +import Metrics
    +import Foundation
    +import NIOConcurrencyHelpers
    +
    +/// Metrics factory which allows inspecting recorded metrics programmatically.
    +/// Only intended for tests of the Metrics API itself.
    +internal final class CapturingMetricsSystem: MetricsFactory {
    +    private let lock = Lock()
    +    var counters = [String: CounterHandler]()
    +    var recorders = [String: RecorderHandler]()
    +    var timers = [String: TimerHandler]()
    +
    +    public func makeCounter(label: String, dimensions: [(String, String)]) -> CounterHandler {
    +        return self.make(label: label, dimensions: dimensions, registry: &self.counters, maker: TestCounter.init)
    +    }
    +
    +    public func makeRecorder(label: String, dimensions: [(String, String)], aggregate: Bool) -> RecorderHandler {
    +        let maker = { (label: String, dimensions: [(String, String)]) -> RecorderHandler in
    +            TestRecorder(label: label, dimensions: dimensions, aggregate: aggregate)
    +        }
    +        return self.make(label: label, dimensions: dimensions, registry: &self.recorders, maker: maker)
    +    }
    +
    +    public func makeTimer(label: String, dimensions: [(String, String)]) -> TimerHandler {
    +        return self.make(label: label, dimensions: dimensions, registry: &self.timers, maker: TestTimer.init)
    +    }
    +
    +    private func make<Item>(label: String, dimensions: [(String, String)], registry: inout [String: Item], maker: (String, [(String, String)]) -> Item) -> Item {
    +        return self.lock.withLock {
    +            let item = maker(label, dimensions)
    +            registry[label] = item
    +            return item
    +        }
    +    }
    +
    +    func destroyCounter(_ handler: CounterHandler) {
    +        if let testCounter = handler as? TestCounter {
    +            self.counters.removeValue(forKey: testCounter.label)
    +        }
    +    }
    +
    +    func destroyRecorder(_ handler: RecorderHandler) {
    +        if let testRecorder = handler as? TestRecorder {
    +            self.recorders.removeValue(forKey: testRecorder.label)
    +        }
    +    }
    +
    +    func destroyTimer(_ handler: TimerHandler) {
    +        if let testTimer = handler as? TestTimer {
    +            self.timers.removeValue(forKey: testTimer.label)
    +        }
    +    }
    +}
    +
    +internal class TestCounter: CounterHandler, Equatable {
    +    let id: String
    +    let label: String
    +    let dimensions: [(String, String)]
    +
    +    let lock = Lock()
    +    var values = [(Date, Int64)]()
    +
    +    init(label: String, dimensions: [(String, String)]) {
    +        self.id = UUID().uuidString
    +        self.label = label
    +        self.dimensions = dimensions
    +    }
    +
    +    func increment(by amount: Int64) {
    +        self.lock.withLock {
    +            self.values.append((Date(), amount))
    +        }
    +        print("adding \(amount) to \(self.label)")
    +    }
    +
    +    func reset() {
    +        self.lock.withLock {
    +            self.values = []
    +        }
    +        print("resetting \(self.label)")
    +    }
    +
    +    public static func == (lhs: TestCounter, rhs: TestCounter) -> Bool {
    +        return lhs.id == rhs.id
    +    }
    +}
    +
    +internal class TestRecorder: RecorderHandler, Equatable {
    +    let id: String
    +    let label: String
    +    let dimensions: [(String, String)]
    +    let aggregate: Bool
    +
    +    let lock = Lock()
    +    var values = [(Date, Double)]()
    +
    +    init(label: String, dimensions: [(String, String)], aggregate: Bool) {
    +        self.id = UUID().uuidString
    +        self.label = label
    +        self.dimensions = dimensions
    +        self.aggregate = aggregate
    +    }
    +
    +    func record(_ value: Int64) {
    +        self.record(Double(value))
    +    }
    +
    +    func record(_ value: Double) {
    +        self.lock.withLock {
    +            values.append((Date(), value))
    +        }
    +        print("recording \(value) in \(self.label)")
    +    }
    +
    +    public static func == (lhs: TestRecorder, rhs: TestRecorder) -> Bool {
    +        return lhs.id == rhs.id
    +    }
    +}
    +
    +internal class TestTimer: TimerHandler, Equatable {
    +    let id: String
    +    let label: String
    +    var displayUnit: TimeUnit?
    +    let dimensions: [(String, String)]
    +
    +    let lock = Lock()
    +    var values = [(Date, Int64)]()
    +
    +    init(label: String, dimensions: [(String, String)]) {
    +        self.id = UUID().uuidString
    +        self.label = label
    +        self.displayUnit = nil
    +        self.dimensions = dimensions
    +    }
    +
    +    func preferDisplayUnit(_ unit: TimeUnit) {
    +        self.lock.withLock {
    +            self.displayUnit = unit
    +        }
    +    }
    +
    +    func retriveValueInPreferredUnit(atIndex i: Int) -> Double {
    +        return self.lock.withLock {
    +            let value = values[i].1
    +            guard let displayUnit = self.displayUnit else {
    +                return Double(value)
    +            }
    +            return Double(value) / Double(displayUnit.scaleFromNanoseconds)
    +        }
    +    }
    +
    +    func recordNanoseconds(_ duration: Int64) {
    +        self.lock.withLock {
    +            values.append((Date(), duration))
    +        }
    +        print("recording \(duration) \(self.label)")
    +    }
    +
    +    public static func == (lhs: TestTimer, rhs: TestTimer) -> Bool {
    +        return lhs.id == rhs.id
    +    }
    +}
    

Vulnerability mechanics

Generated on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

7

News mentions

0

No linked articles in our index yet.