Skip to content

Commit 8fa97cc

Browse files
authored
fix: Send Prometheus host credentials as a Basic auth header in the proxy
proxyToPrometheus passed the connection host to fetch with its userinfo, which fetch rejects, so every PromQL query to a host like http://user:pass@prometheus:9090 failed with a 502. The ClickHouse Prometheus API probe had the same problem. Both now use the header that the MCP label lookup sends.
1 parent b365d4c commit 8fa97cc

4 files changed

Lines changed: 29 additions & 11 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@hyperdx/api': patch
3+
---
4+
5+
fix: Authenticate to Prometheus connections whose host includes credentials
6+
7+
A Prometheus connection host such as `http://user:pass@prometheus:9090` made every PromQL query fail with "Failed to reach Prometheus", because `fetch` rejects URLs that carry credentials. The credentials are now sent as a Basic auth header.

‎packages/api/src/controllers/promql.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ export function joinPrometheusUpstreamUrl(
5050
* Removes `user:pass@` from `url` and returns it as a Basic auth header. fetch
5151
* rejects a URL that carries credentials, so they cannot stay in the host.
5252
*/
53-
function takeBasicAuthHeader(url: URL): Record<string, string> {
53+
export function takeBasicAuthHeader(url: URL): Record<string, string> {
5454
if (!url.username && !url.password) return {};
5555
const credentials = `${decodeURIComponent(url.username)}:${decodeURIComponent(url.password)}`;
5656
url.username = '';

‎packages/api/src/routers/api/__tests__/prometheus.int.test.ts‎

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -517,10 +517,9 @@ describe('prometheus router', () => {
517517
});
518518

519519
// Every other userinfo test asserts what's redacted for *display* (the
520-
// error bodies below). The actual outgoing `fetch` target must still
521-
// carry real credentials, or authenticated Prometheus connections would
522-
// silently stop authenticating.
523-
it('still sends real credentials to the actual upstream fetch call', async () => {
520+
// error bodies below). The outgoing request must still carry the real
521+
// credentials, as a header: fetch rejects a URL that includes them.
522+
it('sends host credentials to the upstream as a Basic auth header', async () => {
524523
const { agent, team } = await getLoggedInAgent(server);
525524
const source = await seedPrometheusSource(
526525
team._id,
@@ -545,7 +544,11 @@ describe('prometheus router', () => {
545544
})
546545
.expect(200);
547546

548-
expect(mockFetch.mock.calls[0][0] as string).toContain('user:pw@');
547+
const [target, init] = mockFetch.mock.calls[0];
548+
expect(new URL(String(target)).username).toBe('');
549+
expect(init?.headers).toEqual({
550+
Authorization: `Basic ${Buffer.from('user:pw').toString('base64')}`,
551+
});
549552
});
550553

551554
it('leaves a host-pinned param untouched when the request never references it', async () => {

‎packages/api/src/routers/api/prometheus.ts‎

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,10 @@ import z from 'zod';
1818

1919
import { ClickhouseClient } from '@/clickhouse';
2020
import { getConnectionById } from '@/controllers/connection';
21-
import { joinPrometheusUpstreamUrl } from '@/controllers/promql';
21+
import {
22+
joinPrometheusUpstreamUrl,
23+
takeBasicAuthHeader,
24+
} from '@/controllers/promql';
2225
import { getSource } from '@/controllers/sources';
2326
import {
2427
connectionSupportsPrometheusHttpApi,
@@ -346,7 +349,10 @@ async function clickhouseServesPrometheusHttpApi(
346349
);
347350
url.searchParams.set('query', 'up');
348351
const resp = await fetch(url, {
349-
headers: clickhouseAuthHeaders(connection),
352+
headers: {
353+
...takeBasicAuthHeader(url),
354+
...clickhouseAuthHeaders(connection),
355+
},
350356
signal: AbortSignal.timeout(PROBE_TIMEOUT_MS),
351357
});
352358
return typeof JSON.parse(await resp.text())?.status === 'string';
@@ -424,6 +430,9 @@ async function proxyToPrometheus(
424430
url.searchParams.set(k, v);
425431
}
426432
}
433+
// fetch rejects a URL that carries credentials, so `user:pw@` in the host
434+
// becomes a Basic auth header instead.
435+
const authHeader = takeBasicAuthHeader(url);
427436
const target = url.toString();
428437

429438
// A connection host may carry basic-auth credentials (`http://user:pw@host`)
@@ -433,14 +442,13 @@ async function proxyToPrometheus(
433442
// the browser. `url.origin` never includes userinfo (WHATWG URL spec), and
434443
// dropping the query/hash entirely -- rather than trying to redact only the
435444
// secret-shaped parts of it -- means there's no query key to ever miss.
436-
// `target` itself is unaffected and still carries everything needed to
437-
// authenticate.
445+
// `target` still carries the query string, and `authHeader` the userinfo.
438446
const redactedTarget = `${url.origin}${url.pathname}`;
439447

440448
let upstreamResp: Response;
441449
try {
442450
upstreamResp = await fetch(target, {
443-
headers,
451+
headers: { ...authHeader, ...headers },
444452
signal: AbortSignal.timeout(PROMETHEUS_PROXY_TIMEOUT_MS),
445453
});
446454
} catch (err) {

0 commit comments

Comments
 (0)