fix(metrics): reject non-loopback callers on metrics endpoints (#2222)

This commit is contained in:
Hampus authored and GitHub committed 2026-08-31 00:23:00 +02:00
1 parent 0a920def2b
commit 50a17b6263
6 files changed
+231 -22

No files matched your search

+13
View File
@@ -1,9 +1,19 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {isLoopbackIpAddress} from '@fluxer/ip_utils/src/IpAddress';
import type {HttpBindings} from '@hono/node-server';
import type {Handler, MiddlewareHandler} from 'hono';
const SKIP_PATHS = new Set(['/_health', '/_healthz', '/_metrics']);
function isLoopbackPeer(env: unknown): boolean {
const remoteAddress = (env as Partial<HttpBindings> | null | undefined)?.incoming?.socket?.remoteAddress;
if (typeof remoteAddress !== 'string' || remoteAddress === '') {
return false;
}
return isLoopbackIpAddress(remoteAddress);
}
const DEFAULT_BUCKETS = [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10];
function statusClass(status: number): string {
@@ -140,6 +150,9 @@ export function createMetricsMiddleware(serviceName: string): MetricsResult {
};
const metricsHandler: Handler = (c) => {
if (!isLoopbackPeer(c.env)) {
return c.text('FORBIDDEN', 403, {'Content-Type': 'text/plain'});
}
const sections = [
requestsTotal.render(`${prefix}_http_requests_total`, 'Total HTTP requests'),
requestDuration.render(`${prefix}_http_request_duration_seconds`, 'HTTP request duration in seconds'),
@@ -4,6 +4,10 @@ import {createMetricsMiddleware} from '@fluxer/hono/src/middleware/Metrics';
import {Hono} from 'hono';
import {describe, expect, test} from 'vitest';
function requestMetrics(app: Hono, remoteAddress = '127.0.0.1') {
return app.request('/_metrics', undefined, {incoming: {socket: {remoteAddress}}});
}
function createTestApp() {
const {middleware, metricsHandler, state} = createMetricsMiddleware('test');
const app = new Hono();
@@ -24,7 +28,7 @@ describe('Metrics Middleware', () => {
const {app} = createTestApp();
await app.request('/users');
await app.request('/users');
const res = await app.request('/_metrics');
const res = await requestMetrics(app);
const body = await res.text();
expect(body).toContain('fluxer_test_http_requests_total{method="GET",status="2xx"} 2');
});
@@ -33,7 +37,7 @@ describe('Metrics Middleware', () => {
const {app} = createTestApp();
await app.request('/users');
await app.request('/users', {method: 'POST'});
const res = await app.request('/_metrics');
const res = await requestMetrics(app);
const body = await res.text();
expect(body).toContain('fluxer_test_http_requests_total{method="GET",status="2xx"} 1');
expect(body).toContain('fluxer_test_http_requests_total{method="POST",status="2xx"} 1');
@@ -44,7 +48,7 @@ describe('Metrics Middleware', () => {
await app.request('/users');
await app.request('/bad');
await app.request('/error');
const res = await app.request('/_metrics');
const res = await requestMetrics(app);
const body = await res.text();
expect(body).toContain('status="2xx"');
expect(body).toContain('status="4xx"');
@@ -57,7 +61,7 @@ describe('Metrics Middleware', () => {
const {app} = createTestApp();
await app.request('/error');
await app.request('/error');
const res = await app.request('/_metrics');
const res = await requestMetrics(app);
const body = await res.text();
expect(body).toContain('fluxer_test_http_errors_total{method="GET"} 2');
});
@@ -65,7 +69,7 @@ describe('Metrics Middleware', () => {
test('does not count 4xx as errors', async () => {
const {app} = createTestApp();
await app.request('/bad');
const res = await app.request('/_metrics');
const res = await requestMetrics(app);
const body = await res.text();
expect(body).not.toContain('fluxer_test_http_errors_total{method="GET"}');
});
@@ -75,7 +79,7 @@ describe('Metrics Middleware', () => {
test('records request duration', async () => {
const {app} = createTestApp();
await app.request('/users');
const res = await app.request('/_metrics');
const res = await requestMetrics(app);
const body = await res.text();
expect(body).toContain('fluxer_test_http_request_duration_seconds_count 1');
expect(body).toContain('fluxer_test_http_request_duration_seconds_sum');
@@ -88,7 +92,7 @@ describe('Metrics Middleware', () => {
await app.request('/users');
await app.request('/users');
await app.request('/users');
const res = await app.request('/_metrics');
const res = await requestMetrics(app);
const body = await res.text();
expect(body).toContain('fluxer_test_http_request_duration_seconds_count 3');
});
@@ -97,7 +101,7 @@ describe('Metrics Middleware', () => {
describe('uptime gauge', () => {
test('reports uptime in seconds', async () => {
const {app} = createTestApp();
const res = await app.request('/_metrics');
const res = await requestMetrics(app);
const body = await res.text();
expect(body).toContain('# TYPE fluxer_test_uptime_seconds gauge');
expect(body).toMatch(/fluxer_test_uptime_seconds \d/);
@@ -109,7 +113,7 @@ describe('Metrics Middleware', () => {
const {app} = createTestApp();
await app.request('/_health');
await app.request('/_health');
const res = await app.request('/_metrics');
const res = await requestMetrics(app);
const body = await res.text();
expect(body).not.toContain('method="GET",status="2xx"');
});
@@ -117,14 +121,14 @@ describe('Metrics Middleware', () => {
test('skips /_healthz requests', async () => {
const {app} = createTestApp();
await app.request('/_healthz');
const res = await app.request('/_metrics');
const res = await requestMetrics(app);
const body = await res.text();
expect(body).not.toContain('method="GET",status="2xx"');
});
test('skips /_metrics requests', async () => {
const {app} = createTestApp();
const res = await app.request('/_metrics');
const res = await requestMetrics(app);
const body = await res.text();
expect(body).not.toContain('method="GET",status="2xx"');
});
@@ -132,7 +136,7 @@ describe('Metrics Middleware', () => {
test('does not skip normal paths', async () => {
const {app} = createTestApp();
await app.request('/users');
const res = await app.request('/_metrics');
const res = await requestMetrics(app);
const body = await res.text();
expect(body).toContain('method="GET",status="2xx"');
});
@@ -141,19 +145,19 @@ describe('Metrics Middleware', () => {
describe('metrics endpoint', () => {
test('returns correct content type', async () => {
const {app} = createTestApp();
const res = await app.request('/_metrics');
const res = await requestMetrics(app);
expect(res.headers.get('Content-Type')).toBe('text/plain; version=0.0.4; charset=utf-8');
});
test('returns 200 status', async () => {
const {app} = createTestApp();
const res = await app.request('/_metrics');
const res = await requestMetrics(app);
expect(res.status).toBe(200);
});
test('includes HELP and TYPE annotations', async () => {
const {app} = createTestApp();
const res = await app.request('/_metrics');
const res = await requestMetrics(app);
const body = await res.text();
expect(body).toContain('# HELP fluxer_test_http_requests_total Total HTTP requests');
expect(body).toContain('# TYPE fluxer_test_http_requests_total counter');
@@ -167,13 +171,74 @@ describe('Metrics Middleware', () => {
test('renders default counter value when no requests made', async () => {
const {app} = createTestApp();
const res = await app.request('/_metrics');
const res = await requestMetrics(app);
const body = await res.text();
expect(body).toContain('fluxer_test_http_requests_total 0');
expect(body).toContain('fluxer_test_http_errors_total 0');
});
});
describe('loopback restriction', () => {
test('serves metrics to an IPv4 loopback peer', async () => {
const {app} = createTestApp();
const res = await requestMetrics(app, '127.0.0.1');
expect(res.status).toBe(200);
});
test('serves metrics to any 127.0.0.0/8 peer', async () => {
const {app} = createTestApp();
const res = await requestMetrics(app, '127.0.0.2');
expect(res.status).toBe(200);
});
test('serves metrics to an IPv6 loopback peer', async () => {
const {app} = createTestApp();
const res = await requestMetrics(app, '::1');
expect(res.status).toBe(200);
});
test('serves metrics to an IPv4-mapped loopback peer', async () => {
const {app} = createTestApp();
const res = await requestMetrics(app, '::ffff:127.0.0.1');
expect(res.status).toBe(200);
});
test('rejects a public peer', async () => {
const {app} = createTestApp();
const res = await requestMetrics(app, '8.8.8.8');
expect(res.status).toBe(403);
expect(await res.text()).toBe('FORBIDDEN');
});
test('rejects a container network peer', async () => {
const {app} = createTestApp();
const res = await requestMetrics(app, '172.18.0.4');
expect(res.status).toBe(403);
});
test('rejects a request with no peer address', async () => {
const {app} = createTestApp();
const res = await app.request('/_metrics', undefined, {incoming: {socket: {}}});
expect(res.status).toBe(403);
});
test('rejects a request with no node bindings', async () => {
const {app} = createTestApp();
const res = await app.request('/_metrics', undefined, {});
expect(res.status).toBe(403);
});
test('ignores a forged x-forwarded-for header', async () => {
const {app} = createTestApp();
const res = await app.request(
'/_metrics',
{headers: {'x-forwarded-for': '127.0.0.1'}},
{incoming: {socket: {remoteAddress: '203.0.113.9'}}},
);
expect(res.status).toBe(403);
});
});
describe('service name prefix', () => {
test('uses provided service name in metric names', async () => {
const {middleware, metricsHandler} = createMetricsMiddleware('gateway');
@@ -182,7 +247,7 @@ describe('Metrics Middleware', () => {
app.get('/_metrics', metricsHandler);
app.get('/test', (c) => c.json({ok: true}));
await app.request('/test');
const res = await app.request('/_metrics');
const res = await requestMetrics(app);
const body = await res.text();
expect(body).toContain('fluxer_gateway_http_requests_total');
expect(body).toContain('fluxer_gateway_http_request_duration_seconds');