mirror of
https://github.com/fluxerapp/fluxer.git
synced 2026-09-02 21:04:06 +03:00
fix(metrics): reject non-loopback callers on metrics endpoints (#2222)
This commit is contained in:
@@ -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<SocketAddr>) -> 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 {
|
||||
|
||||
@@ -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::<SocketAddr>(),
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -51,7 +55,17 @@ async fn readiness_check(State(state): State<HttpState>) -> impl IntoResponse {
|
||||
}
|
||||
}
|
||||
|
||||
async fn metrics_handler(State(state): State<HttpState>) -> impl IntoResponse {
|
||||
fn is_loopback_peer(peer: &SocketAddr) -> bool {
|
||||
peer.ip().to_canonical().is_loopback()
|
||||
}
|
||||
|
||||
async fn metrics_handler(
|
||||
ConnectInfo(peer): ConnectInfo<SocketAddr>,
|
||||
State(state): State<HttpState>,
|
||||
) -> 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<HttpState>) -> impl IntoResponse {
|
||||
)],
|
||||
body,
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
fn build_version() -> &'static str {
|
||||
@@ -81,3 +96,30 @@ async fn add_version_header(request: Request<Body>, 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")));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user