CVE-2024-28867 (Swift, published 2024-03-29T15:15Z)
Description: 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.
Severity: nan (CVSS v3 base score: )
Weakness: NVD-CWE-noinfo \u2014 Insufficient Information
There is insufficient information about the issue to classify it; details are unkown or unspecified.
Repository: https://github.com/swift-server/swift-prometheus
Files changed:
Sources/Prometheus/PrometheusCollectorRegistry.swift
Tests/PrometheusTests/ValidNamesTests.swift
Commit message: Merge pull request from GHSA-x768-cvr2-345r



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
            """
        )
    }
}