use axum::{
Json, Router,
extract::{ConnectInfo, DefaultBodyLimit},
http::{HeaderMap, StatusCode},
response::{IntoResponse, Response},
routing::post,
};
use std::net::{IpAddr, SocketAddr};
use crate::client_events::AuthToken;
use crate::client_events::websocket::is_loopback_source;
use crate::contract::{
BundleKeyKind, ContractHandlerEvent, ImportBundle, ImportTargetScope, Priority, RedactedToken,
};
use crate::node::OpManager;
use crate::server::{AllowedHosts, AllowedSourceCidrs};
use crate::wasm_runtime::secret_export::MAX_EXPORT_TOTAL_PLAINTEXT_BYTES;
use super::hosted_export::ExportOpManagerHandle;
use super::permission_prompts::is_origin_trusted;
use super::{ApiVersion, OriginContractMap};
const BUNDLE_KEY_HEADER: &str = "x-freenet-bundle-key";
const BUNDLE_KEY_KIND_HEADER: &str = "x-freenet-bundle-key-kind";
const OVERWRITE_HEADER: &str = "x-freenet-import-overwrite";
const AUTHORIZATION_NAME: &str = "authorization";
const MAX_IMPORT_BUNDLE_BYTES: usize = 2 * MAX_EXPORT_TOTAL_PLAINTEXT_BYTES + 64 * 1024 * 1024;
pub(super) fn routes(version: ApiVersion) -> Router {
let path = format!("/{}/import", version.prefix());
Router::new()
.route(&path, post(import_handler))
.layer(DefaultBodyLimit::max(MAX_IMPORT_BUNDLE_BYTES))
}
fn import_gate_or_reject(
headers: &HeaderMap,
source_ip: Option<IpAddr>,
allowed_hosts: Option<&AllowedHosts>,
allowed_source_cidrs: Option<&AllowedSourceCidrs>,
query: Option<&str>,
origin_contracts: &OriginContractMap,
) -> Result<(), (StatusCode, &'static str)> {
let Some(ip) = source_ip else {
return Err((
StatusCode::FORBIDDEN,
"import requires a loopback connection",
));
};
if !is_loopback_source(ip) {
return Err((
StatusCode::FORBIDDEN,
"import requires a loopback connection",
));
}
let Some(origin) = headers.get("origin").and_then(|v| v.to_str().ok()) else {
return Err((
StatusCode::FORBIDDEN,
"import requires a trusted same-origin request (missing Origin)",
));
};
if !is_origin_trusted(headers, origin, allowed_hosts, allowed_source_cidrs) {
return Err((
StatusCode::FORBIDDEN,
"import is only available from the node's own dashboard origin",
));
}
if presented_tokens(headers, query).any(|tok| origin_contracts.contains_key(&tok)) {
return Err((
StatusCode::FORBIDDEN,
"import is not available to contract web apps",
));
}
Ok(())
}
fn presented_tokens<'a>(
headers: &'a HeaderMap,
query: Option<&'a str>,
) -> impl Iterator<Item = AuthToken> + 'a {
let mut tokens: Vec<AuthToken> = Vec::new();
if let Some(v) = headers
.get(axum::http::header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
&& let Some(tok) = strip_bearer(v)
{
tokens.push(AuthToken::from(tok.to_owned()));
}
if let Some(q) = query {
for (k, val) in url_query_pairs(q) {
if k == "authToken" && !val.is_empty() {
tokens.push(AuthToken::from(val));
}
}
}
if let Some(cookies) = headers
.get(axum::http::header::COOKIE)
.and_then(|v| v.to_str().ok())
{
for part in cookies.split(';') {
let part = part.trim();
if let Some((name, value)) = part.split_once('=')
&& name.trim().eq_ignore_ascii_case(AUTHORIZATION_NAME)
{
let value = value.trim().trim_matches('"');
let tok = strip_bearer(value)
.or_else(|| value.strip_prefix("Bearer%20"))
.unwrap_or(value);
if !tok.is_empty() {
tokens.push(AuthToken::from(tok.to_owned()));
}
}
}
}
tokens.into_iter()
}
fn strip_bearer(value: &str) -> Option<&str> {
let rest = value.strip_prefix("Bearer ").or_else(|| {
value
.get(..7)
.filter(|p| p.eq_ignore_ascii_case("Bearer "))
.map(|_| &value[7..])
})?;
Some(rest.trim())
}
fn url_query_pairs(query: &str) -> impl Iterator<Item = (&str, String)> {
query.split('&').filter_map(|pair| {
let (k, v) = pair.split_once('=')?;
Some((k, v.to_owned()))
})
}
fn parse_key_kind(headers: &HeaderMap) -> Result<BundleKeyKind, (StatusCode, &'static str)> {
match headers
.get(BUNDLE_KEY_KIND_HEADER)
.and_then(|v| v.to_str().ok())
{
None => Ok(BundleKeyKind::Token),
Some(s) if s.eq_ignore_ascii_case("token") => Ok(BundleKeyKind::Token),
Some(s) if s.eq_ignore_ascii_case("passphrase") => Ok(BundleKeyKind::Passphrase),
Some(_) => Err((
StatusCode::BAD_REQUEST,
"X-Freenet-Bundle-Key-Kind must be 'token' or 'passphrase'",
)),
}
}
async fn import_handler(req: axum::extract::Request) -> Response {
let (parts, body) = req.into_parts();
let headers = &parts.headers;
let source_ip = parts
.extensions
.get::<ConnectInfo<SocketAddr>>()
.map(|ci| ci.0.ip());
let allowed_hosts = parts.extensions.get::<AllowedHosts>();
let allowed_source_cidrs = parts.extensions.get::<AllowedSourceCidrs>();
let empty_origin_contracts: OriginContractMap = std::sync::Arc::new(dashmap::DashMap::new());
let origin_contracts = parts
.extensions
.get::<OriginContractMap>()
.unwrap_or(&empty_origin_contracts);
if let Err((status, reason)) = import_gate_or_reject(
headers,
source_ip,
allowed_hosts,
allowed_source_cidrs,
parts.uri.query(),
origin_contracts,
) {
tracing::warn!(source_ip = ?source_ip, reason, "Rejected live import request");
return (status, reason).into_response();
}
let key = match headers.get(BUNDLE_KEY_HEADER).and_then(|v| v.to_str().ok()) {
Some(k) if !k.is_empty() => k.to_owned(),
_ => {
return (
StatusCode::BAD_REQUEST,
"import requires a bundle key (X-Freenet-Bundle-Key header)",
)
.into_response();
}
};
let key_kind = match parse_key_kind(headers) {
Ok(k) => k,
Err((status, reason)) => return (status, reason).into_response(),
};
let overwrite = headers
.get(OVERWRITE_HEADER)
.and_then(|v| v.to_str().ok())
.is_some_and(|v| v.eq_ignore_ascii_case("true"));
let op_manager = parts
.extensions
.get::<ExportOpManagerHandle>()
.and_then(ExportOpManagerHandle::current);
let Some(op_manager) = op_manager else {
tracing::warn!("Live import requested but no running node is registered");
return (
StatusCode::SERVICE_UNAVAILABLE,
"node is not ready to serve imports",
)
.into_response();
};
let bundle = match axum::body::to_bytes(body, MAX_IMPORT_BUNDLE_BYTES).await {
Ok(b) => b.to_vec(),
Err(_) => {
return (
StatusCode::PAYLOAD_TOO_LARGE,
"import bundle exceeds the maximum allowed size",
)
.into_response();
}
};
if bundle.is_empty() {
return (StatusCode::BAD_REQUEST, "import bundle is empty").into_response();
}
match run_import(&op_manager, bundle, key, key_kind, overwrite).await {
Ok(report) => (
StatusCode::OK,
Json(serde_json::json!({
"imported": report.imported,
"skipped": report.skipped,
})),
)
.into_response(),
Err(status_and_msg) => status_and_msg.into_response(),
}
}
async fn run_import(
op_manager: &OpManager,
bundle: Vec<u8>,
key: String,
key_kind: BundleKeyKind,
overwrite: bool,
) -> Result<crate::wasm_runtime::secret_export::ImportReport, (StatusCode, &'static str)> {
let event = ContractHandlerEvent::ImportSecrets {
target_scope: ImportTargetScope::Local,
bundle: ImportBundle::new(bundle),
key_material: RedactedToken::new(key.into_bytes()),
key_kind,
overwrite,
};
match op_manager
.notify_contract_handler_prioritized(event, Priority::ClientLocal)
.await
{
Ok(ContractHandlerEvent::ImportSecretsResponse(Ok(report))) => Ok(report),
Ok(ContractHandlerEvent::ImportSecretsResponse(Err(e))) => {
if e.is_import_bad_bundle() {
tracing::warn!(error = %e, "Rejected live import: bad bundle/key");
return Err((
StatusCode::UNPROCESSABLE_ENTITY,
"could not decrypt the bundle: wrong key or corrupt bundle",
));
}
if e.is_contract_queue_full() {
tracing::warn!(error = %e, "Rejected live import: contract queue full");
return Err((
StatusCode::SERVICE_UNAVAILABLE,
"node is busy; retry the import shortly",
));
}
tracing::error!(error = %e, "Live import failed on the executor");
Err((
StatusCode::INTERNAL_SERVER_ERROR,
"import failed on the node",
))
}
Ok(other) => {
tracing::error!(
response = %other,
"Unexpected contract-handler response to ImportSecrets"
);
Err((
StatusCode::INTERNAL_SERVER_ERROR,
"import failed on the node",
))
}
Err(e) => {
tracing::error!(error = %e, "Contract handler unavailable for live import");
Err((
StatusCode::SERVICE_UNAVAILABLE,
"node is not ready to serve imports",
))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use axum::http::HeaderValue;
use std::net::{Ipv4Addr, Ipv6Addr};
const LOOPBACK: Option<IpAddr> = Some(IpAddr::V4(Ipv4Addr::LOCALHOST));
const LOCAL_ORIGIN: &str = "http://127.0.0.1:50509";
fn headers_with(origin: Option<&str>) -> HeaderMap {
let mut h = HeaderMap::new();
if let Some(o) = origin {
h.insert("origin", HeaderValue::from_str(o).unwrap());
}
h
}
fn empty_map() -> OriginContractMap {
std::sync::Arc::new(dashmap::DashMap::new())
}
#[test]
fn gate_admits_loopback_dashboard_origin() {
let headers = headers_with(Some(LOCAL_ORIGIN));
import_gate_or_reject(&headers, LOOPBACK, None, None, None, &empty_map())
.expect("loopback dashboard request must be admitted");
}
#[test]
fn gate_rejects_missing_source_ip() {
let headers = headers_with(Some(LOCAL_ORIGIN));
let err =
import_gate_or_reject(&headers, None, None, None, None, &empty_map()).unwrap_err();
assert_eq!(err.0, StatusCode::FORBIDDEN);
}
#[test]
fn gate_rejects_non_loopback_source() {
let headers = headers_with(Some(LOCAL_ORIGIN));
let public = Some(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 7)));
let err =
import_gate_or_reject(&headers, public, None, None, None, &empty_map()).unwrap_err();
assert_eq!(err.0, StatusCode::FORBIDDEN);
}
#[test]
fn gate_admits_ipv4_mapped_loopback() {
let headers = headers_with(Some(LOCAL_ORIGIN));
let mapped = Some(IpAddr::V6(Ipv4Addr::LOCALHOST.to_ipv6_mapped()));
import_gate_or_reject(&headers, mapped, None, None, None, &empty_map())
.expect("ipv4-mapped loopback must be admitted");
}
#[test]
fn gate_rejects_missing_origin() {
let headers = headers_with(None);
let err =
import_gate_or_reject(&headers, LOOPBACK, None, None, None, &empty_map()).unwrap_err();
assert_eq!(err.0, StatusCode::FORBIDDEN);
}
#[test]
fn gate_rejects_null_origin_sandbox_iframe() {
let headers = headers_with(Some("null"));
let err =
import_gate_or_reject(&headers, LOOPBACK, None, None, None, &empty_map()).unwrap_err();
assert_eq!(err.0, StatusCode::FORBIDDEN);
}
#[test]
fn gate_rejects_cross_site_origin() {
let headers = headers_with(Some("https://evil.example.com"));
let err =
import_gate_or_reject(&headers, LOOPBACK, None, None, None, &empty_map()).unwrap_err();
assert_eq!(err.0, StatusCode::FORBIDDEN);
}
#[test]
fn gate_rejects_registered_origin_contract_bearer() {
let map: OriginContractMap = std::sync::Arc::new(dashmap::DashMap::new());
let tok = AuthToken::from("contract-token-xyz".to_owned());
map.insert(tok.clone(), dummy_origin_contract());
let mut headers = headers_with(Some(LOCAL_ORIGIN));
headers.insert(
axum::http::header::AUTHORIZATION,
HeaderValue::from_str("Bearer contract-token-xyz").unwrap(),
);
let err = import_gate_or_reject(&headers, LOOPBACK, None, None, None, &map).unwrap_err();
assert_eq!(err.0, StatusCode::FORBIDDEN);
}
#[test]
fn gate_rejects_registered_origin_contract_cookie() {
let map: OriginContractMap = std::sync::Arc::new(dashmap::DashMap::new());
let tok = AuthToken::from("cookie-token-abc".to_owned());
map.insert(tok.clone(), dummy_origin_contract());
let mut headers = headers_with(Some(LOCAL_ORIGIN));
headers.insert(
axum::http::header::COOKIE,
HeaderValue::from_str("authorization=Bearer cookie-token-abc").unwrap(),
);
let err = import_gate_or_reject(&headers, LOOPBACK, None, None, None, &map).unwrap_err();
assert_eq!(err.0, StatusCode::FORBIDDEN);
}
#[test]
fn gate_admits_unregistered_bearer() {
let mut headers = headers_with(Some(LOCAL_ORIGIN));
headers.insert(
axum::http::header::AUTHORIZATION,
HeaderValue::from_str("Bearer some-unrelated-token").unwrap(),
);
import_gate_or_reject(&headers, LOOPBACK, None, None, None, &empty_map())
.expect("an unregistered bearer is not a contract origin");
}
#[test]
fn presented_tokens_reads_header_query_and_cookie() {
let mut headers = HeaderMap::new();
headers.insert(
axum::http::header::AUTHORIZATION,
HeaderValue::from_str("Bearer header-tok").unwrap(),
);
headers.insert(
axum::http::header::COOKIE,
HeaderValue::from_str("foo=bar; authorization=Bearer cookie-tok").unwrap(),
);
let toks: Vec<String> = presented_tokens(&headers, Some("a=b&authToken=query-tok"))
.map(|t| t.as_str().to_owned())
.collect();
assert!(toks.contains(&"header-tok".to_owned()));
assert!(toks.contains(&"query-tok".to_owned()));
assert!(toks.contains(&"cookie-tok".to_owned()));
}
#[test]
fn parse_key_kind_defaults_and_variants() {
let mut h = HeaderMap::new();
assert_eq!(parse_key_kind(&h).unwrap(), BundleKeyKind::Token);
h.insert(
BUNDLE_KEY_KIND_HEADER,
HeaderValue::from_static("passphrase"),
);
assert_eq!(parse_key_kind(&h).unwrap(), BundleKeyKind::Passphrase);
h.insert(BUNDLE_KEY_KIND_HEADER, HeaderValue::from_static("TOKEN"));
assert_eq!(parse_key_kind(&h).unwrap(), BundleKeyKind::Token);
h.insert(BUNDLE_KEY_KIND_HEADER, HeaderValue::from_static("bogus"));
assert_eq!(parse_key_kind(&h).unwrap_err().0, StatusCode::BAD_REQUEST);
}
#[test]
fn strip_bearer_handles_case_and_absence() {
assert_eq!(strip_bearer("Bearer abc"), Some("abc"));
assert_eq!(strip_bearer("bearer abc"), Some("abc"));
assert_eq!(strip_bearer("Token abc"), None);
}
#[test]
fn gate_rejects_ipv6_non_loopback() {
let headers = headers_with(Some(LOCAL_ORIGIN));
let public6 = Some(IpAddr::V6(Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0, 1)));
let err =
import_gate_or_reject(&headers, public6, None, None, None, &empty_map()).unwrap_err();
assert_eq!(err.0, StatusCode::FORBIDDEN);
}
fn dummy_origin_contract() -> super::super::OriginContract {
use freenet_stdlib::prelude::ContractInstanceId;
let id = ContractInstanceId::new([0u8; 32]);
super::super::OriginContract::new(id, crate::client_events::ClientId::FIRST)
}
}