Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 36 additions & 2 deletions fluxer_media_proxy/src/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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 {
Expand Down
48 changes: 45 additions & 3 deletions fluxer_svc/src/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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(())
}

Expand All @@ -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);
(
[(
Expand All @@ -60,6 +74,7 @@ async fn metrics_handler(State(state): State<HttpState>) -> impl IntoResponse {
)],
body,
)
.into_response()
}

fn build_version() -> &'static str {
Expand All @@ -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")));
}
}
13 changes: 13 additions & 0 deletions packages/hono/src/middleware/Metrics.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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'),
Expand Down
99 changes: 82 additions & 17 deletions packages/hono/src/middleware/tests/Metrics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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');
});
Expand All @@ -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');
Expand All @@ -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"');
Expand All @@ -57,15 +61,15 @@ 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');
});

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"}');
});
Expand All @@ -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');
Expand All @@ -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');
});
Expand All @@ -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/);
Expand All @@ -109,30 +113,30 @@ 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"');
});

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"');
});

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"');
});
Expand All @@ -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');
Expand All @@ -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');
Expand All @@ -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');
Expand Down
Loading