CVE-2024-28867: Insufficient Information (NVD-CWE-noinfo)
value: Swift Prometheus is a Swift client for the Prometheus monitoring system, supporting counters, gauges and histograms. In code which applies _un-sanitized string values into metric names or labels_, an attacker could make use of this and send a `?lang` query parameter containing newlines, `}`  or similar characters which can lead to the attacker taking over the exported format -- including creating unbounded numbers of stored metrics, inflating server memory usage, or causing "bogus" metrics. This vulnerability is fixed in2.0.0-alpha.2.
CWE description: There is insufficient information about the issue to classify it; details are unkown or unspecified.
Published: 2024-03-29T15:15Z
Repository: https://github.com/swift-server/swift-prometheus
Language: Swift
Files changed: Sources/Prometheus/PrometheusCollectorRegistry.swift, Tests/PrometheusTests/ValidNamesTests.swift
Commit date: 2024-03-29 16:02:23 +0900
Commit message: Merge pull request from GHSA-x768-cvr2-345r


Diff: diff --git a/Sources/Prometheus/PrometheusCollectorRegistry.swift b/Sources/Prometheus/PrometheusCollectorRegistry.swift
index caaef10..1284112 100644
--- a/Sources/Prometheus/PrometheusCollectorRegistry.swift
+++ b/Sources/Prometheus/PrometheusCollectorRegistry.swift
@@ -71,11 +71,12 @@ public final class PrometheusCollectorRegistry: Sendable {
     /// created ``Counter`` will be part of the export.
     ///
     /// - Parameter name: A name to identify ``Counter``'s value.
     /// - Returns: A ``Counter`` that is registered with this ``PrometheusCollectorRegistry``
     public func makeCounter(name: String) -> Counter {
-        self.box.withLockedValue { store -> Counter in
+        let name = name.ensureValidMetricName()
+        return self.box.withLockedValue { store -> Counter in
             guard let value = store[name] else {
                 let counter = Counter(name: name, labels: [])
                 store[name] = .counter(counter)
                 return counter
             }
@@ -104,10 +105,13 @@ public final class PrometheusCollectorRegistry: Sendable {
     public func makeCounter(name: String, labels: [(String, String)]) -> Counter {
         guard !labels.isEmpty else {
             return self.makeCounter(name: name)
         }
 
+        let name = name.ensureValidMetricName()
+        let labels = labels.ensureValidLabelNames()
+
         return self.box.withLockedValue { store -> Counter in
             guard let value = store[name] else {
                 let labelNames = labels.allLabelNames
                 let counter = Counter(name: name, labels: labels)
 
@@ -152,11 +156,12 @@ public final class PrometheusCollectorRegistry: Sendable {
     /// created ``Gauge`` will be part of the export.
     ///
     /// - Parameter name: A name to identify ``Gauge``'s value.
     /// - Returns: A ``Gauge`` that is registered with this ``PrometheusCollectorRegistry``
     public func makeGauge(name: String) -> Gauge {
-        self.box.withLockedValue { store -> Gauge in
+        let name = name.ensureValidMetricName()
+        return self.box.withLockedValue { store -> Gauge in
             guard let value = store[name] else {
                 let gauge = Gauge(name: name, labels: [])
                 store[name] = .gauge(gauge)
                 return gauge
             }
@@ -185,10 +190,13 @@ public final class PrometheusCollectorRegistry: Sendable {
     public func makeGauge(name: String, labels: [(String, String)]) -> Gauge {
         guard !labels.isEmpty else {
             return self.makeGauge(name: name)
         }
 
+        let name = name.ensureValidMetricName()
+        let labels = labels.ensureValidLabelNames()
+
         return self.box.withLockedValue { store -> Gauge in
             guard let value = store[name] else {
                 let labelNames = labels.allLabelNames
                 let gauge = Gauge(name: name, labels: labels)
 
@@ -234,11 +242,12 @@ public final class PrometheusCollectorRegistry: Sendable {
     ///
     /// - Parameter name: A name to identify ``DurationHistogram``'s value.
     /// - Parameter buckets: Define the buckets that shall be used within the ``DurationHistogram``
     /// - Returns: A ``DurationHistogram`` that is registered with this ``PrometheusCollectorRegistry``
     public func makeDurationHistogram(name: String, buckets: [Duration]) -> DurationHistogram {
-        self.box.withLockedValue { store -> DurationHistogram in
+        let name = name.ensureValidMetricName()
+        return self.box.withLockedValue { store -> DurationHistogram in
             guard let value = store[name] else {
                 let gauge = DurationHistogram(name: name, labels: [], buckets: buckets)
                 store[name] = .durationHistogram(gauge)
                 return gauge
             }
@@ -272,10 +281,13 @@ public final class PrometheusCollectorRegistry: Sendable {
     ) -> DurationHistogram {
         guard !labels.isEmpty else {
             return self.makeDurationHistogram(name: name, buckets: buckets)
         }
 
+        let name = name.ensureValidMetricName()
+        let labels = labels.ensureValidLabelNames()
+
         return self.box.withLockedValue { store -> DurationHistogram in
             guard let value = store[name] else {
                 let labelNames = labels.allLabelNames
                 let histogram = DurationHistogram(name: name, labels: labels, buckets: buckets)
 
@@ -333,11 +345,12 @@ public final class PrometheusCollectorRegistry: Sendable {
     ///
     /// - Parameter name: A name to identify ``ValueHistogram``'s value.
     /// - Parameter buckets: Define the buckets that shall be used within the ``ValueHistogram``
     /// - Returns: A ``ValueHistogram`` that is registered with this ``PrometheusCollectorRegistry``
     public func makeValueHistogram(name: String, buckets: [Double]) -> ValueHistogram {
-        self.box.withLockedValue { store -> ValueHistogram in
+        let name = name.ensureValidMetricName()
+        return self.box.withLockedValue { store -> ValueHistogram in
             guard let value = store[name] else {
                 let gauge = ValueHistogram(name: name, labels: [], buckets: buckets)
                 store[name] = .valueHistogram(gauge)
                 return gauge
             }
@@ -362,10 +375,13 @@ public final class PrometheusCollectorRegistry: Sendable {
     public func makeValueHistogram(name: String, labels: [(String, String)], buckets: [Double]) -> ValueHistogram {
         guard !labels.isEmpty else {
             return self.makeValueHistogram(name: name, buckets: buckets)
         }
 
+        let name = name.ensureValidMetricName()
+        let labels = labels.ensureValidLabelNames()
+
         return self.box.withLockedValue { store -> ValueHistogram in
             guard let value = store[name] else {
                 let labelNames = labels.allLabelNames
                 let histogram = ValueHistogram(name: name, labels: labels, buckets: buckets)
 
@@ -558,10 +574,18 @@ extension [(String, String)] {
             result.append(name)
         }
         result = result.sorted()
         return result
     }
+
+    fileprivate func ensureValidLabelNames() -> [(String, String)] {
+        if self.allSatisfy({ $0.0.isValidLabelName() }) {
+            return self
+        } else {
+            return self.map { ($0.ensureValidLabelName(), $1) }
+        }
+    }
 }
 
 extension [UInt8] {
     fileprivate mutating func addTypeLine(label: String, type: String) {
         self.append(contentsOf: #"# TYPE "#.utf8)
@@ -593,5 +617,93 @@ extension PrometheusMetric {
             }
         }
         return prerendered
     }
 }
+
+extension String {
+    fileprivate func isValidMetricName() -> Bool {
+        var isFirstCharacter = true
+        for ascii in self.utf8 {
+            defer { isFirstCharacter = false }
+            switch ascii {
+            case UInt8(ascii: "A")...UInt8(ascii: "Z"),
+                 UInt8(ascii: "a")...UInt8(ascii: "z"),
+                 UInt8(ascii: "_"), UInt8(ascii: ":"):
+                continue
+            case UInt8(ascii: "0"), UInt8(ascii: "9"):
+                if isFirstCharacter {
+                    return false
+                }
+                continue
+            default:
+                return false
+            }
+        }
+        return true
+    }
+
+    fileprivate func isValidLabelName() -> Bool {
+        var isFirstCharacter = true
+        for ascii in self.utf8 {
+            defer { isFirstCharacter = false }
+            switch ascii {
+            case UInt8(ascii: "A")...UInt8(ascii: "Z"),
+                 UInt8(ascii: "a")...UInt8(ascii: "z"),
+                 UInt8(ascii: "_"):
+                continue
+            case UInt8(ascii: "0"), UInt8(ascii: "9"):
+                if isFirstCharacter {
+                    return false
+                }
+                continue
+            default:
+                return false
+            }
+        }
+        return true
+    }
+
+    fileprivate func ensureValidMetricName() -> String {
+        if self.isValidMetricName() {
+            return self
+        } else {
+            var new = self
+            new.fixPrometheusName(allowColon: true)
+            return new
+        }
+    }
+
+    fileprivate func ensureValidLabelName() -> String {
+        if self.isValidLabelName() {
+            return self
+        } else {
+            var new = self
+            new.fixPrometheusName(allowColon: false)
+            return new
+        }
+    }
+
+    fileprivate mutating func fixPrometheusName(allowColon: Bool) {
+        var startIndex = self.startIndex
+        var isFirstCharacter = true
+        while let fixIndex = self[startIndex...].firstIndex(where: { character in
+            defer { isFirstCharacter = false }
+            switch character {
+            case "A"..."Z", "a"..."z", "_":
+                return false
+            case ":":
+                return !allowColon
+            case "0"..."9":
+                return isFirstCharacter
+            default:
+                return true
+            }
+        }) {
+            self.replaceSubrange(fixIndex...fixIndex, with: CollectionOfOne("_"))
+            startIndex = fixIndex
+            if startIndex == self.endIndex {
+                break
+            }
+        }
+    }
+}
diff --git a/Tests/PrometheusTests/ValidNamesTests.swift b/Tests/PrometheusTests/ValidNamesTests.swift
new file mode 100644
index 0000000..8b1e172
--- /dev/null
+++ b/Tests/PrometheusTests/ValidNamesTests.swift
@@ -0,0 +1,95 @@
+//===----------------------------------------------------------------------===//
+//
+// This source file is part of the SwiftPrometheus open source project
+//
+// Copyright (c) 2024 SwiftPrometheus project authors
+// Licensed under Apache License v2.0
+//
+// See LICENSE.txt for license information
+// See CONTRIBUTORS.txt for the list of SwiftPrometheus project authors
+//
+// SPDX-License-Identifier: Apache-2.0
+//
+//===----------------------------------------------------------------------===//
+
+import Prometheus
+import XCTest
+
+final class ValidNamesTests: XCTestCase {
+    func testCounterWithEmoji() {
+        let client = PrometheusCollectorRegistry()
+        let counter = client.makeCounter(name: "coffee☕️", labels: [])
+        counter.increment()
+
+        var buffer = [UInt8]()
+        client.emit(into: &buffer)
+        XCTAssertEqual(
+            String(decoding: buffer, as: Unicode.UTF8.self),
+            """
+            # TYPE coffee_ counter
+            coffee_ 1
+
+            """
+        )
+    }
+
+    func testIllegalMetricNames() async throws {
+        let registry = PrometheusCollectorRegistry()
+
+        /// Notably, newlines must not allow creating whole new metric root
+        let tests = [
+            "name",
+            """
+            name{bad="haha"} 121212121
+            bad_bad 12321323
+            """
+        ]
+
+        for test in tests {
+            registry.makeCounter(
+                name: test,
+                labels: []
+            ).increment()
+        }
+
+        var buffer = [UInt8]()
+        registry.emit(into: &buffer)
+        XCTAssertEqual(
+            String(decoding: buffer, as: Unicode.UTF8.self).split(separator: "\n").sorted().joined(separator: "\n"),
+            """
+            # TYPE name counter
+            # TYPE name_bad__haha___121212121_bad_bad_12321323 counter
+            name 1
+            name_bad__haha___121212121_bad_bad_12321323 1
+            """
+        )
+    }
+
+    func testIllegalLabelNames() async throws {
+        let registry = PrometheusCollectorRegistry()
+
+        let tests = [
+            """
+            name{bad="haha"} 121212121
+            bad_bad 12321323
+            """
+        ]
+
+        for test in tests {
+            registry.makeCounter(
+                name: "metric",
+                labels: [(test, "value")]
+            ).increment()
+        }
+
+        var buffer = [UInt8]()
+        registry.emit(into: &buffer)
+        XCTAssertEqual(
+            String(decoding: buffer, as: Unicode.UTF8.self).split(separator: "\n").sorted().joined(separator: "\n"),
+            """
+            # TYPE metric counter
+            metric{name_bad__haha___121212121_bad_bad_12321323="value"} 1
+            """
+        )
+    }
+}

Vulnerable code:         self.box.withLockedValue { store -> Counter in
        self.box.withLockedValue { store -> Gauge in
        self.box.withLockedValue { store -> DurationHistogram in
        self.box.withLockedValue { store -> ValueHistogram in
Fixed code:         let name = name.ensureValidMetricName()
        return self.box.withLockedValue { store -> Counter in
        let name = name.ensureValidMetricName()
        let labels = labels.ensureValidLabelNames()

        let name = name.ensureValidMetricName()
        return self.box.withLockedValue { store -> Gauge in
        let name = name.ensureValidMetricName()
        let labels = labels.ensureValidLabelNames()

        let name = name.ensureValidMetricName()
        return self.box.withLockedValue { store -> DurationHistogram in
        let name = name.ensureValidMetricName()
        let labels = labels.ensureValidLabelNames()

        let name = name.ensureValidMetricName()
        return self.box.withLockedValue { store -> ValueHistogram in
        let name = name.ensureValidMetricName()
        let labels = labels.ensureValidLabelNames()


    fileprivate func ensureValidLabelNames() -> [(String, String)] {
        if self.allSatisfy({ $0.0.isValidLabelName() }) {
            return self
        } else {
            return self.map { ($0.ensureValidLabelName(), $1) }
        }
    }

extension String {
    fileprivate func isValidMetricName() -> Bool {
        var isFirstCharacter = true
        for ascii in self.utf8 {
            defer { isFirstCharacter = false }
            switch ascii {
            case UInt8(ascii: "A")...UInt8(ascii: "Z"),
                 UInt8(ascii: "a")...UInt8(ascii: "z"),
                 UInt8(ascii: "_"), UInt8(ascii: ":"):
                continue
            case UInt8(ascii: "0"), UInt8(ascii: "9"):
                if isFirstCharacter {
                    return false
                }
                continue
            default:
                return false
            }
        }
        return true
    }

    fileprivate func isValidLabelName() -> Bool {
        var isFirstCharacter = true
        for ascii in self.utf8 {
            defer { isFirstCharacter = false }
            switch ascii {
            case UInt8(ascii: "A")...UInt8(ascii: "Z"),
                 UInt8(ascii: "a")...UInt8(ascii: "z"),
                 UInt8(ascii: "_"):
                continue
            case UInt8(ascii: "0"), UInt8(ascii: "9"):
                if isFirstCharacter {
                    return false
                }
                continue
            default:
                return false
            }
        }
        return true
    }

    fileprivate func ensureValidMetricName() -> String {
        if self.isValidMetricName() {
            return self
        } else {
            var new = self
            new.fixPrometheusName(allowColon: true)
            return new
        }
    }

    fileprivate func ensureValidLabelName() -> String {
        if self.isValidLabelName() {
            return self
        } else {
            var new = self
            new.fixPrometheusName(allowColon: false)
            return new
        }
    }

    fileprivate mutating func fixPrometheusName(allowColon: Bool) {
        var startIndex = self.startIndex
        var isFirstCharacter = true
        while let fixIndex = self[startIndex...].firstIndex(where: { character in
            defer { isFirstCharacter = false }
            switch character {
            case "A"..."Z", "a"..."z", "_":
                return false
            case ":":
                return !allowColon
            case "0"..."9":
                return isFirstCharacter
            default:
                return true
            }
        }) {
            self.replaceSubrange(fixIndex...fixIndex, with: CollectionOfOne("_"))
            startIndex = fixIndex
            if startIndex == self.endIndex {
                break
            }
        }
    }
}
//===----------------------------------------------------------------------===//
//
// This source file is part of the SwiftPrometheus open source project
//
// Copyright (c) 2024 SwiftPrometheus project authors
// Licensed under Apache License v2.0
//
// See LICENSE.txt for license information
// See CONTRIBUTORS.txt for the list of SwiftPrometheus project authors
//
// SPDX-License-Identifier: Apache-2.0
//
//===----------------------------------------------------------------------===//

import Prometheus
import XCTest

final class ValidNamesTests: XCTestCase {
    func testCounterWithEmoji() {
        let client = PrometheusCollectorRegistry()
        let counter = client.makeCounter(name: "coffee☕️", labels: [])
        counter.increment()

        var buffer = [UInt8]()
        client.emit(into: &buffer)
        XCTAssertEqual(
            String(decoding: buffer, as: Unicode.UTF8.self),
            """
            # TYPE coffee_ counter
            coffee_ 1

            """
        )
    }

    func testIllegalMetricNames() async throws {
        let registry = PrometheusCollectorRegistry()

        /// Notably, newlines must not allow creating whole new metric root
        let tests = [
            "name",
            """
            name{bad="haha"} 121212121
            bad_bad 12321323
            """
        ]

        for test in tests {
            registry.makeCounter(
                name: test,
                labels: []
            ).increment()
        }

        var buffer = [UInt8]()
        registry.emit(into: &buffer)
        XCTAssertEqual(
            String(decoding: buffer, as: Unicode.UTF8.self).split(separator: "\n").sorted().joined(separator: "\n"),
            """
            # TYPE name counter
            # TYPE name_bad__haha___121212121_bad_bad_12321323 counter
            name 1
            name_bad__haha___121212121_bad_bad_12321323 1
            """
        )
    }

    func testIllegalLabelNames() async throws {
        let registry = PrometheusCollectorRegistry()

        let tests = [
            """
            name{bad="haha"} 121212121
            bad_bad 12321323
            """
        ]

        for test in tests {
            registry.makeCounter(
                name: "metric",
                labels: [(test, "value")]
            ).increment()
        }

        var buffer = [UInt8]()
        registry.emit(into: &buffer)
        XCTAssertEqual(
            String(decoding: buffer, as: Unicode.UTF8.self).split(separator: "\n").sorted().joined(separator: "\n"),
            """
            # TYPE metric counter
            metric{name_bad__haha___121212121_bad_bad_12321323="value"} 1
            """
        )
    }
}