mirror of
https://github.com/telemt/telemt.git
synced 2026-09-15 06:54:10 +03:00
WEB
Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com> Co-Authored-By: John Preston <17900494+john-preston@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
use std::task::{Context, Poll};
|
||||
use std::time::Instant;
|
||||
|
||||
use bytes::Bytes;
|
||||
use hyper::body::{Body, Frame, SizeHint};
|
||||
use parking_lot::Mutex;
|
||||
|
||||
use super::{BoxError, HttpBody};
|
||||
|
||||
/// Request lifecycle guard that refreshes HTTP connection activity on completion.
|
||||
pub(super) struct RequestActivity {
|
||||
last_activity: Arc<Mutex<Instant>>,
|
||||
}
|
||||
|
||||
impl RequestActivity {
|
||||
/// Starts activity accounting for one HTTP request.
|
||||
pub(super) fn begin(last_activity: Arc<Mutex<Instant>>) -> Self {
|
||||
*last_activity.lock() = Instant::now();
|
||||
Self { last_activity }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for RequestActivity {
|
||||
fn drop(&mut self) {
|
||||
*self.last_activity.lock() = Instant::now();
|
||||
}
|
||||
}
|
||||
|
||||
/// Response body wrapper that refreshes activity while downstream data progresses.
|
||||
pub(super) struct ActivityBody {
|
||||
inner: HttpBody,
|
||||
activity: RequestActivity,
|
||||
}
|
||||
|
||||
impl ActivityBody {
|
||||
/// Binds one response body to its request activity guard.
|
||||
pub(super) fn new(inner: HttpBody, activity: RequestActivity) -> Self {
|
||||
Self {
|
||||
inner,
|
||||
activity,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Body for ActivityBody {
|
||||
type Data = Bytes;
|
||||
type Error = BoxError;
|
||||
|
||||
fn poll_frame(
|
||||
mut self: Pin<&mut Self>,
|
||||
context: &mut Context<'_>,
|
||||
) -> Poll<Option<Result<Frame<Self::Data>, Self::Error>>> {
|
||||
let result = Pin::new(&mut self.inner).poll_frame(context);
|
||||
if result.is_ready() {
|
||||
*self.activity.last_activity.lock() = Instant::now();
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn is_end_stream(&self) -> bool {
|
||||
self.inner.is_end_stream()
|
||||
}
|
||||
|
||||
fn size_hint(&self) -> SizeHint {
|
||||
self.inner.size_hint()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use bytes::Bytes;
|
||||
use http_body_util::{BodyExt, Empty, Limited};
|
||||
use hyper::body::{Body as _, Incoming};
|
||||
use hyper::Request;
|
||||
|
||||
use crate::web::manager::WebProcessRuntime;
|
||||
|
||||
/// Collected carrier request retaining its process-wide body reservation.
|
||||
pub(super) struct CollectedBody {
|
||||
/// Request head reconstructed without the consumed network body.
|
||||
pub(super) request: Request<Empty<Bytes>>,
|
||||
/// Fully collected bounded carrier payload.
|
||||
pub(super) body: Bytes,
|
||||
/// Byte-budget reservation held through request processing.
|
||||
pub(super) _body_budget: tokio::sync::OwnedSemaphorePermit,
|
||||
}
|
||||
|
||||
// Keep rejected requests inline to avoid attacker-controlled allocations on invalid bodies.
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
/// Body collection failure with sanitized request context when decoy routing is safe.
|
||||
pub(super) enum CollectBodyError {
|
||||
/// The body shape, size, or deadline failed after retaining the request head.
|
||||
Invalid(Request<Empty<Bytes>>),
|
||||
/// Process-wide body reader or byte capacity is temporarily exhausted.
|
||||
Limit,
|
||||
}
|
||||
|
||||
/// Collects one bounded carrier body under reader, byte, and deadline ownership.
|
||||
pub(super) async fn collect_body(
|
||||
request: Request<Incoming>,
|
||||
runtime: &WebProcessRuntime,
|
||||
limit: usize,
|
||||
allow_empty: bool,
|
||||
) -> Result<CollectedBody, CollectBodyError> {
|
||||
let exceeds_limit = request.body().size_hint().lower() > limit as u64
|
||||
|| request
|
||||
.body()
|
||||
.size_hint()
|
||||
.upper()
|
||||
.is_some_and(|upper| upper > limit as u64);
|
||||
let (parts, body) = request.into_parts();
|
||||
if exceeds_limit {
|
||||
return Err(CollectBodyError::Invalid(Request::from_parts(
|
||||
parts,
|
||||
Empty::new(),
|
||||
)));
|
||||
}
|
||||
let Some((reader_budget, body_budget)) = runtime.try_body_budget(limit) else {
|
||||
return Err(CollectBodyError::Limit);
|
||||
};
|
||||
let body_timeout = Duration::from_secs(
|
||||
runtime
|
||||
.active_generation()
|
||||
.config()
|
||||
.web
|
||||
.timeouts
|
||||
.body_secs,
|
||||
);
|
||||
let body = match tokio::time::timeout(body_timeout, Limited::new(body, limit).collect()).await {
|
||||
Ok(Ok(body)) => body.to_bytes(),
|
||||
_ => {
|
||||
return Err(CollectBodyError::Invalid(Request::from_parts(
|
||||
parts,
|
||||
Empty::new(),
|
||||
)));
|
||||
}
|
||||
};
|
||||
drop(reader_budget);
|
||||
let request = Request::from_parts(parts, Empty::new());
|
||||
if !allow_empty && body.is_empty() {
|
||||
return Err(CollectBodyError::Invalid(request));
|
||||
}
|
||||
Ok(CollectedBody {
|
||||
request,
|
||||
body,
|
||||
_body_budget: body_budget,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
use std::error::Error;
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use bytes::Bytes;
|
||||
use http_body_util::{BodyExt, Empty};
|
||||
use hyper::header::{self, HeaderName, HeaderValue};
|
||||
use hyper::{Method, Request, StatusCode, Uri};
|
||||
use hyper_util::rt::TokioIo;
|
||||
use tokio::net::TcpStream;
|
||||
|
||||
use super::{
|
||||
BoxError, HttpBody, HttpResponse, bad_gateway, full_response, generic_not_found,
|
||||
insert_header,
|
||||
};
|
||||
use crate::config::{WebRuntimeDecoy, WebRuntimeVhost};
|
||||
use crate::web::manager::WebProcessRuntime;
|
||||
|
||||
/// Serves the configured ordinary site after optionally removing carrier material.
|
||||
pub(super) async fn serve_decoy<B>(
|
||||
mut request: Request<B>,
|
||||
vhost: Arc<WebRuntimeVhost>,
|
||||
sanitize_transport: bool,
|
||||
runtime: &WebProcessRuntime,
|
||||
) -> HttpResponse
|
||||
where
|
||||
B: hyper::body::Body<Data = Bytes> + Send + 'static,
|
||||
B::Error: Error + Send + Sync + 'static,
|
||||
{
|
||||
if sanitize_transport {
|
||||
sanitize_transport_request(&mut request);
|
||||
}
|
||||
let (parts, body) = request.into_parts();
|
||||
let body = if sanitize_transport {
|
||||
Empty::<Bytes>::new()
|
||||
.map_err(|never| -> BoxError { match never {} })
|
||||
.boxed_unsync()
|
||||
} else {
|
||||
body.map_err(|error| -> BoxError { Box::new(error) })
|
||||
.boxed_unsync()
|
||||
};
|
||||
let request = Request::from_parts(parts, body);
|
||||
match &vhost.decoy {
|
||||
WebRuntimeDecoy::StaticDirectory(site) => serve_static(request, site),
|
||||
WebRuntimeDecoy::HttpUpstream { addr, authority } => {
|
||||
proxy_to_upstream(
|
||||
request,
|
||||
*addr,
|
||||
authority,
|
||||
Duration::from_secs(vhost.decoy_header_secs),
|
||||
runtime,
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn serve_static<B>(request: Request<B>, site: &crate::config::WebStaticSite) -> HttpResponse {
|
||||
if !matches!(*request.method(), Method::GET | Method::HEAD) {
|
||||
return static_entry(request, site, None, StatusCode::NOT_FOUND);
|
||||
}
|
||||
let path = request.uri().path();
|
||||
let resolved = resolve_static_path(path, site);
|
||||
let status = if resolved.is_some() {
|
||||
StatusCode::OK
|
||||
} else {
|
||||
StatusCode::NOT_FOUND
|
||||
};
|
||||
static_entry(request, site, resolved, status)
|
||||
}
|
||||
|
||||
fn static_entry<B>(
|
||||
request: Request<B>,
|
||||
site: &crate::config::WebStaticSite,
|
||||
route: Option<&str>,
|
||||
status: StatusCode,
|
||||
) -> HttpResponse {
|
||||
let fallback = format!("/{}", site.index);
|
||||
let not_found = site.assets.contains_key("/404.html").then_some("/404.html");
|
||||
let route = route.or(not_found).unwrap_or(&fallback);
|
||||
let Some(asset) = site.assets.get(route) else {
|
||||
return generic_not_found();
|
||||
};
|
||||
let not_modified = status == StatusCode::OK
|
||||
&& request
|
||||
.headers()
|
||||
.get(header::IF_NONE_MATCH)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
== Some(asset.etag.as_str());
|
||||
let response_body = if request.method() == Method::HEAD || not_modified {
|
||||
Bytes::new()
|
||||
} else {
|
||||
asset.body.clone()
|
||||
};
|
||||
let mut response = full_response(
|
||||
if not_modified {
|
||||
StatusCode::NOT_MODIFIED
|
||||
} else {
|
||||
status
|
||||
},
|
||||
response_body,
|
||||
);
|
||||
insert_header(&mut response, header::CONTENT_TYPE, asset.content_type);
|
||||
insert_header(&mut response, header::ETAG, &asset.etag);
|
||||
insert_header(
|
||||
&mut response,
|
||||
header::CONTENT_LENGTH,
|
||||
&asset.body.len().to_string(),
|
||||
);
|
||||
response.headers_mut().insert(
|
||||
header::CACHE_CONTROL,
|
||||
if status.is_client_error() || request.uri().query().is_some() {
|
||||
HeaderValue::from_static("no-store")
|
||||
} else {
|
||||
HeaderValue::from_static("public, max-age=300")
|
||||
},
|
||||
);
|
||||
response.headers_mut().insert(
|
||||
header::CONTENT_SECURITY_POLICY,
|
||||
HeaderValue::from_static("default-src 'self'; style-src 'self'; img-src 'self'; worker-src 'none'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'"),
|
||||
);
|
||||
response.headers_mut().insert(
|
||||
header::REFERRER_POLICY,
|
||||
HeaderValue::from_static("strict-origin-when-cross-origin"),
|
||||
);
|
||||
response.headers_mut().insert(
|
||||
header::X_CONTENT_TYPE_OPTIONS,
|
||||
HeaderValue::from_static("nosniff"),
|
||||
);
|
||||
response.headers_mut().insert(header::X_FRAME_OPTIONS, HeaderValue::from_static("DENY"));
|
||||
response
|
||||
}
|
||||
|
||||
fn resolve_static_path<'a>(
|
||||
path: &str,
|
||||
site: &'a crate::config::WebStaticSite,
|
||||
) -> Option<&'a str> {
|
||||
if !path.starts_with('/')
|
||||
|| path.contains('\\')
|
||||
|| path.contains("//")
|
||||
|| path.split('/').any(|part| matches!(part, "." | ".."))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let root;
|
||||
let route = if path == "/" {
|
||||
root = format!("/{}", site.index);
|
||||
root.as_str()
|
||||
} else {
|
||||
path
|
||||
};
|
||||
if site.assets.contains_key(route) {
|
||||
return site.assets.get_key_value(route).map(|(key, _)| key.as_str());
|
||||
}
|
||||
if route == "/favicon.ico" && site.assets.contains_key("/favicon.svg") {
|
||||
return Some("/favicon.svg");
|
||||
}
|
||||
if !route.rsplit('/').next().unwrap_or_default().contains('.') {
|
||||
let html = format!("{route}.html");
|
||||
return site
|
||||
.assets
|
||||
.get_key_value(&html)
|
||||
.map(|(key, _)| key.as_str());
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
async fn proxy_to_upstream(
|
||||
mut request: Request<HttpBody>,
|
||||
addr: SocketAddr,
|
||||
authority: &str,
|
||||
header_timeout: Duration,
|
||||
runtime: &WebProcessRuntime,
|
||||
) -> HttpResponse {
|
||||
remove_hop_by_hop(request.headers_mut());
|
||||
if let Ok(host) = HeaderValue::from_str(authority) {
|
||||
request.headers_mut().insert(header::HOST, host);
|
||||
}
|
||||
let path_and_query = request
|
||||
.uri()
|
||||
.path_and_query()
|
||||
.map(|value| value.as_str())
|
||||
.unwrap_or("/");
|
||||
let Ok(uri) = path_and_query.parse::<Uri>() else {
|
||||
return bad_gateway();
|
||||
};
|
||||
*request.uri_mut() = uri;
|
||||
let stream = match tokio::time::timeout(header_timeout, TcpStream::connect(addr)).await {
|
||||
Ok(Ok(stream)) => stream,
|
||||
_ => return bad_gateway(),
|
||||
};
|
||||
let max_header_bytes = runtime
|
||||
.active_generation()
|
||||
.config()
|
||||
.web
|
||||
.limits
|
||||
.max_header_bytes;
|
||||
let mut builder = hyper::client::conn::http1::Builder::new();
|
||||
builder.max_buf_size(max_header_bytes);
|
||||
let (mut sender, connection) = match tokio::time::timeout(
|
||||
header_timeout,
|
||||
builder.handshake(TokioIo::new(stream)),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Ok(parts)) => parts,
|
||||
_ => return bad_gateway(),
|
||||
};
|
||||
runtime.spawn_auxiliary(async move {
|
||||
let _ = connection.await;
|
||||
});
|
||||
let mut response = match tokio::time::timeout(header_timeout, sender.send_request(request)).await
|
||||
{
|
||||
Ok(Ok(response)) => response,
|
||||
_ => return bad_gateway(),
|
||||
};
|
||||
remove_hop_by_hop(response.headers_mut());
|
||||
response.map(|body| {
|
||||
body.map_err(|error| -> BoxError { Box::new(error) })
|
||||
.boxed_unsync()
|
||||
})
|
||||
}
|
||||
|
||||
fn sanitize_transport_request<B>(request: &mut Request<B>) {
|
||||
for name in [
|
||||
header::AUTHORIZATION,
|
||||
header::CONTENT_LENGTH,
|
||||
header::CONTENT_TYPE,
|
||||
header::UPGRADE,
|
||||
HeaderName::from_static("sec-websocket-key"),
|
||||
HeaderName::from_static("sec-websocket-protocol"),
|
||||
HeaderName::from_static("sec-websocket-version"),
|
||||
HeaderName::from_static("x-down-cursor"),
|
||||
HeaderName::from_static("x-lane-id"),
|
||||
HeaderName::from_static("x-up-seq"),
|
||||
] {
|
||||
request.headers_mut().remove(name);
|
||||
}
|
||||
request
|
||||
.headers_mut()
|
||||
.insert(header::CONNECTION, HeaderValue::from_static("close"));
|
||||
}
|
||||
|
||||
fn remove_hop_by_hop(headers: &mut hyper::HeaderMap) {
|
||||
let nominated = headers
|
||||
.get_all(header::CONNECTION)
|
||||
.iter()
|
||||
.filter_map(|value| value.to_str().ok())
|
||||
.flat_map(|value| value.split(','))
|
||||
.filter_map(|value| HeaderName::from_bytes(value.trim().as_bytes()).ok())
|
||||
.collect::<Vec<_>>();
|
||||
for name in nominated {
|
||||
headers.remove(name);
|
||||
}
|
||||
for name in [
|
||||
header::CONNECTION,
|
||||
header::PROXY_AUTHENTICATE,
|
||||
header::PROXY_AUTHORIZATION,
|
||||
header::TE,
|
||||
header::TRAILER,
|
||||
header::TRANSFER_ENCODING,
|
||||
header::UPGRADE,
|
||||
HeaderName::from_static("keep-alive"),
|
||||
HeaderName::from_static("proxy-connection"),
|
||||
] {
|
||||
headers.remove(name);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn static_resolver_rejects_rewritten_paths() {
|
||||
let site = crate::config::WebStaticSite {
|
||||
assets: std::collections::BTreeMap::new(),
|
||||
index: "index.html".to_string(),
|
||||
};
|
||||
assert!(resolve_static_path("/../index.html", &site).is_none());
|
||||
assert!(resolve_static_path("//index.html", &site).is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
use std::net::{IpAddr, SocketAddr};
|
||||
use std::sync::Arc;
|
||||
|
||||
use base64::Engine as _;
|
||||
use hyper::header;
|
||||
use hyper::Request;
|
||||
use ipnetwork::IpNetwork;
|
||||
use sha2::{Digest, Sha256};
|
||||
use subtle::ConstantTimeEq;
|
||||
|
||||
use crate::config::{
|
||||
WebClientIpSource, WebRuntimeProfile, WebRuntimeVhost,
|
||||
};
|
||||
use crate::web::manager::TokenHash;
|
||||
|
||||
/// Parses one lowercase canonical Host value restricted to the public HTTPS port.
|
||||
pub(super) fn canonical_request_host<B>(request: &Request<B>) -> Option<&str> {
|
||||
let values = request.headers().get_all(header::HOST);
|
||||
let mut values = values.iter();
|
||||
let value = values.next()?.to_str().ok()?;
|
||||
if values.next().is_some() {
|
||||
return None;
|
||||
}
|
||||
let authority = value.parse::<hyper::http::uri::Authority>().ok()?;
|
||||
if authority.port_u16().is_some_and(|port| port != 443) {
|
||||
return None;
|
||||
}
|
||||
let host = value.strip_suffix(":443").unwrap_or(value);
|
||||
if authority.host() != host || host.bytes().any(|byte| byte.is_ascii_uppercase())
|
||||
{
|
||||
return None;
|
||||
}
|
||||
Some(host)
|
||||
}
|
||||
|
||||
/// Accepts one canonical forwarded client address from an explicitly trusted peer.
|
||||
pub(super) fn client_ip<B>(
|
||||
request: &Request<B>,
|
||||
peer: SocketAddr,
|
||||
source: WebClientIpSource,
|
||||
trusted_proxy_cidrs: &[IpNetwork],
|
||||
) -> Option<IpAddr> {
|
||||
if !trusted_proxy_cidrs
|
||||
.iter()
|
||||
.any(|network| network.contains(peer.ip()))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let header_name = match source {
|
||||
WebClientIpSource::XForwardedFor => "x-forwarded-for",
|
||||
};
|
||||
let values = request.headers().get_all(header_name);
|
||||
let mut values = values.iter();
|
||||
let value = values.next()?.to_str().ok()?;
|
||||
if values.next().is_some()
|
||||
|| value.is_empty()
|
||||
|| value.trim() != value
|
||||
|| value.contains(',')
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let ip = value.parse::<IpAddr>().ok()?;
|
||||
(ip.to_string() == value).then_some(ip)
|
||||
}
|
||||
|
||||
/// Decodes an exact canonical bridge query without allocating credential strings.
|
||||
pub(super) fn bridge_candidate(query: Option<&str>) -> ([u8; 32], bool) {
|
||||
let mut candidate = [0u8; 32];
|
||||
let Some(value) = query.and_then(|query| query.strip_prefix("bridge=")) else {
|
||||
return (candidate, false);
|
||||
};
|
||||
if value.len() != 43 {
|
||||
return (candidate, false);
|
||||
}
|
||||
let mut decoded = [0u8; 32];
|
||||
let Ok(decoded_len) = base64::engine::general_purpose::URL_SAFE_NO_PAD
|
||||
.decode_slice(value, &mut decoded)
|
||||
else {
|
||||
return (candidate, false);
|
||||
};
|
||||
let mut canonical = [0u8; 43];
|
||||
let Ok(encoded_len) = base64::engine::general_purpose::URL_SAFE_NO_PAD
|
||||
.encode_slice(decoded, &mut canonical)
|
||||
else {
|
||||
return (candidate, false);
|
||||
};
|
||||
if decoded_len != decoded.len()
|
||||
|| encoded_len != canonical.len()
|
||||
|| !bool::from(canonical.ct_eq(value.as_bytes()))
|
||||
{
|
||||
return (candidate, false);
|
||||
}
|
||||
candidate = decoded;
|
||||
(candidate, true)
|
||||
}
|
||||
|
||||
/// Matches a capability in constant time across every profile of one virtual host.
|
||||
pub(super) fn match_profile(
|
||||
vhost: &WebRuntimeVhost,
|
||||
candidate: &[u8; 32],
|
||||
) -> Option<Arc<WebRuntimeProfile>> {
|
||||
let mut matched = None;
|
||||
for profile in &vhost.profiles {
|
||||
if bool::from(profile.capability.ct_eq(candidate)) {
|
||||
matched = Some(Arc::clone(profile));
|
||||
}
|
||||
}
|
||||
matched
|
||||
}
|
||||
|
||||
/// Validates and hashes one canonical bearer credential for map lookup.
|
||||
pub(super) fn bearer_token_hash<B>(request: &Request<B>) -> Option<TokenHash> {
|
||||
let values = request.headers().get_all(header::AUTHORIZATION);
|
||||
let mut values = values.iter();
|
||||
let value = values.next()?.to_str().ok()?;
|
||||
if values.next().is_some() || !value.starts_with("Bearer ") || value.matches(' ').count() != 1
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let token = value.strip_prefix("Bearer ")?;
|
||||
if token.len() != 43 {
|
||||
return None;
|
||||
}
|
||||
let mut decoded = [0u8; 32];
|
||||
let decoded_len = base64::engine::general_purpose::URL_SAFE_NO_PAD
|
||||
.decode_slice(token, &mut decoded)
|
||||
.ok()?;
|
||||
let mut canonical = [0u8; 43];
|
||||
let encoded_len = base64::engine::general_purpose::URL_SAFE_NO_PAD
|
||||
.encode_slice(decoded, &mut canonical)
|
||||
.ok()?;
|
||||
(decoded_len == decoded.len()
|
||||
&& encoded_len == canonical.len()
|
||||
&& bool::from(canonical.ct_eq(token.as_bytes())))
|
||||
.then(|| Sha256::digest(decoded).into())
|
||||
}
|
||||
|
||||
/// Checks the exact carrier media type without accepting duplicate headers.
|
||||
pub(super) fn binary_content_type<B>(request: &Request<B>) -> bool {
|
||||
let values = request.headers().get_all(header::CONTENT_TYPE);
|
||||
let mut values = values.iter();
|
||||
let value = values.next().and_then(|value| value.to_str().ok());
|
||||
values.next().is_none()
|
||||
&& value.is_some_and(|value| value.eq_ignore_ascii_case("application/octet-stream"))
|
||||
}
|
||||
|
||||
/// Parses one canonical unsigned decimal carrier sequence header.
|
||||
pub(super) fn canonical_u64_header<B>(
|
||||
request: &Request<B>,
|
||||
name: &'static str,
|
||||
) -> Option<u64> {
|
||||
let values = request.headers().get_all(name);
|
||||
let mut values = values.iter();
|
||||
let value = values.next()?.to_str().ok()?;
|
||||
if values.next().is_some()
|
||||
|| value.is_empty()
|
||||
|| value.starts_with('+')
|
||||
|| (value.len() > 1 && value.starts_with('0'))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let parsed = value.parse::<u64>().ok()?;
|
||||
(parsed.to_string() == value).then_some(parsed)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn canonical_bridge_query_rejects_aliases() {
|
||||
let token = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode([7u8; 32]);
|
||||
assert!(bridge_candidate(Some(&format!("bridge={token}"))).1);
|
||||
assert!(!bridge_candidate(Some(&format!("x=1&bridge={token}"))).1);
|
||||
assert!(!bridge_candidate(Some(&format!("bridge={token}="))).1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn host_and_forwarded_identity_require_canonical_single_values() {
|
||||
let request = Request::builder()
|
||||
.header(header::HOST, "proxy.example.com:443")
|
||||
.header("x-forwarded-for", "192.0.2.10")
|
||||
.body(())
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
canonical_request_host(&request),
|
||||
Some("proxy.example.com")
|
||||
);
|
||||
let trusted: [IpNetwork; 1] = ["127.0.0.1/32".parse().unwrap()];
|
||||
assert_eq!(
|
||||
client_ip(
|
||||
&request,
|
||||
"127.0.0.1:40000".parse().unwrap(),
|
||||
WebClientIpSource::XForwardedFor,
|
||||
&trusted,
|
||||
),
|
||||
Some("192.0.2.10".parse().unwrap())
|
||||
);
|
||||
|
||||
let uppercase = Request::builder()
|
||||
.header(header::HOST, "Proxy.Example.com")
|
||||
.body(())
|
||||
.unwrap();
|
||||
assert!(canonical_request_host(&uppercase).is_none());
|
||||
let appended = Request::builder()
|
||||
.header("x-forwarded-for", "192.0.2.10, 198.51.100.4")
|
||||
.body(())
|
||||
.unwrap();
|
||||
assert!(
|
||||
client_ip(
|
||||
&appended,
|
||||
"127.0.0.1:40000".parse().unwrap(),
|
||||
WebClientIpSource::XForwardedFor,
|
||||
&trusted,
|
||||
)
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bearer_and_sequence_headers_reject_noncanonical_aliases() {
|
||||
let token = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode([1u8; 32]);
|
||||
let request = Request::builder()
|
||||
.header(header::AUTHORIZATION, format!("Bearer {token}"))
|
||||
.header("x-up-seq", "17")
|
||||
.body(())
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
bearer_token_hash(&request),
|
||||
Some(Sha256::digest([1u8; 32]).into())
|
||||
);
|
||||
assert_eq!(canonical_u64_header(&request, "x-up-seq"), Some(17));
|
||||
|
||||
let leading_zero = Request::builder()
|
||||
.header("x-up-seq", "017")
|
||||
.body(())
|
||||
.unwrap();
|
||||
assert!(canonical_u64_header(&leading_zero, "x-up-seq").is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use arc_swap::ArcSwap;
|
||||
use base64::Engine as _;
|
||||
use bytes::Bytes;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::{TcpListener, TcpStream};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use super::serve_connection;
|
||||
use crate::config::{
|
||||
ProxyConfig, WebClientIpSource, WebRuntimeConfig, WebRuntimeDecoy,
|
||||
WebRuntimeProfile, WebRuntimeVhost, WebSecretMode, WebStaticAsset, WebStaticSite,
|
||||
};
|
||||
use crate::maestro::generation::test_runtime_generation;
|
||||
use crate::web::frame::{self, FrameType};
|
||||
use crate::web::manager::WebProcessRuntime;
|
||||
|
||||
fn runtime_config(capability: [u8; 32]) -> ProxyConfig {
|
||||
let profile = Arc::new(WebRuntimeProfile {
|
||||
host: "proxy.example.com".to_string(),
|
||||
public_addr: "203.0.113.10:443".parse().unwrap(),
|
||||
user: "alice".to_string(),
|
||||
secret_mode: WebSecretMode::Plain,
|
||||
capability,
|
||||
max_sessions: 4,
|
||||
max_streams: 16,
|
||||
max_streams_per_session: 4,
|
||||
});
|
||||
let mut assets = BTreeMap::new();
|
||||
assets.insert(
|
||||
"/index.html".to_string(),
|
||||
WebStaticAsset {
|
||||
body: Bytes::from_static(b"<!doctype html><title>decoy</title>"),
|
||||
content_type: "text/html; charset=utf-8",
|
||||
etag: "\"test\"".to_string(),
|
||||
},
|
||||
);
|
||||
let site = Arc::new(WebStaticSite {
|
||||
assets,
|
||||
index: "index.html".to_string(),
|
||||
});
|
||||
let vhost = Arc::new(WebRuntimeVhost {
|
||||
host: "proxy.example.com".to_string(),
|
||||
decoy: WebRuntimeDecoy::StaticDirectory(Arc::clone(&site)),
|
||||
decoy_header_secs: 1,
|
||||
profiles: vec![Arc::clone(&profile)],
|
||||
});
|
||||
let mut vhosts = BTreeMap::new();
|
||||
vhosts.insert("proxy.example.com".to_string(), vhost);
|
||||
vhosts.insert(
|
||||
"other.example.com".to_string(),
|
||||
Arc::new(WebRuntimeVhost {
|
||||
host: "other.example.com".to_string(),
|
||||
decoy: WebRuntimeDecoy::StaticDirectory(site),
|
||||
decoy_header_secs: 1,
|
||||
profiles: Vec::new(),
|
||||
}),
|
||||
);
|
||||
let mut config = ProxyConfig::default();
|
||||
config.web.enabled = true;
|
||||
config.web.limits.max_bootstraps_per_ip = 1;
|
||||
config.web.timeouts.shutdown_secs = 1;
|
||||
config.web.runtime = Some(Arc::new(WebRuntimeConfig {
|
||||
vhosts,
|
||||
profiles: vec![profile],
|
||||
}));
|
||||
config
|
||||
}
|
||||
|
||||
async fn request(
|
||||
listener: &TcpListener,
|
||||
runtime: &Arc<WebProcessRuntime>,
|
||||
request: Vec<u8>,
|
||||
) -> Vec<u8> {
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let (accepted, client) = tokio::join!(listener.accept(), TcpStream::connect(addr));
|
||||
let (server, peer) = accepted.unwrap();
|
||||
let mut client = client.unwrap();
|
||||
let permit = runtime.try_http_connection().unwrap();
|
||||
let task = tokio::spawn(serve_connection(
|
||||
server,
|
||||
peer,
|
||||
WebClientIpSource::XForwardedFor,
|
||||
Arc::from(["127.0.0.1/32".parse().unwrap()]),
|
||||
Arc::clone(runtime),
|
||||
CancellationToken::new(),
|
||||
permit,
|
||||
));
|
||||
client.write_all(&request).await.unwrap();
|
||||
let mut response = Vec::new();
|
||||
client.read_to_end(&mut response).await.unwrap();
|
||||
task.await.unwrap();
|
||||
response
|
||||
}
|
||||
|
||||
fn split_response(response: &[u8]) -> (&[u8], &[u8]) {
|
||||
let separator = response
|
||||
.windows(4)
|
||||
.position(|window| window == b"\r\n\r\n")
|
||||
.unwrap();
|
||||
(&response[..separator], &response[separator + 4..])
|
||||
}
|
||||
|
||||
fn response_header<'a>(headers: &'a [u8], name: &str) -> &'a str {
|
||||
std::str::from_utf8(headers)
|
||||
.unwrap()
|
||||
.lines()
|
||||
.filter_map(|line| line.split_once(':'))
|
||||
.find_map(|(header, value)| header.eq_ignore_ascii_case(name).then_some(value.trim()))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn https_carrier_bootstraps_and_closes_one_session() {
|
||||
let capability = [7u8; 32];
|
||||
let generation = test_runtime_generation(1, runtime_config(capability));
|
||||
let active_runtime = Arc::new(ArcSwap::from(Arc::clone(&generation)));
|
||||
let runtime = WebProcessRuntime::start(Arc::clone(&active_runtime));
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(capability);
|
||||
let wrong_family = format!(
|
||||
"GET /?bridge={encoded} HTTP/1.1\r\nHost: proxy.example.com\r\nX-Forwarded-For: 2001:db8::10\r\nConnection: close\r\n\r\n"
|
||||
)
|
||||
.into_bytes();
|
||||
let wrong_family_response = request(&listener, &runtime, wrong_family).await;
|
||||
let (_, wrong_family_body) = split_response(&wrong_family_response);
|
||||
assert!(!wrong_family_body
|
||||
.windows(11)
|
||||
.any(|value| value == b"bootstrap='"));
|
||||
let root = format!(
|
||||
"GET /?bridge={encoded} HTTP/1.1\r\nHost: proxy.example.com\r\nX-Forwarded-For: 192.0.2.10\r\nConnection: close\r\n\r\n"
|
||||
)
|
||||
.into_bytes();
|
||||
let root_response = request(&listener, &runtime, root).await;
|
||||
let (root_headers, root_body) = split_response(&root_response);
|
||||
assert!(root_headers.starts_with(b"HTTP/1.1 200"));
|
||||
let root_body = std::str::from_utf8(root_body).unwrap();
|
||||
let bootstrap = root_body
|
||||
.split_once("bootstrap='")
|
||||
.and_then(|(_, suffix)| suffix.split_once('\''))
|
||||
.map(|(token, _)| token)
|
||||
.unwrap();
|
||||
assert_eq!(bootstrap.len(), 43);
|
||||
|
||||
let hello = frame::encode(FrameType::Hello, 0, &[1]);
|
||||
let mut wrong_host = format!(
|
||||
"POST /api/v1/session HTTP/1.1\r\nHost: other.example.com\r\nX-Forwarded-For: 192.0.2.10\r\nAuthorization: Bearer {bootstrap}\r\nContent-Type: application/octet-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
|
||||
hello.len()
|
||||
)
|
||||
.into_bytes();
|
||||
wrong_host.extend_from_slice(&hello);
|
||||
let wrong_host_response = request(&listener, &runtime, wrong_host).await;
|
||||
assert!(wrong_host_response.starts_with(b"HTTP/1.1 404"));
|
||||
|
||||
let mut create = format!(
|
||||
"POST /api/v1/session HTTP/1.1\r\nHost: proxy.example.com\r\nX-Forwarded-For: 192.0.2.10\r\nAuthorization: Bearer {bootstrap}\r\nContent-Type: application/octet-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
|
||||
hello.len()
|
||||
)
|
||||
.into_bytes();
|
||||
let create_retry = create.clone();
|
||||
create.extend_from_slice(&hello);
|
||||
let mut create_retry = create_retry;
|
||||
create_retry.extend_from_slice(&hello);
|
||||
let create_response = request(&listener, &runtime, create).await;
|
||||
let (create_headers, create_body) = split_response(&create_response);
|
||||
assert!(create_headers.starts_with(b"HTTP/1.1 200"));
|
||||
assert_eq!(response_header(create_headers, "x-carrier-mode"), "https");
|
||||
assert_eq!(create_body, frame::encode(FrameType::Welcome, 0, &[]));
|
||||
let session = response_header(create_headers, "x-session-token");
|
||||
assert_eq!(session.len(), 43);
|
||||
|
||||
let replacement = test_runtime_generation(2, runtime_config(capability));
|
||||
active_runtime.store(Arc::clone(&replacement));
|
||||
tokio::time::sleep(std::time::Duration::from_millis(1100)).await;
|
||||
let retry_response = request(&listener, &runtime, create_retry).await;
|
||||
let (retry_headers, retry_body) = split_response(&retry_response);
|
||||
assert!(retry_headers.starts_with(b"HTTP/1.1 200"));
|
||||
assert_eq!(response_header(retry_headers, "x-session-token"), session);
|
||||
assert_eq!(retry_body, frame::encode(FrameType::Welcome, 0, &[]));
|
||||
|
||||
let next_root = format!(
|
||||
"GET /?bridge={encoded} HTTP/1.1\r\nHost: proxy.example.com\r\nX-Forwarded-For: 192.0.2.10\r\nConnection: close\r\n\r\n"
|
||||
)
|
||||
.into_bytes();
|
||||
let next_root_response = request(&listener, &runtime, next_root).await;
|
||||
let (_, next_root_body) = split_response(&next_root_response);
|
||||
assert!(next_root_body.windows(11).any(|value| value == b"bootstrap='"));
|
||||
|
||||
let close = format!(
|
||||
"DELETE /api/v1/session HTTP/1.1\r\nHost: proxy.example.com\r\nX-Forwarded-For: 192.0.2.10\r\nAuthorization: Bearer {session}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
|
||||
)
|
||||
.into_bytes();
|
||||
let close_retry = close.clone();
|
||||
let close_response = request(&listener, &runtime, close).await;
|
||||
assert!(close_response.starts_with(b"HTTP/1.1 204"));
|
||||
let close_retry_response = request(&listener, &runtime, close_retry).await;
|
||||
assert!(close_retry_response.starts_with(b"HTTP/1.1 204"));
|
||||
|
||||
runtime.shutdown().await;
|
||||
generation.stop_sessions().await;
|
||||
generation.stop_background_tasks().await;
|
||||
replacement.stop_sessions().await;
|
||||
replacement.stop_background_tasks().await;
|
||||
}
|
||||
Reference in New Issue
Block a user