Skip to content

Commit 8ee8a4d

Browse files
committed
fix: health endpoint privacy, /r/ folder routing, and HTTPS detection
- Restrict /health endpoint to private IPs only (10.x, 172.16-31.x, 192.168.x, 127.x) - Fix /r/ folder on subdomains by passing on_main_domain to get_file_path_from_url() - Detect TLS from actual connection state instead of spoofable x-forwarded-proto header - Show HTTPS/1.1 protocol for HTTPS requests
1 parent 71de401 commit 8ee8a4d

3 files changed

Lines changed: 75 additions & 19 deletions

File tree

src/src/http/mod.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,11 @@ mod websocket;
55

66
pub use static_files::StaticFiles;
77

8+
/// Marker extension to indicate a request came over TLS
9+
/// This is inserted by the HTTPS server and cannot be spoofed by clients
10+
#[derive(Clone, Copy, Debug)]
11+
pub struct TlsConnectInfo;
12+
813
use anyhow::{anyhow, Result};
914
use axum::{
1015
extract::{ConnectInfo, DefaultBodyLimit},
@@ -256,6 +261,8 @@ where
256261

257262
fn call(&mut self, mut req: axum::http::Request<ReqBody>) -> Self::Future {
258263
req.extensions_mut().insert(ConnectInfo(self.addr));
264+
// Mark this request as coming over TLS - cannot be spoofed by clients
265+
req.extensions_mut().insert(TlsConnectInfo);
259266
self.inner.call(req)
260267
}
261268
}

src/src/http/routes.rs

Lines changed: 45 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -14,17 +14,45 @@ use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _};
1414
use std::collections::HashMap;
1515
use std::str::FromStr;
1616

17-
use crate::http::AppState;
17+
use crate::http::{AppState, TlsConnectInfo};
1818
use crate::ip2country::lookup_country;
1919
use crate::models::{Header, HttpRequestLog, Response as ResponseModel};
2020
use crate::utils::{
2121
config::CONFIG, generate_request_id, get_current_timestamp, get_file_path_from_url,
22-
get_subdomain_from_hostname, get_subdomain_from_path,
22+
get_subdomain_from_hostname, get_subdomain_from_path, is_private_ip,
2323
};
2424
use serde_json::json;
2525

2626
/// Health check endpoint for monitoring and orchestration
27-
pub async fn health(State(state): State<AppState>) -> impl IntoResponse {
27+
/// Only accessible from private IPs (10.x.x.x, 172.16-31.x.x, 192.168.x.x, 127.x.x.x)
28+
/// For external requests, behaves like any other route (logs request, serves custom response)
29+
pub async fn health(
30+
State(state): State<AppState>,
31+
connect_info: axum::extract::ConnectInfo<std::net::SocketAddr>,
32+
tls_info: Option<axum::Extension<TlsConnectInfo>>,
33+
uri: Uri,
34+
method: axum::http::Method,
35+
headers: HeaderMap,
36+
body: Bytes,
37+
) -> Response {
38+
let client_ip = connect_info.0.ip().to_string();
39+
40+
// Only return health stats for private/internal IPs
41+
if !is_private_ip(&client_ip) {
42+
// For external requests, treat /health as a normal route
43+
return catch_all(
44+
State(state),
45+
connect_info,
46+
tls_info,
47+
uri,
48+
method,
49+
headers,
50+
body,
51+
)
52+
.await
53+
.into_response();
54+
}
55+
2856
let stats = state.cache.stats();
2957

3058
let memory_used_mb = stats.memory_used_bytes as f64 / 1024.0 / 1024.0;
@@ -44,17 +72,21 @@ pub async fn health(State(state): State<AppState>) -> impl IntoResponse {
4472
}
4573
})),
4674
)
75+
.into_response()
4776
}
4877

