From 50a17b626334bf6328ab3031773ce50c01132a85 Mon Sep 17 00:00:00 2001 From: Hampus Date: Mon, 31 Aug 2026 00:23:00 +0200 Subject: [PATCH] fix(metrics): reject non-loopback callers on metrics endpoints (#2222) --- fluxer_media_proxy/src/server.rs | 38 ++++++- fluxer_svc/src/server.rs | 48 ++++++++- packages/hono/src/middleware/Metrics.ts | 13 +++ .../hono/src/middleware/tests/Metrics.test.ts | 99 +++++++++++++++---- packages/ip_utils/src/IpAddress.ts | 24 +++++ .../ip_utils/src/__tests__/IpAddress.test.ts | 31 ++++++ 6 files changed, 231 insertions(+), 22 deletions(-) diff --git a/fluxer_media_proxy/src/server.rs b/fluxer_media_proxy/src/server.rs index 07c5924fb..f7aa0bcbe 100644 --- a/fluxer_media_proxy/src/server.rs +++ b/fluxer_media_proxy/src/server.rs @@ -20,7 +20,7 @@ use anyhow::Context as _; use axum::{ Router, body::{Body, to_bytes}, - extract::{Path, Query, State}, + extract::{ConnectInfo, Path, Query, State}, http::{HeaderMap, HeaderValue, Method, Request, StatusCode, header}, middleware, response::Response, @@ -206,7 +206,20 @@ async fn add_security_header_middleware( response } -async fn metrics_handler() -> Response { +fn is_loopback_peer(peer: &SocketAddr) -> bool { + peer.ip().to_canonical().is_loopback() +} + +async fn metrics_handler(ConnectInfo(peer): ConnectInfo) -> Response { + if !is_loopback_peer(&peer) { + let mut denied = Response::new(Body::from("FORBIDDEN")); + *denied.status_mut() = StatusCode::FORBIDDEN; + http_headers::add_security_headers(denied.headers_mut()); + denied + .headers_mut() + .insert(header::CONTENT_TYPE, HeaderValue::from_static("text/plain")); + return denied; + } let mut response = Response::new(Body::from(metrics::render())); http_headers::add_security_headers(response.headers_mut()); response.headers_mut().insert( @@ -3432,6 +3445,27 @@ mod tests { use super::*; use base64::engine::general_purpose::STANDARD; + fn test_peer(value: &str) -> SocketAddr { + value.parse().expect("valid socket address") + } + + #[test] + fn metrics_guard_accepts_loopback_peers() { + assert!(is_loopback_peer(&test_peer("127.0.0.1:5000"))); + assert!(is_loopback_peer(&test_peer("127.0.0.2:5000"))); + assert!(is_loopback_peer(&test_peer("[::1]:5000"))); + assert!(is_loopback_peer(&test_peer("[::ffff:127.0.0.1]:5000"))); + } + + #[test] + fn metrics_guard_rejects_remote_peers() { + assert!(!is_loopback_peer(&test_peer("8.8.8.8:5000"))); + assert!(!is_loopback_peer(&test_peer("10.0.0.5:5000"))); + assert!(!is_loopback_peer(&test_peer("172.18.0.4:5000"))); + assert!(!is_loopback_peer(&test_peer("[fe80::1]:5000"))); + assert!(!is_loopback_peer(&test_peer("[::ffff:8.8.8.8]:5000"))); + } + fn avatar_cache_key_for_requested_size(raw: &str) -> String { let size = constants::parse_image_size(Some(raw)); let selected = output_format::select_url_variant(output_format::Input { diff --git a/fluxer_svc/src/server.rs b/fluxer_svc/src/server.rs index 1eaa0f8ad..512835e4c 100644 --- a/fluxer_svc/src/server.rs +++ b/fluxer_svc/src/server.rs @@ -3,7 +3,7 @@ use crate::metrics::ServiceMetrics; use axum::Router; use axum::body::Body; -use axum::extract::{Request, State}; +use axum::extract::{ConnectInfo, Request, State}; use axum::http::{HeaderValue, StatusCode, header}; use axum::middleware::{self, Next}; use axum::response::{IntoResponse, Response}; @@ -39,7 +39,11 @@ pub async fn run_http( .layer(middleware::from_fn(add_version_header)); let listener = TcpListener::bind(addr).await?; tracing::info!(addr = %addr, "health HTTP server listening"); - axum::serve(listener, app).await?; + axum::serve( + listener, + app.into_make_service_with_connect_info::(), + ) + .await?; Ok(()) } @@ -51,7 +55,17 @@ async fn readiness_check(State(state): State) -> impl IntoResponse { } } -async fn metrics_handler(State(state): State) -> impl IntoResponse { +fn is_loopback_peer(peer: &SocketAddr) -> bool { + peer.ip().to_canonical().is_loopback() +} + +async fn metrics_handler( + ConnectInfo(peer): ConnectInfo, + State(state): State, +) -> Response { + if !is_loopback_peer(&peer) { + return (StatusCode::FORBIDDEN, "FORBIDDEN").into_response(); + } let body = state.metrics.render_prometheus(&state.service_name); ( [( @@ -60,6 +74,7 @@ async fn metrics_handler(State(state): State) -> impl IntoResponse { )], body, ) + .into_response() } fn build_version() -> &'static str { @@ -81,3 +96,30 @@ async fn add_version_header(request: Request, next: Next) -> Response { } response } + +#[cfg(test)] +mod tests { + use super::is_loopback_peer; + use std::net::SocketAddr; + + fn peer(value: &str) -> SocketAddr { + value.parse().expect("valid socket address") + } + + #[test] + fn accepts_loopback_peers() { + assert!(is_loopback_peer(&peer("127.0.0.1:5000"))); + assert!(is_loopback_peer(&peer("127.0.0.2:5000"))); + assert!(is_loopback_peer(&peer("[::1]:5000"))); + assert!(is_loopback_peer(&peer("[::ffff:127.0.0.1]:5000"))); + } + + #[test] + fn rejects_remote_peers() { + assert!(!is_loopback_peer(&peer("8.8.8.8:5000"))); + assert!(!is_loopback_peer(&peer("10.0.0.5:5000"))); + assert!(!is_loopback_peer(&peer("172.18.0.4:5000"))); + assert!(!is_loopback_peer(&peer("[fe80::1]:5000"))); + assert!(!is_loopback_peer(&peer("[::ffff:8.8.8.8]:5000"))); + } +} diff --git a/packages/hono/src/middleware/Metrics.ts b/packages/hono/src/middleware/Metrics.ts index 059e480e1..502a9f84e 100644 --- a/packages/hono/src/middleware/Metrics.ts +++ b/packages/hono/src/middleware/Metrics.ts @@ -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 | 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'), diff --git a/packages/hono/src/middleware/tests/Metrics.test.ts b/packages/hono/src/middleware/tests/Metrics.test.ts index 8d56de2c9..abe48c3f6 100644 --- a/packages/hono/src/middleware/tests/Metrics.test.ts +++ b/packages/hono/src/middleware/tests/Metrics.test.ts @@ -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'); diff --git a/packages/ip_utils/src/IpAddress.ts b/packages/ip_utils/src/IpAddress.ts index fe9170a08..87be50517 100644 --- a/packages/ip_utils/src/IpAddress.ts +++ b/packages/ip_utils/src/IpAddress.ts @@ -316,6 +316,30 @@ export function isPublicIpAddress(ip: string): boolean { return isPublicIpv6Address(parsed.normalized); } +function isLoopbackIpv4Address(address: string): boolean { + const octets = parseIpv4Octets(address); + if (!octets) { + return false; + } + return isIpv4InCidr(octets, 0x7f000000, 8); +} + +export function isLoopbackIpAddress(ip: string): boolean { + const parsed = parseIpAddress(ip); + if (!parsed) { + return false; + } + if (parsed.family === 'ipv4') { + return isLoopbackIpv4Address(parsed.normalized); + } + const mappedIpv4 = getIpv4MappedIpv6(parsed.normalized); + if (mappedIpv4) { + return isLoopbackIpv4Address(mappedIpv4); + } + const groups = expandIpv6ToGroups(parsed.normalized); + return groups.length === 8 && groups.slice(0, 7).every((group) => group === '0000') && groups[7] === '0001'; +} + export function isSameIpDecisionMatch(left: string | null | undefined, right: string | null | undefined): boolean { if (!left || !right) { return false; diff --git a/packages/ip_utils/src/__tests__/IpAddress.test.ts b/packages/ip_utils/src/__tests__/IpAddress.test.ts index fda8ddbbc..4f4e60c8b 100644 --- a/packages/ip_utils/src/__tests__/IpAddress.test.ts +++ b/packages/ip_utils/src/__tests__/IpAddress.test.ts @@ -4,6 +4,7 @@ import { getIpNetworkKey, getSameIpDecisionKey, getSubnet, + isLoopbackIpAddress, isPublicIpAddress, isSameIpDecisionMatch, isValidIp, @@ -119,6 +120,36 @@ describe('maskIpForDisplay', () => { }); }); +describe('isLoopbackIpAddress', () => { + it('accepts IPv4 loopback addresses', () => { + expect(isLoopbackIpAddress('127.0.0.1')).toBe(true); + expect(isLoopbackIpAddress('127.0.0.2')).toBe(true); + expect(isLoopbackIpAddress('127.255.255.254')).toBe(true); + }); + it('accepts IPv6 loopback and its IPv4-mapped form', () => { + expect(isLoopbackIpAddress('::1')).toBe(true); + expect(isLoopbackIpAddress('[::1]')).toBe(true); + expect(isLoopbackIpAddress('0:0:0:0:0:0:0:1')).toBe(true); + expect(isLoopbackIpAddress('::ffff:127.0.0.1')).toBe(true); + expect(isLoopbackIpAddress('::ffff:7f00:1')).toBe(true); + }); + it('rejects non-loopback addresses', () => { + expect(isLoopbackIpAddress('8.8.8.8')).toBe(false); + expect(isLoopbackIpAddress('10.0.0.5')).toBe(false); + expect(isLoopbackIpAddress('172.18.0.4')).toBe(false); + expect(isLoopbackIpAddress('192.168.1.1')).toBe(false); + expect(isLoopbackIpAddress('128.0.0.1')).toBe(false); + expect(isLoopbackIpAddress('126.255.255.255')).toBe(false); + expect(isLoopbackIpAddress('::2')).toBe(false); + expect(isLoopbackIpAddress('fe80::1')).toBe(false); + expect(isLoopbackIpAddress('::ffff:8.8.8.8')).toBe(false); + }); + it('rejects invalid values', () => { + expect(isLoopbackIpAddress('not-an-ip')).toBe(false); + expect(isLoopbackIpAddress('')).toBe(false); + }); +}); + describe('isPublicIpAddress', () => { it('accepts public unicast addresses', () => { expect(isPublicIpAddress('8.8.8.8')).toBe(true);