Files
RustProxy/src/web_server/fastcgi/protocol.rs
T
2026-10-05 19:05:55 +02:00

379 lines
12 KiB
Rust

use super::{
config::{CompiledPhp, FastCgiEndpoint, MAX_HEADER_VALUE_BYTES},
filesystem::path_components,
htaccess,
request::server_protocol,
response::{builtin_error_response, status_response},
CompiledService, RequestContext,
};
use anyhow::{ensure, Context, Result};
use axum::{body::Body, response::Response};
use bytes::Bytes;
use futures_util::stream;
use http::{
header::{ALLOW, CONTENT_LENGTH, CONTENT_TYPE},
HeaderMap, HeaderName, HeaderValue, Method, StatusCode, Uri,
};
use http_body_util::BodyExt;
use std::{collections::BTreeMap, io, path::Path, str::FromStr};
use tokio::{
io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt},
sync::{mpsc, OwnedSemaphorePermit},
time::Instant,
};
const FCGI_VERSION_1: u8 = 1;
const FCGI_BEGIN_REQUEST: u8 = 1;
pub(super) const FCGI_END_REQUEST: u8 = 3;
const FCGI_PARAMS: u8 = 4;
pub(super) const FCGI_STDIN: u8 = 5;
pub(super) const FCGI_STDOUT: u8 = 6;
const FCGI_STDERR: u8 = 7;
const FCGI_RESPONDER: u16 = 1;
const FCGI_REQUEST_ID: u16 = 1;
pub(super) trait FastCgiIo: AsyncRead + AsyncWrite + Unpin + Send {}
impl<T> FastCgiIo for T where T: AsyncRead + AsyncWrite + Unpin + Send {}
async fn connect_fastcgi(endpoint: &FastCgiEndpoint) -> io::Result<Box<dyn FastCgiIo>> {
match endpoint {
#[cfg(unix)]
FastCgiEndpoint::Unix(path) => Ok(Box::new(tokio::net::UnixStream::connect(path).await?)),
FastCgiEndpoint::Tcp(address) => {
Ok(Box::new(tokio::net::TcpStream::connect(address).await?))
}
}
}
pub(super) async fn write_fcgi_record(
io: &mut dyn FastCgiIo,
record_type: u8,
content: &[u8],
) -> io::Result<()> {
if content.len() > u16::MAX as usize {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"FastCGI record is too large",
));
}
let padding = (8 - (content.len() % 8)) % 8;
let mut header = [0u8; 8];
header[0] = FCGI_VERSION_1;
header[1] = record_type;
header[2..4].copy_from_slice(&FCGI_REQUEST_ID.to_be_bytes());
header[4..6].copy_from_slice(&(content.len() as u16).to_be_bytes());
header[6] = padding as u8;
io.write_all(&header).await?;
io.write_all(content).await?;
if padding != 0 {
io.write_all(&[0u8; 8][..padding]).await?;
}
Ok(())
}
fn encode_fcgi_length(target: &mut Vec<u8>, length: usize) -> io::Result<()> {
if length < 128 {
target.push(length as u8);
} else if length <= 0x7fff_ffff {
target.extend_from_slice(&((length as u32) | 0x8000_0000).to_be_bytes());
} else {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"FastCGI parameter is too large",
));
}
Ok(())
}
fn encode_fcgi_param(target: &mut Vec<u8>, name: &str, value: &str) -> io::Result<()> {
encode_fcgi_length(target, name.len())?;
encode_fcgi_length(target, value.len())?;
target.extend_from_slice(name.as_bytes());
target.extend_from_slice(value.as_bytes());
Ok(())
}
fn fastcgi_header_param(name: &HeaderName) -> Option<String> {
let lower = name.as_str();
if matches!(
lower,
"content-type"
| "content-length"
| "connection"
| "transfer-encoding"
| "upgrade"
| "keep-alive"
| "proxy-connection"
| "vary"
| "te"
| "trailer"
) {
return None;
}
Some(format!(
"HTTP_{}",
lower.replace('-', "_").to_ascii_uppercase()
))
}
pub(super) fn fastcgi_params(
service: &CompiledService,
php: &CompiledPhp,
method: &Method,
headers: &HeaderMap,
original_uri: &Uri,
script_path: &str,
query: Option<&str>,
context: &RequestContext,
) -> Result<Vec<u8>> {
let components = path_components(script_path)?;
let mut script_filename = service.root.clone();
for component in &components {
script_filename.push(component);
}
let document_root = service.root.to_string_lossy().to_string();
let script_filename = script_filename.to_string_lossy().to_string();
let request_uri = original_uri
.path_and_query()
.map(|value| value.as_str())
.unwrap_or(original_uri.path());
let mut params = BTreeMap::<String, String>::new();
params.insert("REQUEST_METHOD".into(), method.as_str().into());
params.insert("SCRIPT_FILENAME".into(), script_filename);
params.insert("SCRIPT_NAME".into(), script_path.into());
params.insert("QUERY_STRING".into(), query.unwrap_or("").into());
params.insert("REQUEST_URI".into(), request_uri.into());
params.insert("DOCUMENT_ROOT".into(), document_root);
params.insert(
"SERVER_PROTOCOL".into(),
server_protocol(context.version).into(),
);
params.insert("SERVER_NAME".into(), context.server_name.clone());
params.insert("SERVER_PORT".into(), context.server_port.to_string());
params.insert("REMOTE_ADDR".into(), context.client_ip.to_string());
if let Some(port) = context.client_port {
params.insert("REMOTE_PORT".into(), port.to_string());
}
params.insert(
"HTTPS".into(),
if context.tls { "on" } else { "off" }.into(),
);
params.insert("GATEWAY_INTERFACE".into(), "CGI/1.1".into());
params.insert("SERVER_SOFTWARE".into(), "RustProxy/0.26.1".into());
params.insert("REDIRECT_STATUS".into(), "200".into());
if let Some(value) = headers
.get(CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
{
params.insert("CONTENT_TYPE".into(), value.into());
}
if let Some(value) = headers
.get(CONTENT_LENGTH)
.and_then(|value| value.to_str().ok())
{
params.insert("CONTENT_LENGTH".into(), value.into());
}
for (name, value) in headers {
// HTTP_PROXY is a well-known CGI/FastCGI injection vector (httpoxy).
// This must never depend on the optional Web Service hardening switch.
if name.as_str() == "proxy" || (!php.pass_authorization && name.as_str() == "authorization")
{
continue;
}
let Some(param) = fastcgi_header_param(name) else {
continue;
};
let Ok(value) = value.to_str() else { continue };
if value.len() > MAX_HEADER_VALUE_BYTES {
continue;
}
params
.entry(param)
.and_modify(|existing| {
if existing.len() + value.len() + 2 <= MAX_HEADER_VALUE_BYTES {
existing.push_str(", ");
existing.push_str(value);
}
})
.or_insert_with(|| value.to_owned());
}
// Trusted operator-defined parameters cannot override request-derived/core CGI names.
for (name, value) in &php.custom_params {
params.insert(name.clone(), value.clone());
}
if !php.php_values.is_empty() {
let mut value = String::new();
for (name, item) in &php.php_values {
if !value.is_empty() {
value.push('\n');
}
value.push_str(name);
value.push('=');
value.push_str(item);
}
params.insert("PHP_VALUE".into(), value);
}
// Request headers cannot synthesize PHP_VALUE/PHP_ADMIN_VALUE because only HTTP_*
// names are derived above. PHP_VALUE comes exclusively from validated operator config.
let mut encoded = Vec::with_capacity(4096);
for (name, value) in params {
encode_fcgi_param(&mut encoded, &name, &value)?;
ensure!(
encoded.len() <= 512 * 1024,
"FastCGI parameter block exceeds 512 KiB"
);
}
Ok(encoded)
}
pub(super) async fn read_fcgi_record(
io: &mut dyn FastCgiIo,
deadline: Instant,
) -> io::Result<(u8, Vec<u8>)> {
let mut header = [0u8; 8];
tokio::time::timeout_at(deadline, io.read_exact(&mut header))
.await
.map_err(|_| io::Error::new(io::ErrorKind::TimedOut, "FastCGI response timeout"))??;
if header[0] != FCGI_VERSION_1 || u16::from_be_bytes([header[2], header[3]]) != FCGI_REQUEST_ID
{
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"invalid FastCGI record header",
));
}
let length = u16::from_be_bytes([header[4], header[5]]) as usize;
let padding = header[6] as usize;
let mut content = vec![0u8; length];
if length != 0 {
tokio::time::timeout_at(deadline, io.read_exact(&mut content))
.await
.map_err(|_| io::Error::new(io::ErrorKind::TimedOut, "FastCGI response timeout"))??;
}
if padding != 0 {
let mut discard = [0u8; 255];
tokio::time::timeout_at(deadline, io.read_exact(&mut discard[..padding]))
.await
.map_err(|_| io::Error::new(io::ErrorKind::TimedOut, "FastCGI response timeout"))??;
}
Ok((header[1], content))
}
fn find_cgi_header_end(value: &[u8]) -> Option<(usize, usize)> {
value
.windows(4)
.position(|window| window == b"\r\n\r\n")
.map(|position| (position, 4))
.or_else(|| {
value
.windows(2)
.position(|window| window == b"\n\n")
.map(|position| (position, 2))
})
}
pub(super) fn parse_cgi_headers(value: &[u8]) -> Result<(StatusCode, HeaderMap)> {
let text = std::str::from_utf8(value).context("PHP-FPM returned non-UTF-8 CGI headers")?;
let mut status = StatusCode::OK;
let mut headers = HeaderMap::new();
for (index, line) in text.lines().enumerate() {
ensure!(index < 128, "PHP-FPM returned too many CGI headers");
let line = line.trim_end_matches('\r');
if line.is_empty() {
continue;
}
let (name, value) = line
.split_once(':')
.context("PHP-FPM returned a malformed CGI header")?;
if name.eq_ignore_ascii_case("status") {
let code = value
.trim()
.split_whitespace()
.next()
.context("PHP-FPM returned an empty Status header")?
.parse::<u16>()?;
ensure!(
(200..=599).contains(&code),
"PHP-FPM returned an unsupported informational/invalid status"
);
status = StatusCode::from_u16(code)?;
continue;
}
let name = HeaderName::from_str(name.trim())?;
if matches!(
name.as_str(),
"connection"
| "content-length"
| "transfer-encoding"
| "upgrade"
| "keep-alive"
| "proxy-connection"
| "te"
| "trailer"
| "proxy-authenticate"
| "proxy-authorization"
) {
continue;
}
let value = HeaderValue::from_str(value.trim())?;
headers.append(name, value);
}
Ok((status, headers))
}
async fn drain_fastcgi_body(
mut io: Box<dyn FastCgiIo>,
deadline: Instant,
tx: mpsc::Sender<io::Result<Bytes>>,
initial: Vec<u8>,
send_body: bool,
stderr_log_bytes: usize,
_permit: OwnedSemaphorePermit,
) {
if send_body && !initial.is_empty() && tx.send(Ok(Bytes::from(initial))).await.is_err() {
return;
}
let mut stderr = Vec::new();
loop {
match read_fcgi_record(io.as_mut(), deadline).await {
Ok((FCGI_STDOUT, content)) => {
if send_body
&& !content.is_empty()
&& tx.send(Ok(Bytes::from(content))).await.is_err()
{
return;
}
}
Ok((FCGI_STDERR, content)) => {
let room = stderr_log_bytes.saturating_sub(stderr.len());
stderr.extend_from_slice(&content[..content.len().min(room)]);
}
Ok((FCGI_END_REQUEST, _)) => break,
Ok(_) => {}
Err(error) => {
if send_body {
let _ = tx.send(Err(error)).await;
}
return;
}
}
}
if !stderr.is_empty() {
tracing::warn!(
stderr = %String::from_utf8_lossy(&stderr),
"PHP-FPM FastCGI stderr"
);
}
}
#[derive(Debug)]
enum FastCgiRequestWriteError {
Io(io::Error),
BodyTooLarge,
}
impl From<io::Error> for FastCgiRequestWriteError {
fn from(value: io::Error) -> Self {
Self::Io(value)
}
}