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 FastCgiIo for T where T: AsyncRead + AsyncWrite + Unpin + Send {} async fn connect_fastcgi(endpoint: &FastCgiEndpoint) -> io::Result> { 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, 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, 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 { 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> { 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::::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)> { 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::()?; 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, deadline: Instant, tx: mpsc::Sender>, initial: Vec, 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 for FastCgiRequestWriteError { fn from(value: io::Error) -> Self { Self::Io(value) } }