CVE-2021-21328 (published 2021-02-26T02:15Z)

Vapor is a web framework for Swift. In Vapor before version 4.40.1, there is a DoS attack against anyone who Bootstraps a metrics backend for their Vapor app. The following is the attack vector: 1. send unlimited requests against a vapor instance with different paths. this will create unlimited counters and timers, which will eventually drain the system. 2. downstream services might suffer from this attack as well by being spammed with error paths. This has been patched in 4.40.1. The `DefaultResponder` will rewrite any undefined route paths for to `vapor_route_undefined` to avoid unlimited counters.

Severity: MEDIUM
CVSS v2 base score: 5.0 (AV:N/AC:L/Au:N/C:N/I:N/A:P)
CVSS v3 base score: 5.3, MEDIUM (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:L)

Weakness information:
cwe_id: CWE-400
cwe_name: Uncontrolled Resource Consumption
description: The product does not properly control the allocation and maintenance of a limited resource, thereby enabling an actor to influence the amount of resources consumed, eventually leading to the exhaustion of available resources.
extended_description: 
            Limited resources include memory, file system storage, database connection pool entries, and CPU. If an attacker can trigger the allocation of these limited resources, but the number or size of the resources is not controlled, then the attacker could cause a denial of service that consumes all available resources. This would prevent valid users from accessing the product, and it could potentially have an impact on the surrounding environment. For example, a memory exhaustion attack against an application could slow down the application as well as its host operating system.
            There are at least three distinct scenarios which can commonly lead to resource exhaustion:
               
                  Lack of throttling for the number of allocated resources
                  Losing all references to a resource before reaching the shutdown stage
                  Not closing/returning a resource after processing
               
            Resource exhaustion problems are often result due to an incorrect implementation of the following situations:
               
                  Error conditions and other exceptional circumstances.
                  Confusion over which part of the program is responsible for releasing the resource.
               
         
         

Fix information:
repo_url: https://github.com/vapor/vapor
commit_details: repo_url: https://github.com/vapor/vapor
author: Tim Condon
author_date: 2021-02-22 16:17:49+00:00
msg: Merge pull request from GHSA-gcj9-jj38-hwmc

* Start backfilling some tests for metrics

* Add failing test for not found route

* Rewrite 404 path to avoid DOS attack

* Failing test to distinguish between undefined routes and routes that return a 404

* Finalise path expressions

* Don't crash the tests by bootstrapping multiple times

* Tests passing

* Make tests non-throwing

* Check request.route instead of adding new bool

* Update Tests/VaporTests/Utilities/CapturingMetricsSystem.swift

Co-authored-by: Gwynne Raskind <gwynne@darkrainfall.org>

* Add attribution for CapturingMetricsSystem

* Update Tests/VaporTests/Utilities/CapturingMetricsSystem.swift

Co-authored-by: Gwynne Raskind <gwynne@darkrainfall.org>

* Remove last uses of NSUUID

* Revert change to stop the tests crashing

* Backfill more tests

* Migrate CapturingMetricsSystem to NIOConcurrencyHelpers

* Ensure undefined route methods get mapped to undefined

* Add tests for the timers as well

* Reuse dimensions for the timer as well

* Rewrite undefined routes' method to undefined as well as the path

* Avoid duplicating the method in the path

* Add comment for rewriting path in metrics

* Add test to ensure we don't pass through the ID for a dynamic path

* Fix typo in comment

Co-authored-by: Gwynne Raskind <gwynne@darkrainfall.org>
repository: repo_url: https://github.com/vapor/vapor
repo_name: vapor/vapor
description: 💧 A server-side Swift HTTP web framework.
repo_language: Swift
owner: vapor
file_changes: filename: NOTICES.txt
change_type: ModificationType.ADD
diff: @@ -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

programming_language: Swift
filename: DefaultResponder.swift
change_type: ModificationType.MODIFY
diff: @@ -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)
     }

programming_language: Swift
filename: MetricsTests.swift
change_type: ModificationType.ADD
diff: @@ -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" }))
+        })
+    }
+}
+

programming_language: Swift
filename: CapturingMetricsSystem.swift
change_type: ModificationType.ADD
diff: @@ -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
+    }
+}

programming_language: Swift