4978
/// Catch-all handler that logs HTTP requests and serves files
5079
pub async fn catch_all(
5180
State(state): State<AppState>,
5281
connect_info: axum::extract::ConnectInfo<std::net::SocketAddr>,
82+
tls_info: Option<axum::Extension<TlsConnectInfo>>,
5383
uri: Uri,
5484
method: axum::http::Method,
5585
headers: HeaderMap,
5686
body: Bytes,
5787
) -> impl IntoResponse {
88+
// Check if request came over TLS - this is set by our server and cannot be spoofed
89+
let is_tls = tls_info.is_some();
5890
let host = headers
5991
.get(header::HOST)
6092
.and_then(|h| h.to_str().ok())
@@ -96,18 +128,13 @@ pub async fn catch_all(
96128

97129
// Extract query string and build full URL
98130
let query_string = uri.query().map(|s| format!("?{s}"));
99-
let protocol = "HTTP/1.1".to_string();
100-
101-
// Build full URL (fragments are not sent to server in HTTP)
102-
let scheme = if headers
103-
.get("x-forwarded-proto")
104-
.and_then(|h| h.to_str().ok())
105-
.map(|s| s == "https")
106-
.unwrap_or(false)
107-
{
108-
"https"
131+
132+
// Determine scheme and protocol from actual TLS state (cannot be spoofed by clients)
133+
let scheme = if is_tls { "https" } else { "http" };
134+
let protocol = if is_tls {
135+
"HTTPS/1.1".to_string()
109136
} else {
110-
"http"
137+
"HTTP/1.1".to_string()
111138
};
112139
let full_url = format!(
113140
"{}://{}{}{}",
@@ -179,8 +206,10 @@ pub async fn catch_all(
179206

180207
let _ = state.tx.send(message);
181208

182-
// Extract the file path from the URL (for /r/subdomain/path routing)
183-
let file_path = get_file_path_from_url(path);
209+
// Extract the file path from the URL
210+
// For main domain path-based routing (/r/subdomain/path): strips /r/subdomain prefix
211+
// For true subdomain requests: keeps path as-is (so /r/folder works)
212+
let file_path = get_file_path_from_url(path, on_main_domain);
184213
return serve_file(state, subdomain, &file_path, on_main_domain).await;
185214
}
186215

src/src/utils/mod.rs

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ use crate::models::{Claims, ShareClaims};
55
use config::CONFIG;
66
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
77
use rand::Rng;
8+
use std::net::IpAddr;
89
use std::time::{Duration, SystemTime};
910
use uuid::Uuid;
1011

@@ -16,6 +17,16 @@ pub fn verify_subdomain(
1617
subdomain.len() == length && subdomain.chars().all(|c| alphabet_set.contains(&c))
1718
}
1819

20+
/// Check if an IP address is private (internal network) or loopback
21+
/// Returns true for: 10.x.x.x, 172.16-31.x.x, 192.168.x.x, 127.x.x.x, ::1
22+
pub fn is_private_ip(ip: &str) -> bool {
23+
match ip.parse::<IpAddr>() {
24+
Ok(IpAddr::V4(ipv4)) => ipv4.is_private() || ipv4.is_loopback(),
25+
Ok(IpAddr::V6(ipv6)) => ipv6.is_loopback(),
26+
Err(_) => false,
27+
}
28+
}
29+
1930
pub fn verify_jwt(token: &str) -> Option<String> {
2031
let validation = Validation::default();
2132
let key = DecodingKey::from_secret(CONFIG.jwt_secret.as_bytes());
@@ -78,9 +89,18 @@ pub fn get_subdomain_from_path(path: &str) -> Option<String> {
7889
Some(subdomain)
7990
}
8091

81-
/// Extract the file path portion from a /r/subdomain/path URL
82-
/// Returns the path after the subdomain portion (e.g., "/r/abc123/foo/bar" -> "/foo/bar")
83-
pub fn get_file_path_from_url(path: &str) -> String {
92+
/// Extract the file path portion from a URL
93+
/// When `on_main_domain` is true (path-based routing like /r/subdomain/path):
94+
/// Returns the path after the subdomain portion (e.g., "/r/abc123/foo/bar" -> "/foo/bar")
95+
/// When `on_main_domain` is false (true subdomain routing):
96+
/// Returns the path as-is (e.g., "/r/test.html" -> "/r/test.html")
97+
pub fn get_file_path_from_url(path: &str, on_main_domain: bool) -> String {
98+
// For true subdomain requests, don't strip anything - the path is the actual file path
99+
if !on_main_domain {
100+
return path.to_string();
101+
}
102+
103+
// For main domain path-based routing, strip the /r/subdomain prefix
84104
let path_lower = path.to_lowercase();
85105
let trimmed = path_lower.trim_start_matches('/');
86106

0 commit comments

Comments
 (0)