This commit is contained in:
Mateusz Gruszczyński committed 2026-10-27 09:27:00 +01:00
1 parent 5c2e50ec06
commit 62b7574553
19 files changed
+563 -139

No files matched your search

+1 -1
View File
@@ -2,7 +2,7 @@
"openapi": "3.0.3",
"info": {
"title": "RustProxy Administration API",
"version": "0.34.13",
"version": "0.34.14",
"description": "Administration API for the active panel origin. Sign in through the panel; the same-origin HttpOnly cookie authenticates all private operations. Mutations also require the exact Origin, X-RustProxy-Request: 1 and a session-bound X-CSRF-Token. Loopback HTTP is the default; Settings can enable HTTPS before using a non-loopback administration bind. This document contains no live configuration or credentials. Object schemas marked extensible are summaries: full configuration validation is authoritative in Rust. The API also permits explicitly scoped GeneralApiBearer tokens on a narrow route allowlist, with RO/RW and source CIDR checks; API-token management and Prometheus-token management are browser-session-only."
},
"servers": [
@@ -0,0 +1,37 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>502 - Tinyauth connection error - RustProxy</title>
<style>
body {
margin: 12vh auto;
padding: 24px;
max-width: 620px;
background: #131615;
color: #e5e9e6;
font: 15px/1.6 system-ui, sans-serif
}
h1 {
font-size: 24px;
margin: 0 0 8px
}
p {
margin: 0;
color: #b9c3bc
}
</style>
</head>
<body>
<main>
<h1>502 - Tinyauth connection error</h1>
<p>Unable to connect to the Tinyauth authentication service.</p>
</main>
</body>
</html>
+37
View File
@@ -0,0 +1,37 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>504 - Tinyauth timeout - RustProxy</title>
<style>
body {
margin: 12vh auto;
padding: 24px;
max-width: 620px;
background: #131615;
color: #e5e9e6;
font: 15px/1.6 system-ui, sans-serif
}
h1 {
font-size: 24px;
margin: 0 0 8px
}
p {
margin: 0;
color: #b9c3bc
}
</style>
</head>
<body>
<main>
<h1>504 - Tinyauth timeout</h1>
<p>The Tinyauth authentication service did not respond in time.</p>
</main>
</body>
</html>
@@ -0,0 +1,37 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>502 - Tinyauth verification error - RustProxy</title>
<style>
body {
margin: 12vh auto;
padding: 24px;
max-width: 620px;
background: #131615;
color: #e5e9e6;
font: 15px/1.6 system-ui, sans-serif
}
h1 {
font-size: 24px;
margin: 0 0 8px
}
p {
margin: 0;
color: #b9c3bc
}
</style>
</head>
<body>
<main>
<h1>502 - Tinyauth verification error</h1>
<p>The Tinyauth authentication service returned an invalid response.</p>
</main>
</body>
</html>
+16 -1
View File
@@ -86,12 +86,27 @@ mod tests {
StatusCode::INTERNAL_SERVER_ERROR,
"500 - Internal server error",
),
(StatusCode::BAD_GATEWAY, "502 - Bad gateway"),
] {
let page = builtin_gateway_error(code).unwrap();
assert!(page.contains(label));
assert!(page.contains("background: #131615"));
}
let gateway_502 = builtin_gateway_error(StatusCode::BAD_GATEWAY).unwrap();
assert!(gateway_502.contains("<h1>502 - Bad gateway</h1>"));
assert!(gateway_502.contains("The upstream service returned an invalid response."));
assert!(gateway_502.contains("RustProxy"));
assert!(gateway_502.contains("#131615"));
assert!(!gateway_502.contains("Tinyauth"));
// The standard HTTP error registry and its stock pages predate the
// Tinyauth-specific diagnostic pages and must remain unchanged.
assert_eq!(
ERROR_CODES,
[400, 401, 403, 404, 408, 413, 429, 500, 502, 503, 504]
);
assert_eq!(
BUILTIN_ERROR_CODES,
[400, 401, 403, 404, 408, 410, 413, 429, 500, 502, 503, 504]
);
assert!(builtin_gateway_error(StatusCode::OK).is_none());
assert!(builtin_application_page(StatusCode::OK).contains("200 - OK"));
assert!(builtin_application_page(StatusCode::CREATED).contains("background: #131615"));
+101 -23
View File
@@ -63,6 +63,51 @@ pub struct Compiled {
}
#[derive(Clone)]
pub struct AuthenticationFailure;
#[derive(Debug)]
enum CheckError {
Connection(anyhow::Error),
InvalidResponse(anyhow::Error),
}
#[derive(Debug, Clone, Copy)]
enum GatewayFailure {
Connection,
Timeout,
Verification,
}
fn gateway_failure(kind: GatewayFailure) -> Response {
let (status, html) = match kind {
GatewayFailure::Connection => (
StatusCode::BAD_GATEWAY,
include_str!("../admin/web/error_tinyauth_connection.html"),
),
GatewayFailure::Timeout => (
StatusCode::GATEWAY_TIMEOUT,
include_str!("../admin/web/error_tinyauth_timeout.html"),
),
GatewayFailure::Verification => (
StatusCode::BAD_GATEWAY,
include_str!("../admin/web/error_tinyauth_verification.html"),
),
};
let mut response = (status, html).into_response();
response.headers_mut().insert(
"content-type",
HeaderValue::from_static("text/html; charset=utf-8"),
);
response.headers_mut().insert(
"cache-control",
HeaderValue::from_static("private, no-store"),
);
response.headers_mut().insert(
"x-content-type-options",
HeaderValue::from_static("nosniff"),
);
response.extensions_mut().insert(AuthenticationFailure);
response
}
#[derive(Debug, Default)]
pub struct Identity {
headers: HeaderMap,
@@ -220,6 +265,31 @@ fn failure(status: StatusCode) -> Response {
response.extensions_mut().insert(AuthenticationFailure);
response
}
fn apply_nginx_auth_context(
headers: &mut HeaderMap,
method: &Method,
context: &forwarding::Context,
) -> Result<()> {
context.apply(headers);
let non_nginx_headers: Vec<_> = headers
.keys()
.filter(|name| {
name.as_str() == "forwarded"
|| (name.as_str().starts_with("x-forwarded-") && name.as_str() != "x-forwarded-for")
})
.cloned()
.collect();
for name in non_nginx_headers {
headers.remove(name);
}
headers.insert("x-original-url", context.original_url.parse()?);
headers.insert("x-original-method", method.as_str().parse()?);
Ok(())
}
impl Compiled {
pub fn strip_identity(&self, headers: &mut HeaderMap) {
let names: Vec<_> = headers
@@ -290,11 +360,18 @@ impl Compiled {
};
let (status, headers) = match result {
Ok(Ok(value)) => value,
Ok(Err(error)) => {
tracing::debug!(%error,"Tinyauth verification failed");
return Err(failure(StatusCode::BAD_GATEWAY));
Ok(Err(CheckError::Connection(error))) => {
tracing::warn!(%error, "Tinyauth backend connection failed");
return Err(gateway_failure(GatewayFailure::Connection));
}
Ok(Err(CheckError::InvalidResponse(error))) => {
tracing::warn!(%error, "Tinyauth returned an invalid verification response");
return Err(gateway_failure(GatewayFailure::Verification));
}
Err(_) => {
tracing::warn!("Tinyauth verification timed out");
return Err(gateway_failure(GatewayFailure::Timeout));
}
Err(_) => return Err(failure(StatusCode::GATEWAY_TIMEOUT)),
};
if status.is_success() {
let mut identity = Identity::default();
@@ -303,7 +380,8 @@ impl Compiled {
for value in headers.get_all(name) {
size += value.as_bytes().len();
if size > 8192 {
return Err(failure(StatusCode::BAD_GATEWAY));
tracing::warn!("Tinyauth identity headers exceed the configured limit");
return Err(gateway_failure(GatewayFailure::Verification));
}
identity.headers.append(name.clone(), value.clone());
}
@@ -323,24 +401,26 @@ impl Compiled {
return Err(response);
}
}
Err(failure(if status == StatusCode::FORBIDDEN {
StatusCode::FORBIDDEN
} else if status == StatusCode::UNAUTHORIZED {
StatusCode::UNAUTHORIZED
} else {
StatusCode::BAD_GATEWAY
}))
if status == StatusCode::FORBIDDEN || status == StatusCode::UNAUTHORIZED {
return Err(failure(status));
}
tracing::warn!(
status = status.as_u16(),
"Tinyauth returned an unexpected HTTP status"
);
Err(gateway_failure(GatewayFailure::Verification))
}
async fn check(
&self,
incoming: &HeaderMap,
method: &Method,
context: &forwarding::Context,
) -> Result<(StatusCode, HeaderMap)> {
) -> std::result::Result<(StatusCode, HeaderMap), CheckError> {
let mut request = Request::builder()
.method(Method::GET)
.uri(self.uri.clone())
.body(Body::empty())?;
.body(Body::empty())
.map_err(|error| CheckError::InvalidResponse(error.into()))?;
for name in &self.request_headers {
for value in incoming.get_all(name) {
request.headers_mut().append(name.clone(), value.clone());
@@ -349,27 +429,25 @@ impl Compiled {
crate::proxy::headers::strip_hop_by_hop(request.headers_mut());
crate::proxy::headers::strip_admin_cookie(request.headers_mut());
self.strip_identity(request.headers_mut());
context.apply(request.headers_mut());
apply_nginx_auth_context(request.headers_mut(), method, context)
.map_err(CheckError::InvalidResponse)?;
request
.headers_mut()
.insert("host", self.backend.host_header.clone());
request
.headers_mut()
.insert("x-original-url", context.original_url.parse()?);
request
.headers_mut()
.insert("x-original-method", method.as_str().parse()?);
let response = self
.backend
.client
.request(request)
.await
.context("Tinyauth request failed")?;
.context("Tinyauth request failed")
.map_err(CheckError::Connection)?;
let (parts, body) = response.into_parts();
Limited::new(body, 16 * 1024)
.collect()
.await
.map_err(|e| anyhow::anyhow!("Tinyauth response body: {e}"))?;
.map_err(|error| {
CheckError::InvalidResponse(anyhow::anyhow!("Tinyauth response body: {error}"))
})?;
Ok((parts.status, parts.headers))
}
}
+202 -2
View File
@@ -9,8 +9,8 @@ mod tests {
..Default::default()
}
}
fn compiled() -> Compiled {
settings()
fn compile_settings(settings: Settings) -> Compiled {
settings
.compile(
&ValidationPolicy {
proxy_addr: "127.0.0.1:8080".parse().unwrap(),
@@ -24,6 +24,23 @@ mod tests {
.unwrap()
.unwrap()
}
fn compiled() -> Compiled {
compile_settings(settings())
}
fn request_context(incoming: &HeaderMap) -> forwarding::Context {
forwarding::Settings::default()
.compile()
.unwrap()
.context(
incoming,
"192.0.2.5:34567".parse().unwrap(),
"10.0.0.1:443".parse().unwrap(),
true,
&"app.example:443".parse().unwrap(),
&"/private?next=%2Fhome".parse().unwrap(),
)
.unwrap()
}
#[test]
fn origin_adds_nginx_endpoint() {
assert_eq!(
@@ -32,6 +49,57 @@ mod tests {
);
}
#[test]
fn nginx_auth_context_has_no_conflicting_forward_auth_headers() {
let context = request_context(&HeaderMap::new());
let mut headers = HeaderMap::new();
apply_nginx_auth_context(&mut headers, &Method::POST, &context).unwrap();
assert_eq!(
headers["x-original-url"],
"https://app.example:443/private?next=%2Fhome"
);
assert_eq!(headers["x-original-method"], "POST");
assert_eq!(headers["x-forwarded-for"], "192.0.2.5");
assert_eq!(headers["x-real-ip"], "192.0.2.5");
for name in [
"x-forwarded-host",
"x-forwarded-uri",
"x-forwarded-proto",
"x-forwarded-port",
"forwarded",
] {
assert!(!headers.contains_key(name), "{name}");
}
}
#[test]
fn nginx_auth_context_does_not_forward_spoofed_client_metadata() {
let mut headers = HeaderMap::new();
for (name, value) in [
("x-original-url", "https://evil.example/"),
("x-original-method", "DELETE"),
("x-forwarded-host", "evil.example"),
("x-forwarded-uri", "/admin"),
("x-forwarded-for", "203.0.113.1"),
("x-real-ip", "203.0.113.1"),
("forwarded", "for=203.0.113.1"),
] {
headers.insert(name, value.parse().unwrap());
}
let context = request_context(&headers);
apply_nginx_auth_context(&mut headers, &Method::GET, &context).unwrap();
assert_eq!(
headers["x-original-url"],
"https://app.example:443/private?next=%2Fhome"
);
assert_eq!(headers["x-original-method"], "GET");
assert_eq!(headers["x-forwarded-for"], "192.0.2.5");
assert_eq!(headers["x-real-ip"], "192.0.2.5");
assert!(!headers.contains_key("x-forwarded-host"));
assert!(!headers.contains_key("x-forwarded-uri"));
assert!(!headers.contains_key("forwarded"));
}
#[test]
fn rejects_transport_header_injection_and_unsafe_urls() {
for name in [
"host",
@@ -118,4 +186,136 @@ mod tests {
)
.is_err());
}
#[tokio::test]
async fn tinyauth_gateway_pages_are_distinct_and_fail_closed() {
use http_body_util::BodyExt;
for (kind, status, text) in [
(
GatewayFailure::Connection,
StatusCode::BAD_GATEWAY,
"Tinyauth connection error",
),
(
GatewayFailure::Verification,
StatusCode::BAD_GATEWAY,
"Tinyauth verification error",
),
(
GatewayFailure::Timeout,
StatusCode::GATEWAY_TIMEOUT,
"Tinyauth timeout",
),
] {
let response = gateway_failure(kind);
assert_eq!(response.status(), status);
assert_eq!(
response.headers()["content-type"],
"text/html; charset=utf-8"
);
assert_eq!(response.headers()["cache-control"], "private, no-store");
assert_eq!(response.headers()["x-content-type-options"], "nosniff");
assert!(response
.extensions()
.get::<AuthenticationFailure>()
.is_some());
let body = response.into_body().collect().await.unwrap().to_bytes();
let html = std::str::from_utf8(&body).unwrap();
assert!(html.contains(text));
assert!(html.contains("Access") && html.contains("protected"));
assert!(html.contains("<style>"));
assert!(!html.contains("<script") && !html.contains("href=\"http"));
assert!(!html.contains("127.0.0.1") && !html.contains("/api/auth/nginx"));
}
}
#[tokio::test]
async fn unreachable_tinyauth_returns_branded_connection_error() {
use http_body_util::BodyExt;
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = listener.local_addr().unwrap();
drop(listener);
let mut config = settings();
config.url = format!("http://{address}");
config.connect_timeout_seconds = 1;
config.timeout_seconds = 2;
let auth = compile_settings(config);
let headers = HeaderMap::new();
let response = auth
.authorize(
&headers,
&Method::GET,
&request_context(&headers),
&tokio_util::sync::CancellationToken::new(),
)
.await
.unwrap_err();
assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
let body = response.into_body().collect().await.unwrap().to_bytes();
let html = std::str::from_utf8(&body).unwrap();
assert!(html.contains("Tinyauth connection error"));
assert!(!html.contains(&address.to_string()));
}
#[tokio::test]
async fn unresponsive_tinyauth_returns_branded_timeout() {
use http_body_util::BodyExt;
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let mut config = settings();
config.url = format!("http://{}", listener.local_addr().unwrap());
config.connect_timeout_seconds = 1;
config.timeout_seconds = 1;
let auth = compile_settings(config);
let headers = HeaderMap::new();
let response = auth
.authorize(
&headers,
&Method::GET,
&request_context(&headers),
&tokio_util::sync::CancellationToken::new(),
)
.await
.unwrap_err();
assert_eq!(response.status(), StatusCode::GATEWAY_TIMEOUT);
assert!(
std::str::from_utf8(&response.into_body().collect().await.unwrap().to_bytes())
.unwrap()
.contains("Tinyauth timeout")
);
drop(listener);
}
#[tokio::test]
async fn tinyauth_http_503_is_not_misdiagnosed_as_connection_error() {
use http_body_util::BodyExt;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let mut config = settings();
config.url = format!("http://{}", listener.local_addr().unwrap());
let server = tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
let mut request = [0_u8; 4096];
let read = socket.read(&mut request).await.unwrap();
assert!(read > 0, "auth request must be sent to Tinyauth");
socket.write_all(b"HTTP/1.1 503 Service Unavailable\r\nContent-Length: 0\r\nConnection: close\r\n\r\n").await.unwrap();
});
let auth = compile_settings(config);
let headers = HeaderMap::new();
let response = auth
.authorize(
&headers,
&Method::GET,
&request_context(&headers),
&tokio_util::sync::CancellationToken::new(),
)
.await
.unwrap_err();
server.await.unwrap();
assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
let body = response.into_body().collect().await.unwrap().to_bytes();
assert!(std::str::from_utf8(&body)
.unwrap()
.contains("Tinyauth verification error"));
}
}