use std::collections::HashMap;
use std::net::SocketAddr;
use std::sync::Arc;
use tokio::time::Instant;
use dashmap::DashMap;
use axum::extract::Path;
use axum::response::IntoResponse;
use axum::{Extension, Router};
use freenet_stdlib::client_api::{ClientError, ErrorKind, HostResponse};
use freenet_stdlib::prelude::ContractInstanceId;
use futures::FutureExt;
use futures::future::BoxFuture;
use tokio::sync::mpsc;
use tracing::instrument;
use crate::client_events::{ClientEventsProxy, ClientId, OpenRequest};
use crate::server::HostCallbackResult;
use super::{
ApiVersion, AuthToken, ClientConnection, errors::WebSocketApiError, home_page, path_handlers,
};
const SHELL_PAGE_CSP: &str = "default-src 'none'; script-src 'unsafe-inline'; frame-src 'self'; style-src 'unsafe-inline'; img-src data:; connect-src 'self' ws: wss:";
fn sandbox_csp_for_origin(origin: &str) -> String {
format!(
"default-src {origin} 'unsafe-inline' 'unsafe-eval' blob: data:; connect-src {origin} blob: data:"
)
}
fn sandbox_origin_from_headers(headers: &axum::http::HeaderMap) -> String {
let scheme = headers
.get("x-forwarded-proto")
.and_then(|v| v.to_str().ok())
.map(|v| v.split(',').next().unwrap_or(v).trim())
.filter(|v| v.eq_ignore_ascii_case("https"))
.map(|_| "https")
.unwrap_or("http");
headers
.get("x-forwarded-host")
.or_else(|| headers.get(axum::http::header::HOST))
.and_then(|h| h.to_str().ok())
.map(|host| host.split(',').next().unwrap_or(host).trim())
.filter(|host| !host.is_empty())
.map(|host| format!("{scheme}://{host}"))
.unwrap_or_else(|| "'self'".to_string())
}
pub(crate) mod hosted_export;
mod permission_prompts;
mod v1;
mod v2;
#[derive(Clone)]
pub(super) struct HttpClientApiRequest(mpsc::Sender<ClientConnection>);
impl std::ops::Deref for HttpClientApiRequest {
type Target = mpsc::Sender<ClientConnection>;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl HttpClientApiRequest {
#[cfg(test)]
pub(super) fn from_sender(sender: mpsc::Sender<ClientConnection>) -> Self {
Self(sender)
}
}
#[derive(Clone, Debug)]
pub struct OriginContract {
pub contract_id: ContractInstanceId,
pub client_id: ClientId,
pub last_accessed: Instant,
}
impl OriginContract {
pub fn new(contract_id: ContractInstanceId, client_id: ClientId) -> Self {
Self {
contract_id,
client_id,
last_accessed: Instant::now(),
}
}
}
pub type OriginContractMap = Arc<DashMap<AuthToken, OriginContract>>;
pub struct HttpClientApi {
pub(crate) origin_contracts: OriginContractMap,
proxy_server_request: mpsc::Receiver<ClientConnection>,
response_channels: HashMap<ClientId, mpsc::UnboundedSender<HostCallbackResult>>,
export_op_manager: hosted_export::ExportOpManagerHandle,
}
impl HttpClientApi {
pub fn as_router(socket: &SocketAddr) -> (Self, Router) {
let origin_contracts = Arc::new(DashMap::new());
Self::as_router_with_origin_contracts(
socket,
origin_contracts,
crate::contract::user_input::pending_prompts(),
)
}
pub(crate) fn as_router_with_origin_contracts(
socket: &SocketAddr,
origin_contracts: OriginContractMap,
pending_prompts: crate::contract::user_input::PendingPrompts,
) -> (Self, Router) {
let localhost = socket.ip().is_loopback() || socket.ip().is_unspecified();
let contract_web_path = std::env::temp_dir().join("freenet").join("webs");
std::fs::create_dir_all(&contract_web_path).unwrap_or_else(|e| {
panic!(
"Failed to create contract web directory at {}: {}. \
This may happen if {} was created by another user. \
Try: sudo rm -rf {}",
contract_web_path.display(),
e,
std::env::temp_dir().join("freenet").display(),
std::env::temp_dir().join("freenet").display(),
)
});
let (proxy_request_sender, request_to_server) = mpsc::channel(1);
let config = Config { localhost };
let export_op_manager = hosted_export::ExportOpManagerHandle::default();
let router = Router::new()
.route("/", axum::routing::get(home_page::homepage))
.route(
"/peer/{address}",
axum::routing::get(home_page::peer_detail),
)
.merge(v1::routes(config.clone()))
.merge(v2::routes(config))
.merge(hosted_export::routes(ApiVersion::V1))
.merge(hosted_export::routes(ApiVersion::V2))
.merge(permission_prompts::routes())
.layer(Extension(origin_contracts.clone()))
.layer(Extension(pending_prompts))
.layer(Extension(export_op_manager.clone()))
.layer(Extension(HttpClientApiRequest(proxy_request_sender)));
(
Self {
proxy_server_request: request_to_server,
origin_contracts,
response_channels: HashMap::new(),
export_op_manager,
},
router,
)
}
pub fn origin_contracts(&self) -> &OriginContractMap {
&self.origin_contracts
}
}
#[derive(Clone, Debug)]
struct Config {
localhost: bool,
}
#[instrument(level = "debug")]
async fn home() -> axum::response::Response {
axum::response::Response::default()
}
async fn web_home(
Path(key): Path<String>,
Extension(rs): Extension<HttpClientApiRequest>,
axum::extract::State(config): axum::extract::State<Config>,
req_headers: axum::http::HeaderMap,
api_version: ApiVersion,
query_string: Option<String>,
hosted_mode: bool,
) -> Result<axum::response::Response, WebSocketApiError> {
let is_sandbox = query_string
.as_ref()
.map(|qs| qs.split('&').any(|p| p == "__sandbox=1"))
.unwrap_or(false);
if is_sandbox {
return serve_sandbox_response(key, api_version, None, &req_headers, rs).await;
}
render_shell_response(
key,
&config,
api_version,
query_string,
None,
rs,
hosted_mode,
)
.await
}
async fn render_shell_response(
key: String,
config: &Config,
api_version: ApiVersion,
query_string: Option<String>,
sub_path: Option<&str>,
rs: HttpClientApiRequest,
hosted_mode: bool,
) -> Result<axum::response::Response, WebSocketApiError> {
use headers::{Header, HeaderMapExt};
let token = AuthToken::generate();
let auth_header = headers::Authorization::<headers::authorization::Bearer>::name().to_string();
let version_prefix = api_version.prefix();
let cookie = cookie::Cookie::build((auth_header, format!("Bearer {}", token.as_str())))
.path(format!("/{version_prefix}/contract/web/{key}"))
.same_site(cookie::SameSite::Strict)
.max_age(cookie::time::Duration::days(1))
.secure(!config.localhost)
.http_only(false)
.build();
let token_header = headers::Authorization::bearer(token.as_str()).unwrap();
let contract_response = path_handlers::contract_home(
key,
rs,
token.clone(),
api_version,
query_string,
sub_path,
hosted_mode,
)
.await?;
let mut response = contract_response.into_response();
response.headers_mut().typed_insert(token_header);
response.headers_mut().insert(
headers::SetCookie::name(),
headers::HeaderValue::from_str(&cookie.to_string()).unwrap(),
);
response.headers_mut().insert(
axum::http::header::CONTENT_SECURITY_POLICY,
axum::http::HeaderValue::from_static(SHELL_PAGE_CSP),
);
response.headers_mut().insert(
axum::http::header::X_FRAME_OPTIONS,
axum::http::HeaderValue::from_static("DENY"),
);
response.headers_mut().insert(
axum::http::header::X_CONTENT_TYPE_OPTIONS,
axum::http::HeaderValue::from_static("nosniff"),
);
Ok(response)
}
fn hosted_mode_or_default(ext: Option<Extension<crate::server::HostedMode>>) -> bool {
ext.map(|Extension(hm)| hm.0).unwrap_or(false)
}
#[allow(clippy::too_many_arguments)]
async fn web_subpages(
key: String,
last_path: String,
api_version: ApiVersion,
query_string: Option<String>,
req_headers: axum::http::HeaderMap,
config: &Config,
request_sender: HttpClientApiRequest,
hosted_mode: bool,
) -> Result<axum::response::Response, WebSocketApiError> {
let is_sandbox = query_string
.as_ref()
.map(|qs| qs.split('&').any(|p| p == "__sandbox=1"))
.unwrap_or(false);
if is_sandbox && is_html_page(&last_path) {
return serve_sandbox_response(
key,
api_version,
Some(&last_path),
&req_headers,
request_sender,
)
.await;
}
let fetch_dest = req_headers
.get("sec-fetch-dest")
.and_then(|v| v.to_str().ok())
.unwrap_or("");
if should_serve_shell_for_subpage(is_sandbox, &last_path, fetch_dest) {
return render_shell_response(
key,
config,
api_version,
query_string,
Some(&last_path),
request_sender,
hosted_mode,
)
.await;
}
let version_prefix = api_version.prefix();
let full_path: String = format!("/{version_prefix}/contract/web/{key}/{last_path}");
path_handlers::variable_content(key, full_path, api_version, request_sender)
.await
.map_err(|e| *e)
.map(|r| {
let mut response = r.into_response();
add_sandbox_cors_headers(&mut response);
response
})
}
fn redirect_to_shell_root(
key: &str,
api_version: ApiVersion,
query_string: Option<&str>,
) -> Result<axum::response::Response, WebSocketApiError> {
let shell_url = build_canonical_shell_url(key, api_version, query_string)?;
Ok(axum::response::Redirect::to(&shell_url).into_response())
}
pub(super) fn build_canonical_shell_url(
key: &str,
api_version: ApiVersion,
query_string: Option<&str>,
) -> Result<String, WebSocketApiError> {
if key.is_empty() {
return Err(WebSocketApiError::InvalidParam {
error_cause: "empty contract key in redirect target".into(),
});
}
let _instance_id =
ContractInstanceId::from_bytes(key).map_err(|err| WebSocketApiError::InvalidParam {
error_cause: format!("invalid contract key in redirect target: {err}"),
})?;
let filtered_query = query_string
.map(|qs| {
qs.split('&')
.filter(|p| !p.is_empty())
.filter(|p| !is_sensitive_query_param(p))
.collect::<Vec<_>>()
.join("&")
})
.filter(|s| !s.is_empty());
let prefix = api_version.prefix();
Ok(match filtered_query {
Some(qs) => format!("/{prefix}/contract/web/{key}/?{qs}"),
None => format!("/{prefix}/contract/web/{key}/"),
})
}
fn is_sensitive_query_param(param: &str) -> bool {
param.starts_with("__sandbox") || param.starts_with("authToken")
}
fn should_serve_shell_for_subpage(is_sandbox: bool, last_path: &str, fetch_dest: &str) -> bool {
!is_sandbox && is_html_page(last_path) && fetch_dest == "document"
}
fn is_html_page(path: &str) -> bool {
let lower = path.to_lowercase();
lower.ends_with(".html")
|| lower.ends_with(".htm")
|| lower.ends_with('/')
|| !lower.contains('.')
}
async fn serve_sandbox_response(
key: String,
api_version: ApiVersion,
sub_path: Option<&str>,
req_headers: &axum::http::HeaderMap,
request_sender: HttpClientApiRequest,
) -> Result<axum::response::Response, WebSocketApiError> {
let fetch_dest = req_headers
.get("sec-fetch-dest")
.and_then(|v| v.to_str().ok())
.unwrap_or("");
if fetch_dest == "document" {
return redirect_to_shell_root(&key, api_version, None);
}
let contract_response =
path_handlers::serve_sandbox_content(key, api_version, sub_path, request_sender).await?;
let mut response = contract_response.into_response();
add_sandbox_cors_headers(&mut response);
let local_api_origin = sandbox_origin_from_headers(req_headers);
let csp = sandbox_csp_for_origin(&local_api_origin);
if let Ok(csp_value) = axum::http::HeaderValue::from_str(&csp) {
response
.headers_mut()
.insert(axum::http::header::CONTENT_SECURITY_POLICY, csp_value);
}
Ok(response)
}
fn add_sandbox_cors_headers(response: &mut axum::response::Response) {
response.headers_mut().insert(
axum::http::header::ACCESS_CONTROL_ALLOW_ORIGIN,
axum::http::HeaderValue::from_static("*"),
);
response.headers_mut().insert(
axum::http::header::X_CONTENT_TYPE_OPTIONS,
axum::http::HeaderValue::from_static("nosniff"),
);
}
impl ClientEventsProxy for HttpClientApi {
#[instrument(level = "debug", skip(self))]
fn recv(&mut self) -> BoxFuture<'_, Result<OpenRequest<'static>, ClientError>> {
async move {
while let Some(msg) = self.proxy_server_request.recv().await {
match msg {
ClientConnection::NewConnection {
callbacks,
assigned_token,
} => {
let cli_id = ClientId::next();
callbacks
.send(HostCallbackResult::NewId { id: cli_id })
.map_err(|_e| ErrorKind::NodeUnavailable)?;
if let Some((assigned_token, contract)) = assigned_token {
let origin = OriginContract::new(contract, cli_id);
self.origin_contracts.insert(assigned_token.clone(), origin);
tracing::debug!(
?assigned_token,
?contract,
?cli_id,
"Stored assigned token in origin_contracts map"
);
}
self.response_channels.insert(cli_id, callbacks);
continue;
}
ClientConnection::Request {
client_id,
req,
auth_token,
origin_contract,
user_context,
..
} => {
return Ok(OpenRequest::new(client_id, req)
.with_token(auth_token)
.with_origin_contract(origin_contract)
.with_user_context(user_context));
}
}
}
tracing::warn!("Shutting down HTTP client API receiver");
Err(ErrorKind::Disconnect.into())
}
.boxed()
}
#[instrument(level = "debug", skip(self))]
fn send(
&mut self,
id: ClientId,
result: Result<HostResponse, ClientError>,
) -> BoxFuture<'_, Result<(), ClientError>> {
async move {
if let Some(ch) = self.response_channels.remove(&id) {
let should_rm = result
.as_ref()
.map_err(|err| matches!(err.kind(), ErrorKind::Disconnect))
.err()
.unwrap_or(false);
if ch.send(HostCallbackResult::Result { id, result }).is_ok() && !should_rm {
self.response_channels.insert(id, ch);
} else {
tracing::info!("dropped connection to client #{id}");
}
} else {
tracing::warn!("client: {id} not found");
}
Ok(())
}
.boxed()
}
fn set_op_manager(&self, op_manager: &dyn std::any::Any) {
if let Some(op_manager) = op_manager.downcast_ref::<Arc<crate::node::OpManager>>() {
self.export_op_manager.set(op_manager);
} else {
tracing::error!(
"HttpClientApi::set_op_manager called with a non-OpManager argument; \
hosted export will be unavailable on this node"
);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn dead_request_sender() -> HttpClientApiRequest {
let (tx, _rx) = mpsc::channel(1);
HttpClientApiRequest::from_sender(tx)
}
#[test]
fn is_html_page_detects_html_extensions() {
assert!(is_html_page("page.html"));
assert!(is_html_page("page.HTML"));
assert!(is_html_page("page.htm"));
assert!(is_html_page("dir/page.html"));
}
#[test]
fn hosted_mode_or_default_absent_is_off() {
assert!(
!hosted_mode_or_default(None),
"absent HostedMode extension must map to hosted-off"
);
assert!(
!hosted_mode_or_default(Some(Extension(crate::server::HostedMode(false)))),
"present HostedMode(false) must stay off"
);
assert!(
hosted_mode_or_default(Some(Extension(crate::server::HostedMode(true)))),
"present HostedMode(true) must be honored"
);
}
#[tokio::test]
async fn contract_web_route_does_not_require_hosted_mode_extension() {
use axum::body::to_bytes;
use tower::ServiceExt;
let key = "EqJ5YpEEV3XLqEvKWLQHFhGAac2qXzSUoE6k2zbdnXBr";
for uri in [
format!("/v1/contract/web/{key}/"),
format!("/v2/contract/web/{key}/"),
] {
let (api, router) = HttpClientApi::as_router(&"127.0.0.1:0".parse().unwrap());
drop(api);
let req = axum::http::Request::builder()
.uri(&uri)
.body(axum::body::Body::empty())
.unwrap();
let resp =
tokio::time::timeout(std::time::Duration::from_secs(10), router.oneshot(req))
.await
.unwrap_or_else(|_| panic!("{uri} shell route hung instead of returning"))
.unwrap();
let status = resp.status();
let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
let body = String::from_utf8_lossy(&body);
assert!(
!body.contains("Missing request extension"),
"{uri} must tolerate an absent HostedMode extension, not 500 on \
the missing extractor; got status {status}, body: {body}"
);
}
}
#[test]
fn is_html_page_detects_directory_style_paths() {
assert!(is_html_page("news/"));
assert!(is_html_page("about/team/"));
}
#[test]
fn is_html_page_detects_extensionless_paths() {
assert!(is_html_page("news"));
assert!(is_html_page("about/team"));
}
#[test]
fn is_html_page_rejects_non_html_files() {
assert!(!is_html_page("app.js"));
assert!(!is_html_page("style.css"));
assert!(!is_html_page("image.png"));
assert!(!is_html_page("assets/app.wasm"));
}
#[test]
fn shell_page_csp_allows_same_origin_and_websocket_connect() {
let csp = SHELL_PAGE_CSP;
let connect_src = csp
.split(';')
.map(str::trim)
.find(|d| d.starts_with("connect-src"))
.expect("connect-src directive present");
assert!(
connect_src.contains("'self'"),
"connect-src must include 'self' (regression #3842); got: {connect_src}"
);
assert!(
connect_src.contains("ws:"),
"connect-src must include ws:; got: {connect_src}"
);
assert!(
connect_src.contains("wss:"),
"connect-src must include wss:; got: {connect_src}"
);
assert!(
csp.contains("default-src 'none'"),
"default-src should stay 'none' for defence in depth; got: {csp}"
);
}
#[test]
fn sandbox_csp_includes_explicit_origin_in_connect_src() {
let csp = sandbox_csp_for_origin("http://127.0.0.1:7509");
let connect_src = csp
.split(';')
.map(str::trim)
.find(|d| d.starts_with("connect-src"))
.expect("connect-src directive present");
assert!(
connect_src.contains("http://127.0.0.1:7509"),
"sandbox connect-src must include the explicit local API origin; got: {connect_src}"
);
let default_src = csp
.split(';')
.map(str::trim)
.find(|d| d.starts_with("default-src"))
.expect("default-src directive present");
assert!(
default_src.contains("http://127.0.0.1:7509"),
"sandbox default-src must include the explicit local API origin; got: {default_src}"
);
assert!(connect_src.contains("blob:"));
assert!(connect_src.contains("data:"));
}
#[test]
fn subpage_serves_shell_for_top_level_html_document_load() {
assert!(should_serve_shell_for_subpage(false, "page2", "document"));
assert!(should_serve_shell_for_subpage(
false,
"about/team",
"document"
));
assert!(should_serve_shell_for_subpage(false, "news/", "document"));
assert!(should_serve_shell_for_subpage(
false,
"index.html",
"document"
));
}
#[test]
fn subpage_does_not_serve_shell_for_sub_resource_fetches() {
for path in ["app.js", "style.css", "app.wasm", "logo.png"] {
assert!(
!should_serve_shell_for_subpage(false, path, "document"),
"{path} must not be served the shell"
);
}
for dest in ["iframe", "empty", "script", "style", "image", ""] {
assert!(
!should_serve_shell_for_subpage(false, "page2", dest),
"Sec-Fetch-Dest={dest} must not be served the shell"
);
}
}
#[test]
fn subpage_does_not_serve_shell_for_sandbox_requests() {
assert!(!should_serve_shell_for_subpage(true, "page2", "iframe"));
assert!(!should_serve_shell_for_subpage(true, "page2", "document"));
}
fn valid_contract_key_b58() -> String {
use freenet_stdlib::prelude::ContractInstanceId;
let bytes = [0u8; 32];
ContractInstanceId::new(bytes).to_string()
}
#[test]
fn redirect_to_shell_root_drops_sensitive_params_and_preserves_others() {
let key = valid_contract_key_b58();
let query = Some("authToken=attacker&invite=abc&__sandbox=1&room=42");
let resp = redirect_to_shell_root(&key, ApiVersion::V1, query)
.expect("valid key should not fail validation");
let loc = resp
.headers()
.get(axum::http::header::LOCATION)
.expect("redirect must set Location")
.to_str()
.unwrap()
.to_string();
assert_eq!(
loc,
format!("/v1/contract/web/{key}/?invite=abc&room=42"),
"sensitive params must be stripped, harmless ones preserved in order"
);
assert!(
!loc.contains("authToken"),
"authToken must never appear in redirect target"
);
assert!(
!loc.contains("__sandbox"),
"__sandbox must never appear in redirect target"
);
}
#[test]
fn redirect_to_shell_root_omits_query_when_empty() {
let key = valid_contract_key_b58();
let resp = redirect_to_shell_root(&key, ApiVersion::V1, None).unwrap();
let loc = resp
.headers()
.get(axum::http::header::LOCATION)
.unwrap()
.to_str()
.unwrap()
.to_string();
assert_eq!(loc, format!("/v1/contract/web/{key}/"));
assert!(!loc.contains('?'));
let resp =
redirect_to_shell_root(&key, ApiVersion::V1, Some("authToken=x&__sandbox=1")).unwrap();
let loc = resp
.headers()
.get(axum::http::header::LOCATION)
.unwrap()
.to_str()
.unwrap()
.to_string();
assert_eq!(loc, format!("/v1/contract/web/{key}/"));
}
#[test]
fn redirect_to_shell_root_uses_303_see_other_for_fragment_preservation() {
let key = valid_contract_key_b58();
let resp = redirect_to_shell_root(&key, ApiVersion::V1, None).unwrap();
assert_eq!(resp.status(), axum::http::StatusCode::SEE_OTHER);
}
#[test]
fn redirect_to_shell_root_rejects_invalid_key_instead_of_panicking() {
assert!(matches!(
redirect_to_shell_root("not-a-real-contract-key", ApiVersion::V1, None),
Err(WebSocketApiError::InvalidParam { .. })
));
assert!(matches!(
redirect_to_shell_root("AAAA\r\nInjected: x", ApiVersion::V1, None),
Err(WebSocketApiError::InvalidParam { .. })
));
assert!(matches!(
redirect_to_shell_root("", ApiVersion::V1, None),
Err(WebSocketApiError::InvalidParam { .. })
));
}
fn localhost_config() -> Config {
Config { localhost: true }
}
#[tokio::test]
async fn web_subpages_serves_shell_for_top_level_document_load() {
let key = valid_contract_key_b58();
let mut headers = axum::http::HeaderMap::new();
headers.insert("sec-fetch-dest", "document".parse().unwrap());
let (tx, mut rx) = mpsc::channel(4);
let sender = HttpClientApiRequest::from_sender(tx);
let handler = {
let key = key.clone();
tokio::spawn(async move {
web_subpages(
key,
"page2".to_string(),
ApiVersion::V1,
Some("authToken=attacker&invite=abc".to_string()),
headers,
&localhost_config(),
sender,
false,
)
.await
.map(|_| ())
})
};
let msg = tokio::time::timeout(std::time::Duration::from_secs(5), rx.recv())
.await
.expect("shell render must emit on the channel (not a synchronous redirect)")
.expect("channel must stay open for the send");
assert!(
matches!(msg, ClientConnection::NewConnection { .. }),
"top-level document load must route into the shell render path \
(NewConnection), not a 303 redirect; got: {msg:?}"
);
handler.abort();
let mut headers = axum::http::HeaderMap::new();
headers.insert("sec-fetch-dest", "document".parse().unwrap());
let resp = web_subpages(
key.clone(),
"page2".to_string(),
ApiVersion::V1,
Some("__sandbox=1".to_string()),
headers,
&localhost_config(),
dead_request_sender(),
false,
)
.await
.expect("sandbox document load must redirect, not error");
assert_eq!(resp.status(), axum::http::StatusCode::SEE_OTHER);
let res = web_subpages(
key.clone(),
"page2".to_string(),
ApiVersion::V1,
None,
axum::http::HeaderMap::new(),
&localhost_config(),
dead_request_sender(),
false,
)
.await;
match res {
Ok(resp) => assert_ne!(resp.status(), axum::http::StatusCode::SEE_OTHER),
Err(_) => {
}
}
}
#[test]
fn web_subpages_sandbox_branch_runs_before_shell_branch() {
let src = include_str!("client_api.rs");
let sandbox_idx = src
.find("if is_sandbox && is_html_page(&last_path) {")
.expect("sandbox short-circuit marker present in web_subpages");
let shell_idx = src
.find("should_serve_shell_for_subpage(is_sandbox")
.expect("subpage shell-render marker present in web_subpages");
assert!(
sandbox_idx < shell_idx,
"sandbox branch must run before the top-level-document shell render: \
reordering would break sandbox iframe sub-page loads"
);
assert!(!should_serve_shell_for_subpage(true, "page2", "document"));
assert!(!should_serve_shell_for_subpage(true, "news/", "document"));
assert!(!should_serve_shell_for_subpage(
true,
"index.html",
"iframe"
));
}
#[test]
fn sandbox_csp_adapts_to_remote_host_origin() {
let csp = sandbox_csp_for_origin("http://192.168.1.42:7509");
assert!(csp.contains("http://192.168.1.42:7509"));
assert!(!csp.contains("127.0.0.1"));
}
fn hdrs(pairs: &[(&str, &str)]) -> axum::http::HeaderMap {
let mut h = axum::http::HeaderMap::new();
for (k, v) in pairs {
h.insert(
axum::http::HeaderName::from_bytes(k.as_bytes()).unwrap(),
axum::http::HeaderValue::from_str(v).unwrap(),
);
}
h
}
#[test]
fn sandbox_origin_honors_forwarded_https_scheme() {
let origin = sandbox_origin_from_headers(&hdrs(&[
("host", "127.0.0.1:7509"),
("x-forwarded-proto", "https"),
("x-forwarded-host", "localhost:8443"),
]));
assert_eq!(origin, "https://localhost:8443");
let csp = sandbox_csp_for_origin(&origin);
assert!(csp.contains("https://localhost:8443"));
assert!(!csp.contains("http://localhost:8443"));
}
#[test]
fn sandbox_origin_forwarded_proto_without_forwarded_host_uses_host() {
let origin = sandbox_origin_from_headers(&hdrs(&[
("host", "try.example.org"),
("x-forwarded-proto", "https"),
]));
assert_eq!(origin, "https://try.example.org");
}
#[test]
fn sandbox_origin_direct_connection_is_http_host() {
assert_eq!(
sandbox_origin_from_headers(&hdrs(&[("host", "127.0.0.1:7509")])),
"http://127.0.0.1:7509"
);
assert_eq!(
sandbox_origin_from_headers(&hdrs(&[
("host", "127.0.0.1:7509"),
("x-forwarded-proto", "http"),
])),
"http://127.0.0.1:7509"
);
}
#[test]
fn sandbox_origin_no_host_falls_back_to_self() {
assert_eq!(sandbox_origin_from_headers(&hdrs(&[])), "'self'");
}
#[test]
fn sandbox_origin_uses_first_of_comma_separated_forwarded_values() {
let origin = sandbox_origin_from_headers(&hdrs(&[
("host", "127.0.0.1:7509"),
("x-forwarded-proto", "https, http"),
("x-forwarded-host", "public.example, proxy.internal"),
]));
assert_eq!(origin, "https://public.example");
}
}