chore: initial sanitized public snapshot
This commit is contained in:
@@ -0,0 +1,544 @@
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
io::{self, Read},
|
||||
net::TcpStream,
|
||||
str,
|
||||
sync::{Arc, Mutex},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
InProcessRpcDispatcher, JsonRpcRequest,
|
||||
jsonrpc::{
|
||||
JsonRpcPayload, JsonRpcResponse, jsonrpc_batch_response_to_json, jsonrpc_payload_from_json,
|
||||
jsonrpc_response_to_json,
|
||||
},
|
||||
model::{RpcError, RpcErrorCode, RpcMeta, RpcValue},
|
||||
xmlrpc::{
|
||||
XmlRpcFault, XmlRpcMethodCall, XmlRpcMethodResponse, XmlRpcParam, XmlRpcValue,
|
||||
xmlrpc_method_call_from_xml, xmlrpc_method_response_to_xml,
|
||||
},
|
||||
};
|
||||
|
||||
use super::{RpcServerConfig, websocket_surface::websocket_upgrade_response};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
/// Buffered HTTP request model used by the synchronous transport helpers.
|
||||
pub(super) struct HttpRequest {
|
||||
/// Request method token from the HTTP start line.
|
||||
pub(super) method: String,
|
||||
/// Raw request target path from the HTTP start line.
|
||||
pub(super) path: String,
|
||||
/// Lower-cased request headers.
|
||||
pub(super) headers: BTreeMap<String, String>,
|
||||
/// Fully buffered request body bytes.
|
||||
pub(super) body: Vec<u8>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
/// Prepared HTTP RPC request after transport-level validation and body parsing.
|
||||
enum PreparedHttpRpcRequest {
|
||||
/// An XML-RPC request body ready for dispatcher execution.
|
||||
Xml(XmlRpcMethodCall),
|
||||
/// A JSON-RPC payload ready for dispatcher execution.
|
||||
Json(JsonRpcPayload),
|
||||
}
|
||||
|
||||
/// Strips any query string or fragment from an inbound request target.
|
||||
pub(super) fn request_path_without_query_or_fragment(path: &str) -> &str {
|
||||
path.split(['?', '#']).next().unwrap_or(path)
|
||||
}
|
||||
|
||||
/// Normalizes supported RPC paths so equivalent HTTP targets share one route key.
|
||||
pub(super) fn normalized_rpc_path(path: &str) -> &str {
|
||||
let path = request_path_without_query_or_fragment(path);
|
||||
if path.len() > 1 {
|
||||
path.trim_end_matches('/')
|
||||
} else {
|
||||
path
|
||||
}
|
||||
}
|
||||
|
||||
/// Looks up an HTTP header by name using case-insensitive matching.
|
||||
pub(super) fn header_value<'a>(
|
||||
headers: &'a BTreeMap<String, String>,
|
||||
name: &str,
|
||||
) -> Option<&'a str> {
|
||||
headers
|
||||
.iter()
|
||||
.find(|(header_name, _)| header_name.eq_ignore_ascii_case(name))
|
||||
.map(|(_, value)| value.as_str())
|
||||
}
|
||||
|
||||
/// Returns whether an HTTP header exists, ignoring header-name case.
|
||||
pub(super) fn has_header(headers: &BTreeMap<String, String>, name: &str) -> bool {
|
||||
header_value(headers, name).is_some()
|
||||
}
|
||||
|
||||
/// Reads and buffers a single HTTP request from a client stream.
|
||||
pub(super) fn read_http_request(
|
||||
stream: &mut TcpStream,
|
||||
request_timeout: Duration,
|
||||
) -> io::Result<Option<HttpRequest>> {
|
||||
stream.set_read_timeout(Some(request_timeout))?;
|
||||
let mut buffer = Vec::new();
|
||||
let mut chunk = [0_u8; 1024];
|
||||
loop {
|
||||
let read = stream.read(&mut chunk)?;
|
||||
if read == 0 {
|
||||
if buffer.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
break;
|
||||
}
|
||||
buffer.extend_from_slice(&chunk[..read]);
|
||||
if buffer.windows(4).any(|window| window == b"\r\n\r\n") {
|
||||
break;
|
||||
}
|
||||
if buffer.len() > 1024 * 1024 {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
"request too large",
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let header_end = buffer
|
||||
.windows(4)
|
||||
.position(|window| window == b"\r\n\r\n")
|
||||
.map(|index| index + 4)
|
||||
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "missing request headers"))?;
|
||||
let (header_bytes, body_prefix) = buffer.split_at(header_end);
|
||||
let header_text = str::from_utf8(header_bytes)
|
||||
.map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error.to_string()))?;
|
||||
let mut header_lines = header_text.lines();
|
||||
let request_line = header_lines
|
||||
.next()
|
||||
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "missing request line"))?;
|
||||
let mut request_parts = request_line.split_whitespace();
|
||||
let method = request_parts
|
||||
.next()
|
||||
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "missing method"))?
|
||||
.to_owned();
|
||||
let path = request_parts
|
||||
.next()
|
||||
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "missing path"))?
|
||||
.to_owned();
|
||||
let headers = header_lines
|
||||
.filter_map(|line| line.split_once(':'))
|
||||
.map(|(name, value)| (name.trim().to_ascii_lowercase(), value.trim().to_owned()))
|
||||
.collect::<BTreeMap<_, _>>();
|
||||
let content_length = headers
|
||||
.get("content-length")
|
||||
.and_then(|value| value.parse::<usize>().ok())
|
||||
.unwrap_or(0);
|
||||
|
||||
let mut body = body_prefix.to_vec();
|
||||
while body.len() < content_length {
|
||||
let read = stream.read(&mut chunk)?;
|
||||
if read == 0 {
|
||||
break;
|
||||
}
|
||||
body.extend_from_slice(&chunk[..read]);
|
||||
}
|
||||
body.truncate(content_length);
|
||||
|
||||
Ok(Some(HttpRequest {
|
||||
method,
|
||||
path,
|
||||
headers,
|
||||
body,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Builds a minimal HTTP response with the supplied content type and body.
|
||||
pub(super) fn http_response(status: &str, content_type: &str, body: &[u8]) -> Vec<u8> {
|
||||
let mut response = format!(
|
||||
"HTTP/1.1 {status}\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
|
||||
body.len()
|
||||
)
|
||||
.into_bytes();
|
||||
response.extend_from_slice(body);
|
||||
response
|
||||
}
|
||||
|
||||
/// Builds an empty `204 No Content` HTTP response.
|
||||
pub(super) fn http_no_content_response() -> Vec<u8> {
|
||||
b"HTTP/1.1 204 No Content\r\nContent-Length: 0\r\nConnection: close\r\n\r\n".to_vec()
|
||||
}
|
||||
|
||||
/// Builds a minimal HTTP response while allowing additional headers to be injected.
|
||||
pub(super) fn http_response_with_headers(
|
||||
status: &str,
|
||||
content_type: Option<&str>,
|
||||
extra_headers: &[(&str, String)],
|
||||
body: &[u8],
|
||||
) -> Vec<u8> {
|
||||
let mut response = format!("HTTP/1.1 {status}\r\n").into_bytes();
|
||||
if let Some(content_type) = content_type {
|
||||
response.extend_from_slice(format!("Content-Type: {content_type}\r\n").as_bytes());
|
||||
}
|
||||
response.extend_from_slice(format!("Content-Length: {}\r\n", body.len()).as_bytes());
|
||||
for (name, value) in extra_headers {
|
||||
response.extend_from_slice(format!("{name}: {value}\r\n").as_bytes());
|
||||
}
|
||||
response.extend_from_slice(b"\r\n");
|
||||
response.extend_from_slice(body);
|
||||
response
|
||||
}
|
||||
|
||||
/// Produces the CORS headers required for either a normal RPC response or a preflight reply.
|
||||
pub(super) fn cors_response_headers(
|
||||
config: &RpcServerConfig,
|
||||
requested_headers: Option<&str>,
|
||||
preflight: bool,
|
||||
) -> Vec<(&'static str, String)> {
|
||||
let Some(origin) = &config.allow_origin else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
let mut headers = vec![("Access-Control-Allow-Origin", origin.clone())];
|
||||
if preflight {
|
||||
headers.push((
|
||||
"Access-Control-Allow-Methods",
|
||||
"POST, GET, OPTIONS".to_owned(),
|
||||
));
|
||||
headers.push((
|
||||
"Access-Control-Allow-Headers",
|
||||
requested_headers
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.unwrap_or("content-type")
|
||||
.to_owned(),
|
||||
));
|
||||
}
|
||||
headers
|
||||
}
|
||||
|
||||
/// Builds an RPC HTTP response and attaches configured CORS headers.
|
||||
pub(super) fn rpc_http_response(
|
||||
config: &RpcServerConfig,
|
||||
status: &str,
|
||||
content_type: &str,
|
||||
body: &[u8],
|
||||
) -> Vec<u8> {
|
||||
let headers = cors_response_headers(config, None, false);
|
||||
http_response_with_headers(status, Some(content_type), &headers, body)
|
||||
}
|
||||
|
||||
/// Builds an empty RPC HTTP response and attaches configured CORS headers.
|
||||
pub(super) fn rpc_http_no_content_response(
|
||||
config: &RpcServerConfig,
|
||||
requested_headers: Option<&str>,
|
||||
) -> Vec<u8> {
|
||||
let headers = cors_response_headers(config, requested_headers, true);
|
||||
http_response_with_headers("204 No Content", None, &headers, b"")
|
||||
}
|
||||
|
||||
/// Returns whether an HTTP request looks like a WebSocket upgrade handshake for RPC.
|
||||
pub(super) fn is_websocket_upgrade_candidate(request: &HttpRequest) -> bool {
|
||||
request.method.eq_ignore_ascii_case("GET")
|
||||
&& normalized_rpc_path(&request.path) == "/jsonrpc"
|
||||
&& (header_value(&request.headers, "upgrade")
|
||||
.is_some_and(|value| value.eq_ignore_ascii_case("websocket"))
|
||||
|| has_header(&request.headers, "sec-websocket-key")
|
||||
|| has_header(&request.headers, "sec-websocket-version")
|
||||
|| header_value(&request.headers, "connection")
|
||||
.is_some_and(|value| contains_ascii_case_insensitive(value, "upgrade")))
|
||||
}
|
||||
|
||||
/// Removes an optional leading `token:` secret from JSON-RPC positional parameters.
|
||||
fn extract_rpc_token(params: &mut Vec<RpcValue>) -> Option<String> {
|
||||
if let Some(RpcValue::String(token)) = params.first()
|
||||
&& let Some(value) = token.strip_prefix("token:")
|
||||
{
|
||||
let value = value.to_owned();
|
||||
params.remove(0);
|
||||
return Some(value);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Returns whether an RPC method remains callable without the shared secret token.
|
||||
fn rpc_method_skips_secret(method: &str) -> bool {
|
||||
matches!(method, "system.listMethods" | "system.listNotifications")
|
||||
}
|
||||
|
||||
/// Dispatches one JSON-RPC request after applying the shared-secret compatibility rules.
|
||||
pub(super) fn dispatch_json_request(
|
||||
dispatcher: &mut InProcessRpcDispatcher,
|
||||
config: &RpcServerConfig,
|
||||
mut request: JsonRpcRequest,
|
||||
) -> JsonRpcResponse {
|
||||
let provided_token = extract_rpc_token(&mut request.params);
|
||||
if let Some(secret) = &config.secret_token
|
||||
&& !rpc_method_skips_secret(&request.method)
|
||||
&& provided_token.as_deref() != Some(secret.as_str())
|
||||
{
|
||||
return JsonRpcResponse::error(
|
||||
request.id,
|
||||
RpcError::unauthorized("RPC secret required or invalid token"),
|
||||
);
|
||||
}
|
||||
dispatcher.dispatch_json(request)
|
||||
}
|
||||
|
||||
/// Dispatches one XML-RPC request after applying the shared-secret compatibility rules.
|
||||
pub(super) fn dispatch_xml_request(
|
||||
dispatcher: &mut InProcessRpcDispatcher,
|
||||
config: &RpcServerConfig,
|
||||
mut request: XmlRpcMethodCall,
|
||||
) -> XmlRpcMethodResponse {
|
||||
let provided_token = if let Some(XmlRpcParam {
|
||||
value: XmlRpcValue::String(token),
|
||||
}) = request.params.first()
|
||||
&& let Some(value) = token.strip_prefix("token:")
|
||||
{
|
||||
Some(value.to_owned())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if provided_token.is_some() {
|
||||
request.params.remove(0);
|
||||
}
|
||||
if let Some(secret) = &config.secret_token
|
||||
&& !rpc_method_skips_secret(&request.method_name)
|
||||
&& provided_token.as_deref() != Some(secret.as_str())
|
||||
{
|
||||
return XmlRpcMethodResponse {
|
||||
value: None,
|
||||
fault: Some(XmlRpcFault {
|
||||
code: 1,
|
||||
message: "RPC secret required or invalid token".to_owned(),
|
||||
error: Some(RpcError::unauthorized(
|
||||
"RPC secret required or invalid token",
|
||||
)),
|
||||
}),
|
||||
meta: RpcMeta::default(),
|
||||
};
|
||||
}
|
||||
dispatcher.dispatch_xml(request)
|
||||
}
|
||||
|
||||
/// Trims a UTF-8 BOM, comments, and processing instructions before XML-RPC sniffing.
|
||||
fn trim_xml_prelude(mut body: &str) -> &str {
|
||||
loop {
|
||||
body = body.trim_start();
|
||||
if let Some(rest) = body.strip_prefix("<?")
|
||||
&& let Some(end) = rest.find("?>")
|
||||
{
|
||||
body = &rest[end + 2..];
|
||||
continue;
|
||||
}
|
||||
if let Some(rest) = body.strip_prefix("<!--")
|
||||
&& let Some(end) = rest.find("-->")
|
||||
{
|
||||
body = &rest[end + 3..];
|
||||
continue;
|
||||
}
|
||||
return body;
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns whether an HTTP request body should be treated as XML-RPC input.
|
||||
fn looks_like_xmlrpc_request_body(body_text: &str) -> bool {
|
||||
let body = trim_xml_prelude(body_text.trim_start_matches('\u{feff}'));
|
||||
body.starts_with("<methodCall") || body.starts_with("<methodResponse")
|
||||
}
|
||||
|
||||
/// Handles one HTTP RPC request, including JSON-RPC, XML-RPC, CORS, and upgrade paths.
|
||||
#[cfg(test)]
|
||||
pub(super) fn handle_rpc_http_request(
|
||||
dispatcher: &mut InProcessRpcDispatcher,
|
||||
config: &RpcServerConfig,
|
||||
request: HttpRequest,
|
||||
) -> Vec<u8> {
|
||||
match prepare_rpc_http_request(config, request) {
|
||||
Ok(Some(prepared)) => render_rpc_http_dispatch_response(dispatcher, config, prepared),
|
||||
Ok(None) => http_no_content_response(),
|
||||
Err(response) => response,
|
||||
}
|
||||
}
|
||||
|
||||
/// Handles one HTTP RPC request while locking the dispatcher only for the actual dispatch path.
|
||||
pub(super) fn handle_rpc_http_request_shared(
|
||||
dispatcher: &Arc<Mutex<InProcessRpcDispatcher>>,
|
||||
config: &RpcServerConfig,
|
||||
request: HttpRequest,
|
||||
) -> io::Result<Vec<u8>> {
|
||||
match prepare_rpc_http_request(config, request) {
|
||||
Ok(Some(prepared)) => {
|
||||
let mut dispatcher = dispatcher
|
||||
.lock()
|
||||
.map_err(|_| io::Error::other("rpc dispatcher mutex poisoned"))?;
|
||||
Ok(render_rpc_http_dispatch_response(
|
||||
&mut dispatcher,
|
||||
config,
|
||||
prepared,
|
||||
))
|
||||
}
|
||||
Ok(None) => Ok(http_no_content_response()),
|
||||
Err(response) => Ok(response),
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses and validates one HTTP RPC request before any dispatcher locking occurs.
|
||||
fn prepare_rpc_http_request(
|
||||
config: &RpcServerConfig,
|
||||
request: HttpRequest,
|
||||
) -> Result<Option<PreparedHttpRpcRequest>, Vec<u8>> {
|
||||
if is_websocket_upgrade_candidate(&request) {
|
||||
return Err(websocket_upgrade_response(config, &request));
|
||||
}
|
||||
let path = normalized_rpc_path(&request.path);
|
||||
let is_rpc_endpoint = matches!(path, "/jsonrpc" | "/rpc");
|
||||
if request.method.eq_ignore_ascii_case("OPTIONS")
|
||||
&& is_rpc_endpoint
|
||||
&& config.allow_origin.is_some()
|
||||
{
|
||||
return Err(rpc_http_no_content_response(
|
||||
config,
|
||||
header_value(&request.headers, "access-control-request-headers"),
|
||||
));
|
||||
}
|
||||
if request.method != "POST" {
|
||||
return Err(http_response(
|
||||
"405 Method Not Allowed",
|
||||
"text/plain",
|
||||
b"method not allowed",
|
||||
));
|
||||
}
|
||||
let content_type = header_value(&request.headers, "content-type").unwrap_or("");
|
||||
let body_text = String::from_utf8_lossy(&request.body);
|
||||
let normalized_xml_body = trim_xml_prelude(body_text.trim_start_matches('\u{feff}'));
|
||||
let is_xml = contains_ascii_case_insensitive(content_type, "xml")
|
||||
|| path.ends_with(".xml")
|
||||
|| (path == "/rpc" && looks_like_xmlrpc_request_body(&body_text))
|
||||
|| request.body.starts_with(b"<?xml")
|
||||
|| body_text.trim_start().starts_with("<?xml");
|
||||
if is_xml {
|
||||
if !config.enable_xml_rpc {
|
||||
return Err(http_response(
|
||||
"404 Not Found",
|
||||
"text/plain",
|
||||
b"xml-rpc disabled",
|
||||
));
|
||||
}
|
||||
let request = match xmlrpc_method_call_from_xml(normalized_xml_body) {
|
||||
Ok(request) => request,
|
||||
Err(error) => {
|
||||
return Err(http_response(
|
||||
"400 Bad Request",
|
||||
"text/plain",
|
||||
error.as_bytes(),
|
||||
));
|
||||
}
|
||||
};
|
||||
Ok(Some(PreparedHttpRpcRequest::Xml(request)))
|
||||
} else {
|
||||
if !config.enable_json_rpc {
|
||||
return Err(http_response(
|
||||
"404 Not Found",
|
||||
"text/plain",
|
||||
b"json-rpc disabled",
|
||||
));
|
||||
}
|
||||
let payload = match jsonrpc_payload_from_json(&body_text) {
|
||||
Ok(payload) => payload,
|
||||
Err(error) => {
|
||||
let response = JsonRpcResponse::error(
|
||||
None,
|
||||
RpcError {
|
||||
code: if error.contains("must not be empty") {
|
||||
RpcErrorCode::InvalidRequest
|
||||
} else {
|
||||
RpcErrorCode::ParseError
|
||||
},
|
||||
kind: crate::model::RpcErrorKind::InvalidParams,
|
||||
message: error,
|
||||
},
|
||||
);
|
||||
let body = jsonrpc_response_to_json(&response)
|
||||
.unwrap_or_else(|render_error| format!(r#"{{"error":"{render_error}"}}"#));
|
||||
return Err(rpc_http_response(
|
||||
config,
|
||||
"200 OK",
|
||||
"application/json",
|
||||
body.as_bytes(),
|
||||
));
|
||||
}
|
||||
};
|
||||
Ok(Some(PreparedHttpRpcRequest::Json(payload)))
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns whether `needle` appears in `haystack`, ignoring ASCII case.
|
||||
pub(super) fn contains_ascii_case_insensitive(haystack: &str, needle: &str) -> bool {
|
||||
if needle.is_empty() {
|
||||
return true;
|
||||
}
|
||||
haystack
|
||||
.as_bytes()
|
||||
.windows(needle.len())
|
||||
.any(|window| window.eq_ignore_ascii_case(needle.as_bytes()))
|
||||
}
|
||||
|
||||
/// Renders a prepared HTTP RPC request once dispatcher access has been acquired.
|
||||
fn render_rpc_http_dispatch_response(
|
||||
dispatcher: &mut InProcessRpcDispatcher,
|
||||
config: &RpcServerConfig,
|
||||
prepared: PreparedHttpRpcRequest,
|
||||
) -> Vec<u8> {
|
||||
match prepared {
|
||||
PreparedHttpRpcRequest::Xml(request) => {
|
||||
let response = dispatch_xml_request(dispatcher, config, request);
|
||||
let body = xmlrpc_method_response_to_xml(&response);
|
||||
rpc_http_response(config, "200 OK", "text/xml", body.as_bytes())
|
||||
}
|
||||
PreparedHttpRpcRequest::Json(payload) => {
|
||||
render_json_http_dispatch_response(dispatcher, config, payload)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders a prepared JSON-RPC HTTP request once dispatcher access has been acquired.
|
||||
fn render_json_http_dispatch_response(
|
||||
dispatcher: &mut InProcessRpcDispatcher,
|
||||
config: &RpcServerConfig,
|
||||
payload: JsonRpcPayload,
|
||||
) -> Vec<u8> {
|
||||
match payload {
|
||||
JsonRpcPayload::Single(request) => {
|
||||
let response = dispatch_json_request(dispatcher, config, request);
|
||||
if response.id.is_none() {
|
||||
return http_no_content_response();
|
||||
}
|
||||
let body = jsonrpc_response_to_json(&response)
|
||||
.unwrap_or_else(|error| format!(r#"{{"error":"{error}"}}"#));
|
||||
rpc_http_response(config, "200 OK", "application/json", body.as_bytes())
|
||||
}
|
||||
JsonRpcPayload::Batch(items) => {
|
||||
let mut responses = Vec::new();
|
||||
for item in items {
|
||||
match item {
|
||||
Ok(request) => {
|
||||
let response = dispatch_json_request(dispatcher, config, request);
|
||||
if response.id.is_some() {
|
||||
responses.push(response);
|
||||
}
|
||||
}
|
||||
Err(error) => responses.push(JsonRpcResponse::error(
|
||||
None,
|
||||
RpcError {
|
||||
code: RpcErrorCode::InvalidRequest,
|
||||
kind: crate::model::RpcErrorKind::InvalidParams,
|
||||
message: error,
|
||||
},
|
||||
)),
|
||||
}
|
||||
}
|
||||
let body = jsonrpc_batch_response_to_json(&responses)
|
||||
.unwrap_or_else(|error| format!(r#"{{"error":"{error}"}}"#));
|
||||
rpc_http_response(config, "200 OK", "application/json", body.as_bytes())
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user