Skip to content

Commit b8cfb96

Browse files
committed
fix(metrics): bind the metrics endpoints to loopback only
1 parent a22db12 commit b8cfb96

6 files changed

Lines changed: 231 additions & 22 deletions

File tree

fluxer_media_proxy/src/server.rs

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ use anyhow::Context as _;
2020
use axum::{
2121
Router,
2222
body::{Body, to_bytes},
23-
extract::{Path, Query, State},
23+
extract::{ConnectInfo, Path, Query, State},
2424
http::{HeaderMap, HeaderValue, Method, Request, StatusCode, header},
2525
middleware,
2626
response::Response,
@@ -206,7 +206,20 @@ async fn add_security_header_middleware(
206206
response
207207
}
208208

209-
async fn metrics_handler() -> Response {
209+
fn is_loopback_peer(peer: &SocketAddr) -> bool {
210+
peer.ip().to_canonical().is_loopback()
211+
}
212+
213+
async fn metrics_handler(ConnectInfo(peer): ConnectInfo<SocketAddr>) -> Response {
214+
if !is_loopback_peer(&peer) {
215+
let mut denied = Response::new(Body::from("FORBIDDEN"));
216+
*denied.status_mut() = StatusCode::FORBIDDEN;
217+
http_headers::add_security_headers(denied.headers_mut());
218+
denied
219+
.headers_mut()
220+
.insert(header::CONTENT_TYPE, HeaderValue::from_static("text/plain"));
221+
return denied;
222+
}
210223
let mut response = Response::new(Body::from(metrics::render()));
211224
http_headers::add_security_headers(response.headers_mut());
212225
response.headers_mut().insert(
@@ -3432,6 +3445,27 @@ mod tests {
34323445
use super::*;
34333446
use base64::engine::general_purpose::STANDARD;
34343447

3448+
fn test_peer(value: &str) -> SocketAddr {
3449+
value.parse().expect("valid socket address")
3450+
}
3451+
3452+
#[test]
3453+
fn metrics_guard_accepts_loopback_peers() {
3454+
assert!(is_loopback_peer(&test_peer("127.0.0.1:5000")));
3455+
assert!(is_loopback_peer(&test_peer("127.0.0.2:5000")));
3456+
assert!(is_loopback_peer(&test_peer("[::1]:5000")));
3457+
assert!(is_loopback_peer(&test_peer("[::ffff:127.0.0.1]:5000")));
3458+
}
3459+
3460+
#[test]
3461+
fn metrics_guard_rejects_remote_peers() {
3462+
assert!(!is_loopback_peer(&test_peer("8.8.8.8:5000")));
3463+
assert!(!is_loopback_peer(&test_peer("10.0.0.5:5000")));
3464+
assert!(!is_loopback_peer(&test_peer("172.18.0.4:5000")));
3465+
assert!(!is_loopback_peer(&test_peer("[fe80::1]:5000")));
3466+
assert!(!is_loopback_peer(&test_peer("[::ffff:8.8.8.8]:5000")));
3467+
}
3468+
34353469
fn avatar_cache_key_for_requested_size(raw: &str) -> String {
34363470
let size = constants::parse_image_size(Some(raw));
34373471
let selected = output_format::select_url_variant(output_format::Input {

fluxer_svc/src/server.rs

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
use crate::metrics::ServiceMetrics;
44
use axum::Router;
55
use axum::body::Body;
6-
use axum::extract::{Request, State};
6+
use axum::extract::{ConnectInfo, Request, State};
77
use axum::http::{HeaderValue, StatusCode, header};
88
use axum::middleware::{self, Next};
99
use axum::response::{IntoResponse, Response};
@@ -39,7 +39,11 @@ pub async fn run_http(
3939
.layer(middleware::from_fn(add_version_header));
4040
let listener = TcpListener::bind(addr).await?;
4141
tracing::info!(addr = %addr, "health HTTP server listening");
42-
axum::serve(listener, app).await?;
42+
axum::serve(
43+
listener,
44+
app.into_make_service_with_connect_info::<SocketAddr>(),
45+
)
46+
.await?;
4347
Ok(())
4448
}
4549

@@ -51,7 +55,17 @@ async fn readiness_check(State(state): State<HttpState>) -> impl IntoResponse {
5155
}
5256
}
5357

54-
async fn metrics_handler(State(state): State<HttpState>) -> impl IntoResponse {
58+
fn is_loopback_peer(peer: &SocketAddr) -> bool {
59+
peer.ip().to_canonical().is_loopback()
60+
}
61+
62+
async fn metrics_handler(
63+
ConnectInfo(peer): ConnectInfo<SocketAddr>,
64+
State(state): State<HttpState>,
65+
) -> Response {
66+
if !is_loopback_peer(&peer) {
67+
return (StatusCode::FORBIDDEN, "FORBIDDEN").into_response();
68+
}
5569
let body = state.metrics.render_prometheus(&state.service_name);
5670
(
5771
[(
@@ -60,6 +74,7 @@ async fn metrics_handler(State(state): State<HttpState>) -> impl IntoResponse {
6074
)],
6175
body,
6276
)
77+
.into_response()
6378
}
6479

6580
fn build_version() -> &'static str {
@@ -81,3 +96,30 @@ async fn add_version_header(request: Request<Body>, next: Next) -> Response {
8196
}
8297
response
8398
}
99+
100+
#[cfg(test)]
101+
mod tests {
102+
use super::is_loopback_peer;
103+
use std::net::SocketAddr;
104+
105+
fn peer(value: &str) -> SocketAddr {
106+
value.parse().expect("valid socket address")
107+
}
108+
109+
#[test]
110+
fn accepts_loopback_peers() {
111+
assert!(is_loopback_peer(&peer("127.0.0.1:5000")));
112+
assert!(is_loopback_peer(&peer("127.0.0.2:5000")));
113+
assert!(is_loopback_peer(&peer("[::1]:5000")));
114+
assert!(is_loopback_peer(&peer("[::ffff:127.0.0.1]:5000")));
115+
}
116+
117+
#[test]
118+
fn rejects_remote_peers() {
119+
assert!(!is_loopback_peer(&peer("8.8.8.8:5000")));
120+
assert!(!is_loopback_peer(&peer("10.0.0.5:5000")));
121+
assert!(!is_loopback_peer(&peer("172.18.0.4:5000")));
122+
assert!(!is_loopback_peer(&peer("[fe80::1]:5000")));
123+
assert!(!is_loopback_peer(&peer("[::ffff:8.8.8.8]:5000")));
124+
}
125+
}

packages/hono/src/middleware/Metrics.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,19 @@
11
// SPDX-License-Identifier: AGPL-3.0-or-later
22

3+
import {isLoopbackIpAddress} from '@fluxer/ip_utils/src/IpAddress';
4+
import type {HttpBindings} from '@hono/node-server';
35
import type {Handler, MiddlewareHandler} from 'hono';
46

57
const SKIP_PATHS = new Set(['/_health', '/_healthz', '/_metrics']);
68

9+
function isLoopbackPeer(env: unknown): boolean {
10+
const remoteAddress = (env as Partial<HttpBindings> | null | undefined)?.incoming?.socket?.remoteAddress;
11+
if (typeof remoteAddress !== 'string' || remoteAddress === '') {
12+
return false;
13+
}
14+
return isLoopbackIpAddress(remoteAddress);
15+
}
16+
717
const DEFAULT_BUCKETS = [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10];
818

919
function statusClass(status: number): string {
@@ -140,6 +150,9 @@ export function createMetricsMiddleware(serviceName: string): MetricsResult {
140150
};
141151

142152
const metricsHandler: Handler = (c) => {
153+
if (!isLoopbackPeer(c.env)) {
154+
return c.text('FORBIDDEN', 403, {'Content-Type': 'text/plain'});
155+
}
143156
const sections = [
144157
requestsTotal.render(`${prefix}_http_requests_total`, 'Total HTTP requests'),
145158
requestDuration.render(`${prefix}_http_request_duration_seconds`, 'HTTP request duration in seconds'),

packages/hono/src/middleware/tests/Metrics.test.ts

Lines changed: 82 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@ import {createMetricsMiddleware} from '@fluxer/hono/src/middleware/Metrics';
44
import {Hono} from 'hono';
55
import {describe, expect, test} from 'vitest';
66

7+
function requestMetrics(app: Hono, remoteAddress = '127.0.0.1') {
8+
return app.request('/_metrics', undefined, {incoming: {socket: {remoteAddress}}});
9+
}
10+
711
function createTestApp() {
812
const {middleware, metricsHandler, state} = createMetricsMiddleware('test');
913
const app = new Hono();
@@ -24,7 +28,7 @@ describe('Metrics Middleware', () => {
2428
const {app} = createTestApp();
2529
await app.request('/users');
2630
await app.request('/users');
27-
const res = await app.request('/_metrics');
31+
const res = await requestMetrics(app);
2832
const body = await res.text();
2933
expect(body).toContain('fluxer_test_http_requests_total{method="GET",status="2xx"} 2');
3034
});
@@ -33,7 +37,7 @@ describe('Metrics Middleware', () => {
3337
const {app} = createTestApp();
3438
await app.request('/users');
3539
await app.request('/users', {method: 'POST'});
36-
const res = await app.request('/_metrics');
40+
const res = await requestMetrics(app);
3741
const body = await res.text();
3842
expect(body).toContain('fluxer_test_http_requests_total{method="GET",status="2xx"} 1');
3943
expect(body).toContain('fluxer_test_http_requests_total{method="POST",status="2xx"} 1');
@@ -44,7 +48,7 @@ describe('Metrics Middleware', () => {
4448
await app.request('/users');
4549
await app.request('/bad');
4650
await app.request('/error');
47-
const res = await app.request('/_metrics');
51+
const res = await requestMetrics(app);
4852
const body = await res.text();
4953
expect(body).toContain('status="2xx"');
5054
expect(body).toContain('status="4xx"');
@@ -57,15 +61,15 @@ describe('Metrics Middleware', () => {
5761
const {app} = createTestApp();
5862
await app.request('/error');
5963
await app.request('/error');
60-
const res = await app.request('/_metrics');
64+
const res = await requestMetrics(app);
6165
const body = await res.text();
6266
expect(body).toContain('fluxer_test_http_errors_total{method="GET"} 2');
6367
});
6468

6569
test('does not count 4xx as errors', async () => {
6670
const {app} = createTestApp();
6771
await app.request('/bad');
68-
const res = await app.request('/_metrics');
72+
const res = await requestMetrics(app);
6973
const body = await res.text();
7074
expect(body).not.toContain('fluxer_test_http_errors_total{method="GET"}');
7175
});
@@ -75,7 +79,7 @@ describe('Metrics Middleware', () => {
7579
test('records request duration', async () => {
7680
const {app} = createTestApp();
7781
await app.request('/users');
78-
const res = await app.request('/_metrics');
82+
const res = await requestMetrics(app);
7983
const body = await res.text();
8084
expect(body).toContain('fluxer_test_http_request_duration_seconds_count 1');
8185
expect(body).toContain('fluxer_test_http_request_duration_seconds_sum');
@@ -88,7 +92,7 @@ describe('Metrics Middleware', () => {
8892
await app.request('/users');
8993
await app.request('/users');
9094
await app.request('/users');
91-
const res = await app.request('/_metrics');
95+
const res = await requestMetrics(app);
9296
const body = await res.text();
9397
expect(body).toContain('fluxer_test_http_request_duration_seconds_count 3');
9498
});
@@ -97,7 +101,7 @@ describe('Metrics Middleware', () => {
97101
describe('uptime gauge', () => {
98102
test('reports uptime in seconds', async () => {
99103
const {app} = createTestApp();
100-
const res = await app.request('/_metrics');
104+
const res = await requestMetrics(app);
101105
const body = await res.text();
102106
expect(body).toContain('# TYPE fluxer_test_uptime_seconds gauge');
103107
expect(body).toMatch(/fluxer_test_uptime_seconds \d/);
@@ -109,30 +113,30 @@ describe('Metrics Middleware', () => {
109113
const {app} = createTestApp();
110114
await app.request('/_health');
111115
await app.request('/_health');
112-
const res = await app.request('/_metrics');
116+
const res = await requestMetrics(app);
113117
const body = await res.text();
114118
expect(body).not.toContain('method="GET",status="2xx"');
115119
});
116120

117121
test('skips /_healthz requests', async () => {
118122
const {app} = createTestApp();
119123
await app.request('/_healthz');
120-
const res = await app.request('/_metrics');
124+
const res = await requestMetrics(app);
121125
const body = await res.text();
122126
expect(body).not.toContain('method="GET",status="2xx"');
123127
});
124128

125129
test('skips /_metrics requests', async () => {
126130
const {app} = createTestApp();
127-
const res = await app.request('/_metrics');
131+
const res = await requestMetrics(app);
128132
const body = await res.text();
129133
expect(body).not.toContain('method="GET",status="2xx"');
130134
});
131135

132136
test('does not skip normal paths', async () => {
133137
const {app} = createTestApp();
134138
await app.request('/users');
135-
const res = await app.request('/_metrics');
139+
const res = await requestMetrics(app);
136140
const body = await res.text();
137141
expect(body).toContain('method="GET",status="2xx"');
138142
});
@@ -141,19 +145,19 @@ describe('Metrics Middleware', () => {
141145
describe('metrics endpoint', () => {
142146
test('returns correct content type', async () => {
143147
const {app} = createTestApp();
144-
const res = await app.request('/_metrics');
148+
const res = await requestMetrics(app);
145149
expect(res.headers.get('Content-Type')).toBe('text/plain; version=0.0.4; charset=utf-8');
146150
});
147151

148152
test('returns 200 status', async () => {
149153
const {app} = createTestApp();
150-
const res = await app.request('/_metrics');
154+
const res = await requestMetrics(app);
151155
expect(res.status).toBe(200);
152156
});
153157

154158
test('includes HELP and TYPE annotations', async () => {
155159
const {app} = createTestApp();
156-
const res = await app.request('/_metrics');
160+
const res = await requestMetrics(app);
157161
const body = await res.text();
158162
expect(body).toContain('# HELP fluxer_test_http_requests_total Total HTTP requests');
159163
expect(body).toContain('# TYPE fluxer_test_http_requests_total counter');
@@ -167,13 +171,74 @@ describe('Metrics Middleware', () => {
167171

168172
test('renders default counter value when no requests made', async () => {
169173
const {app} = createTestApp();
170-
const res = await app.request('/_metrics');
174+
const res = await requestMetrics(app);
171175
const body = await res.text();
172176
expect(body).toContain('fluxer_test_http_requests_total 0');
173177
expect(body).toContain('fluxer_test_http_errors_total 0');
174178
});
175179
});
176180

181+
describe('loopback restriction', () => {
182+
test('serves metrics to an IPv4 loopback peer', async () => {
183+
const {app} = createTestApp();
184+
const res = await requestMetrics(app, '127.0.0.1');
185+
expect(res.status).toBe(200);
186+
});
187+
188+
test('serves metrics to any 127.0.0.0/8 peer', async () => {
189+
const {app} = createTestApp();
190+
const res = await requestMetrics(app, '127.0.0.2');
191+
expect(res.status).toBe(200);
192+
});
193+
194+
test('serves metrics to an IPv6 loopback peer', async () => {
195+
const {app} = createTestApp();
196+
const res = await requestMetrics(app, '::1');
197+
expect(res.status).toBe(200);
198+
});
199+
200+
test('serves metrics to an IPv4-mapped loopback peer', async () => {
201+
const {app} = createTestApp();
202+
const res = await requestMetrics(app, '::ffff:127.0.0.1');
203+
expect(res.status).toBe(200);
204+
});
205+
206+
test('rejects a public peer', async () => {
207+
const {app} = createTestApp();
208+
const res = await requestMetrics(app, '8.8.8.8');
209+
expect(res.status).toBe(403);
210+
expect(await res.text()).toBe('FORBIDDEN');
211+
});
212+
213+
test('rejects a container network peer', async () => {
214+
const {app} = createTestApp();
215+
const res = await requestMetrics(app, '172.18.0.4');
216+
expect(res.status).toBe(403);
217+
});
218+
219+
test('rejects a request with no peer address', async () => {
220+
const {app} = createTestApp();
221+
const res = await app.request('/_metrics', undefined, {incoming: {socket: {}}});
222+
expect(res.status).toBe(403);
223+
});
224+
225+
test('rejects a request with no node bindings', async () => {
226+
const {app} = createTestApp();
227+
const res = await app.request('/_metrics', undefined, {});
228+
expect(res.status).toBe(403);
229+
});
230+
231+
test('ignores a forged x-forwarded-for header', async () => {
232+
const {app} = createTestApp();
233+
const res = await app.request(
234+
'/_metrics',
235+
{headers: {'x-forwarded-for': '127.0.0.1'}},
236+
{incoming: {socket: {remoteAddress: '203.0.113.9'}}},
237+
);
238+
expect(res.status).toBe(403);
239+
});
240+
});
241+
177242
describe('service name prefix', () => {
178243
test('uses provided service name in metric names', async () => {
179244
const {middleware, metricsHandler} = createMetricsMiddleware('gateway');
@@ -182,7 +247,7 @@ describe('Metrics Middleware', () => {
182247
app.get('/_metrics', metricsHandler);
183248
app.get('/test', (c) => c.json({ok: true}));
184249
await app.request('/test');
185-
const res = await app.request('/_metrics');
250+
const res = await requestMetrics(app);
186251
const body = await res.text();
187252
expect(body).toContain('fluxer_gateway_http_requests_total');
188253
expect(body).toContain('fluxer_gateway_http_request_duration_seconds');

0 commit comments

Comments
 (0)