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:"
)
}
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
}
}
#[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>>,
}
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 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(permission_prompts::routes())
.layer(Extension(origin_contracts.clone()))
.layer(Extension(pending_prompts))
.layer(Extension(HttpClientApiRequest(proxy_request_sender)));
(
Self {
proxy_server_request: request_to_server,
origin_contracts,
response_channels: HashMap::new(),
},
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>,
) -> Result<axum::response::Response, WebSocketApiError> {
use headers::{Header, HeaderMapExt};
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).await;
}
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).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)
}
async fn web_subpages(
key: String,
last_path: String,
api_version: ApiVersion,
query_string: Option<String>,
req_headers: axum::http::HeaderMap,
) -> 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).await;
}
let fetch_dest = req_headers
.get("sec-fetch-dest")
.and_then(|v| v.to_str().ok())
.unwrap_or("");
if should_redirect_subpage_to_shell(is_sandbox, &last_path, fetch_dest) {
return redirect_to_shell_root(&key, api_version, query_string.as_deref());
}
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)
.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> {
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();
let shell_url = match filtered_query {
Some(qs) => format!("/{prefix}/contract/web/{key}/?{qs}"),
None => format!("/{prefix}/contract/web/{key}/"),
};
Ok(axum::response::Redirect::to(&shell_url).into_response())
}
fn is_sensitive_query_param(param: &str) -> bool {
param.starts_with("__sandbox") || param.starts_with("authToken")
}
fn should_redirect_subpage_to_shell(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,
) -> 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).await?;
let mut response = contract_response.into_response();
add_sandbox_cors_headers(&mut response);
let local_api_origin = req_headers
.get(axum::http::header::HOST)
.and_then(|h| h.to_str().ok())
.map(|host| format!("http://{host}"))
.unwrap_or_else(|| "'self'".to_string());
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,
..
} => {
return Ok(OpenRequest::new(client_id, req)
.with_token(auth_token)
.with_origin_contract(origin_contract));
}
}
}
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()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[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 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_redirects_top_level_html_document_load_to_shell() {
assert!(should_redirect_subpage_to_shell(false, "page2", "document"));
assert!(should_redirect_subpage_to_shell(
false,
"about/team",
"document"
));
assert!(should_redirect_subpage_to_shell(false, "news/", "document"));
assert!(should_redirect_subpage_to_shell(
false,
"index.html",
"document"
));
}
#[test]
fn subpage_does_not_redirect_sub_resource_fetches() {
for path in ["app.js", "style.css", "app.wasm", "logo.png"] {
assert!(
!should_redirect_subpage_to_shell(false, path, "document"),
"{path} must not be redirected"
);
}
for dest in ["iframe", "empty", "script", "style", "image", ""] {
assert!(
!should_redirect_subpage_to_shell(false, "page2", dest),
"Sec-Fetch-Dest={dest} must not be redirected"
);
}
}
#[test]
fn subpage_does_not_redirect_sandbox_requests() {
assert!(!should_redirect_subpage_to_shell(true, "page2", "iframe"));
assert!(!should_redirect_subpage_to_shell(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 { .. })
));
}
#[tokio::test]
async fn web_subpages_redirects_top_level_document_load_to_shell() {
let key = valid_contract_key_b58();
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("authToken=attacker&invite=abc".to_string()),
headers,
)
.await
.expect("redirect response must be Ok");
assert_eq!(resp.status(), axum::http::StatusCode::SEE_OTHER);
let loc = resp
.headers()
.get(axum::http::header::LOCATION)
.expect("Location header must be set on the redirect")
.to_str()
.unwrap()
.to_string();
assert_eq!(loc, format!("/v1/contract/web/{key}/?invite=abc"));
assert!(
!loc.contains("authToken"),
"authToken must be stripped on the redirect hop"
);
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,
)
.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(),
)
.await;
match res {
Ok(resp) => assert_ne!(resp.status(), axum::http::StatusCode::SEE_OTHER),
Err(_) => {
}
}
}
#[test]
fn web_subpages_sandbox_branch_runs_before_redirect_branch() {
let src = include_str!("client_api.rs");
let sandbox_idx = src
.find("serve_sandbox_response(key, api_version, Some(&last_path)")
.expect("sandbox short-circuit marker present in web_subpages");
let redirect_idx = src
.find("should_redirect_subpage_to_shell(is_sandbox")
.expect("subpage redirect marker present in web_subpages");
assert!(
sandbox_idx < redirect_idx,
"sandbox branch must run before the top-level-document redirect: \
reordering would break sandbox iframe sub-page loads"
);
assert!(!should_redirect_subpage_to_shell(true, "page2", "document"));
assert!(!should_redirect_subpage_to_shell(true, "news/", "document"));
assert!(!should_redirect_subpage_to_shell(
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"));
}
}