wip: preserve two-node IndeeHub UAT fixes

This commit is contained in:
archipelago
2026-10-09 21:17:08 -04:00
parent 54f1ec8322
commit 5f01efda61
10 changed files with 276 additions and 34 deletions
+64 -2
View File
@@ -338,6 +338,20 @@ impl ApiHandler {
origin == format!("http://{host}") || origin == format!("https://{host}")
}
// Browsers do not send Origin on a same-origin GET. Fetch Metadata is a
// browser-controlled header and distinguishes the dashboard from an app on
// another port, which is only same-site. Mutating requests and WebSockets
// still require the exact Origin above.
fn has_terminal_read_origin(headers: &hyper::HeaderMap) -> bool {
if headers.contains_key(hyper::header::ORIGIN) {
return Self::has_terminal_origin(headers);
}
headers
.get("sec-fetch-site")
.and_then(|value| value.to_str().ok())
== Some("same-origin")
}
/// Permissive origin check for the share-to-mesh iframe intent: any scheme
/// http(s):// followed by the configured host_ip, optionally `:port`. Apps
/// proxied under other ports (APP_PORTS) call this from within the same
@@ -533,7 +547,25 @@ impl ApiHandler {
match (method, path.as_str()) {
// RPC — auth is handled inside rpc handler per-method
(Method::POST, "/rpc/v1") => self.rpc_handler.clone().handle(req_with_bytes).await,
(Method::POST, "/rpc/v1") => {
let origin = self.app_cors_origin(&headers);
let mut response = self.rpc_handler.clone().handle(req_with_bytes).await?;
if !origin.is_empty() {
response.headers_mut().insert(
"access-control-allow-origin",
hyper::header::HeaderValue::from_str(&origin)?,
);
response.headers_mut().insert(
"access-control-allow-credentials",
hyper::header::HeaderValue::from_static("true"),
);
response.headers_mut().insert(
hyper::header::VARY,
hyper::header::HeaderValue::from_static("Origin"),
);
}
Ok(response)
}
// AIUI model proxy — session-gated forwarder to Claude/Ollama,
// replacing the unauthenticated claude-api-proxy.py sidecar and
@@ -586,7 +618,7 @@ impl ApiHandler {
if !self.is_authenticated(&headers).await {
return Ok(Self::unauthorized());
}
if !Self::has_terminal_origin(&headers) {
if !Self::has_terminal_read_origin(&headers) {
return Ok(build_response(
StatusCode::FORBIDDEN,
"application/json",
@@ -939,6 +971,18 @@ mod terminal_origin_tests {
ApiHandler::has_terminal_origin(&headers)
}
fn read_allowed(host: &str, origin: Option<&str>, fetch_site: Option<&str>) -> bool {
let mut headers = HeaderMap::new();
headers.insert(HOST, HeaderValue::from_str(host).unwrap());
if let Some(origin) = origin {
headers.insert(ORIGIN, HeaderValue::from_str(origin).unwrap());
}
if let Some(fetch_site) = fetch_site {
headers.insert("sec-fetch-site", HeaderValue::from_str(fetch_site).unwrap());
}
ApiHandler::has_terminal_read_origin(&headers)
}
#[test]
fn terminal_accepts_only_the_exact_dashboard_authority() {
assert!(allowed("node.local", Some("https://node.local")));
@@ -949,4 +993,22 @@ mod terminal_origin_tests {
assert!(!allowed("node.local", Some("null")));
assert!(!allowed("node.local", None));
}
#[test]
fn terminal_read_accepts_same_origin_fetch_but_not_same_site_apps() {
assert!(read_allowed("node.local", None, Some("same-origin")));
assert!(!read_allowed("node.local", None, Some("same-site")));
assert!(!read_allowed("node.local", None, Some("cross-site")));
assert!(!read_allowed("node.local", None, None));
assert!(!read_allowed(
"node.local",
Some("https://evil.example"),
Some("same-site")
));
assert!(!read_allowed(
"node.local",
Some("https://evil.example"),
Some("same-origin")
));
}
}
+5 -1
View File
@@ -146,7 +146,11 @@ impl RpcHandler {
.require_fips()
.single_delivery()
.timeout(std::time::Duration::from_secs(20))
.send_content_json(&self.config.data_dir, &binding.contract.seller_did, &body)
.send_rental_control_json(
&self.config.data_dir,
&binding.contract.seller_did,
&body,
)
.await?;
anyhow::ensure!(
response.status().is_success(),
+69 -1
View File
@@ -35,6 +35,23 @@ use tokio::net::UdpSocket;
/// path filter can restrict the exposed surface.
pub const PEER_PORT: u16 = 5679;
fn is_rental_control_route(path: &str) -> bool {
let parts: Vec<_> = path.split('/').collect();
let valid_id = |value: &str| {
!value.is_empty()
&& value
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_')
};
parts.len() == 6
&& parts[0].is_empty()
&& parts[1] == "content"
&& valid_id(parts[2])
&& parts[3] == "rental"
&& valid_id(parts[4])
&& matches!(parts[5], "prepare" | "start")
}
/// Whether a FIPS-side HTTP status should trigger a fall-back to Tor in
/// `Auto` mode. A `404` over FIPS often means the peer's mesh listener
/// doesn't expose that path (e.g. a peer on an older build with a stricter
@@ -527,8 +544,27 @@ impl<'a> PeerRequest<'a> {
request.send_encoded_json(&encoded).await
}
/// Rental preparation/start carries an already-settled capability, never a
/// payment token. Authenticate the exact control route without relaxing the
/// purchase-only guard above or replaying any purchase on a retry.
pub(crate) async fn send_rental_control_json<B: serde::Serialize>(
self,
data_dir: &std::path::Path,
expected_seller: &str,
body: &B,
) -> Result<(reqwest::Response, crate::transport::TransportKind)> {
anyhow::ensure!(
is_rental_control_route(self.path),
"Not a rental control route"
);
let (request, encoded) = self
.prepare_authenticated_content_json(data_dir, expected_seller, body)
.await?;
request.send_encoded_json(&encoded).await
}
async fn prepare_content_json<B: serde::Serialize>(
mut self,
self,
data_dir: &std::path::Path,
expected_seller: &str,
body: &B,
@@ -537,6 +573,16 @@ impl<'a> PeerRequest<'a> {
self.path.starts_with("/content/purchase/"),
"Not a purchase route"
);
self.prepare_authenticated_content_json(data_dir, expected_seller, body)
.await
}
async fn prepare_authenticated_content_json<B: serde::Serialize>(
mut self,
data_dir: &std::path::Path,
expected_seller: &str,
body: &B,
) -> Result<(Self, Vec<u8>)> {
let peer = crate::federation::load_unique_payment_peer(data_dir, self.onion_host).await?;
anyhow::ensure!(
peer.did == expected_seller,
@@ -887,6 +933,28 @@ impl<'a> PeerRequest<'a> {
mod tests {
use super::*;
#[test]
fn rental_control_routes_are_exact_and_cannot_be_purchase_routes() {
assert!(is_rental_control_route(
"/content/registered_abc/rental/1234-5678/prepare"
));
assert!(is_rental_control_route(
"/content/registered_abc/rental/1234-5678/start"
));
for path in [
"/content/purchase/abc",
"/content/registered_abc/rental/1234-5678/settle",
"/content/registered_abc/rental/1234-5678/prepare/extra",
"/content/../rental/1234-5678/prepare",
"/content/registered_abc/rental/1234-5678/prepare?token=x",
] {
assert!(
!is_rental_control_route(path),
"unexpected rental control route: {path}"
);
}
}
#[tokio::test]
async fn purchase_post_serializes_once_and_binds_the_actual_bytes_to_the_peer() {
use std::sync::atomic::{AtomicUsize, Ordering};