CVE-2021-21328: Uncontrolled Resource Consumption (CWE-400)
value: 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.
CWE 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.
CVSS v3 base score: 5.3
Published: 2021-02-26T02:15Z
Repository: https://github.com/vapor/vapor
Language: Swift
Files changed: NOTICES.txt, Sources/Vapor/Responder/DefaultResponder.swift, Tests/VaporTests/MetricsTests.swift, Tests/VaporTests/Utilities/CapturingMetricsSystem.swift
Commit date: 2021-02-22 16:17:49 +0000
Commit message: 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>
Diff: diff --git a/NOTICES.txt b/NOTICES.txt
new file mode 100644
index 000000000..684c81216
--- /dev/null
+++ b/NOTICES.txt
@@ -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
diff --git a/Sources/Vapor/Responder/DefaultResponder.swift b/Sources/Vapor/Responder/DefaultResponder.swift
index 8d46cf5be..6f36594e5 100644
--- a/Sources/Vapor/Responder/DefaultResponder.swift
+++ b/Sources/Vapor/Responder/DefaultResponder.swift
@@ -39,17 +39,14 @@ internal struct DefaultResponder: Responder {
 
     /// See `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
             let status: HTTPStatus
             switch result {
@@ -58,11 +55,10 @@ internal struct DefaultResponder: Responder {
             case .failure:
                 status = .internalServerError
             }
             self.updateMetrics(
                 for: request,
-                path: path,
                 startTime: startTime,
                 statusCode: status.code
             )
         }
     }
@@ -81,29 +77,43 @@ 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)
     }
 }
 
diff --git a/Tests/VaporTests/MetricsTests.swift b/Tests/VaporTests/MetricsTests.swift
new file mode 100644
index 000000000..81181b6f9
--- /dev/null
+++ b/Tests/VaporTests/MetricsTests.swift
@@ -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" }))
+        })
+    }
+}
+
diff --git a/Tests/VaporTests/Utilities/CapturingMetricsSystem.swift b/Tests/VaporTests/Utilities/CapturingMetricsSystem.swift
new file mode 100644
index 000000000..81406be34
--- /dev/null
+++ b/Tests/VaporTests/Utilities/CapturingMetricsSystem.swift
@@ -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
+    }
+}

Vulnerable code:         let path: String
            path = cachedRoute.route.description
            path = request.url.path
                path: path,
        path: String,
        let counterDimensions = [
            ("method", request.method.string),
            ("path", path),
        Counter(label: "http_requests_total", dimensions: counterDimensions).increment()
            Counter(label: "http_request_errors_total", dimensions: counterDimensions).increment()
            dimensions: [
                ("method", request.method.string),
                ("path", path)
            ],
Fixed code: 
//===----------------------------------------------------------------------===//
//
// 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
        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),
        Counter(label: "http_requests_total", dimensions: dimensions).increment()
            Counter(label: "http_request_errors_total", dimensions: dimensions).increment()
            dimensions: dimensions,
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" }))
        })
    }
}

// ===----------------------------------------------------------------------===##
//
//  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
    }
}