Merge feature/ticket72-proxy-trust-hardening into develop (#72)

Rend réelle la confiance accordée au reverse proxy. Jusqu'ici
`--trust-reverse-proxy` n'était lu que pour exiger sa propre présence : le
serveur ne vérifiait ni qui se connectait, ni ce que le proxy annonçait. Le pair
TCP est désormais vérifié avant toute confiance aux headers, `X-Forwarded-Proto:
https` est exigé en mode proxy, et `X-Forwarded-For` n'autorise jamais rien.

Corrige aussi B0, le pendant CLI du bug de port de #68 B1 : `idea-serve --listen
127.0.0.1:0` ne compare plus l'origine à `http://127.0.0.1:0`.

CHANGEMENT DE COMPORTEMENT ASSUMÉ : un bind non-loopback distant sans
`--trusted-proxy` est refusé au démarrage. Les configurations existantes de cette
forme cassent volontairement, avec un message qui dit quoi ajouter.

Vert avant merge, ré-exécuté par Git HORS SANDBOX — impératif sur ce crate, le
sandbox interdit `TcpListener::bind` et a déjà produit un faux vert sur #68 B1 :
`cargo test -p web-server` 66 passed · `-p backend` · `-p app-tauri` verts.
Revue de sécurité : guard câblé sur les 3 surfaces (statique, /api/*, /api/ws),
vrai `peer_addr` propagé sur tout le chemin de production.

Étape 2 du plan d'intégration séquentiel. Suit : #68 (panneau Settings
Deployment), sur arbitrage utilisateur qui a refusé de le reléguer derrière #71
et #73.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-16 23:11:04 +02:00
2 changed files with 605 additions and 31 deletions

View File

@ -5,7 +5,7 @@
use std::collections::HashSet; use std::collections::HashSet;
use std::env; use std::env;
use std::net::SocketAddr; use std::net::{IpAddr, SocketAddr};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::process::ExitCode; use std::process::ExitCode;
use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::atomic::{AtomicU64, Ordering};
@ -67,19 +67,11 @@ pub fn run_from_args(args: Vec<String>) -> ExitCode {
} }
}; };
let state = Arc::new(ServerState::new(config.clone()));
eprintln!("IdeA pairing code: {}", state.pairing_code());
eprintln!(
"idea --serve: app data dir = {}",
config.app_data_dir.display()
);
eprintln!("IdeA server listening on {}", config.listen);
match tokio::runtime::Builder::new_multi_thread() match tokio::runtime::Builder::new_multi_thread()
.enable_all() .enable_all()
.build() .build()
{ {
Ok(runtime) => match runtime.block_on(run_server(config, state)) { Ok(runtime) => match runtime.block_on(run_server(config)) {
Ok(()) => ExitCode::SUCCESS, Ok(()) => ExitCode::SUCCESS,
Err(err) => { Err(err) => {
eprintln!("idea --serve: {err}"); eprintln!("idea --serve: {err}");
@ -104,12 +96,78 @@ pub struct ServerConfig {
pub allow_remote: bool, pub allow_remote: bool,
/// Whether a trusted HTTPS reverse proxy terminates remote access. /// Whether a trusted HTTPS reverse proxy terminates remote access.
pub trust_reverse_proxy: bool, pub trust_reverse_proxy: bool,
/// Authorized reverse proxy peer IPs or CIDR ranges.
pub trusted_proxies: Vec<TrustedProxy>,
/// IdeA application data directory. /// IdeA application data directory.
pub app_data_dir: PathBuf, pub app_data_dir: PathBuf,
/// Built frontend assets root. /// Built frontend assets root.
pub web_root: PathBuf, pub web_root: PathBuf,
} }
/// Authorized reverse proxy peer IP or CIDR range.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct TrustedProxy {
addr: IpAddr,
prefix: u8,
}
impl TrustedProxy {
/// Parses an IP address or CIDR range accepted by `--trusted-proxy`.
pub fn parse(value: &str) -> Result<Self, String> {
let (addr, prefix) = match value.split_once('/') {
Some((addr, prefix)) => {
let addr = addr
.parse::<IpAddr>()
.map_err(|_| format!("invalid --trusted-proxy address: {value}"))?;
let prefix = prefix
.parse::<u8>()
.map_err(|_| format!("invalid --trusted-proxy CIDR prefix: {value}"))?;
(addr, prefix)
}
None => {
let addr = value
.parse::<IpAddr>()
.map_err(|_| format!("invalid --trusted-proxy address: {value}"))?;
let prefix = match addr {
IpAddr::V4(_) => 32,
IpAddr::V6(_) => 128,
};
(addr, prefix)
}
};
let max = match addr {
IpAddr::V4(_) => 32,
IpAddr::V6(_) => 128,
};
if prefix > max {
return Err(format!("invalid --trusted-proxy CIDR prefix: {value}"));
}
Ok(Self { addr, prefix })
}
fn contains(&self, ip: IpAddr) -> bool {
match (self.addr, ip) {
(IpAddr::V4(net), IpAddr::V4(ip)) => {
let mask = if self.prefix == 0 {
0
} else {
u32::MAX << (32 - self.prefix)
};
u32::from(net) & mask == u32::from(ip) & mask
}
(IpAddr::V6(net), IpAddr::V6(ip)) => {
let mask = if self.prefix == 0 {
0
} else {
u128::MAX << (128 - self.prefix)
};
u128::from(net) & mask == u128::from(ip) & mask
}
_ => false,
}
}
}
impl ServerConfig { impl ServerConfig {
/// Parses the web-server CLI flags. /// Parses the web-server CLI flags.
pub fn from_args(args: Vec<String>) -> Result<Self, String> { pub fn from_args(args: Vec<String>) -> Result<Self, String> {
@ -119,6 +177,7 @@ impl ServerConfig {
let mut public_origin = None; let mut public_origin = None;
let mut allow_remote = false; let mut allow_remote = false;
let mut trust_reverse_proxy = false; let mut trust_reverse_proxy = false;
let mut trusted_proxies = Vec::new();
let mut app_data_dir = default_app_data_dir(); let mut app_data_dir = default_app_data_dir();
let mut web_root = None; let mut web_root = None;
@ -141,6 +200,12 @@ impl ServerConfig {
} }
"--allow-remote" => allow_remote = true, "--allow-remote" => allow_remote = true,
"--trust-reverse-proxy" => trust_reverse_proxy = true, "--trust-reverse-proxy" => trust_reverse_proxy = true,
"--trusted-proxy" => {
let value = it
.next()
.ok_or_else(|| "--trusted-proxy requires an IP or CIDR".to_owned())?;
trusted_proxies.push(TrustedProxy::parse(&value)?);
}
"--app-data-dir" => { "--app-data-dir" => {
let value = it let value = it
.next() .next()
@ -164,6 +229,7 @@ impl ServerConfig {
public_origin, public_origin,
allow_remote, allow_remote,
trust_reverse_proxy, trust_reverse_proxy,
trusted_proxies,
app_data_dir, app_data_dir,
web_root, web_root,
}) })
@ -192,6 +258,9 @@ impl ServerConfig {
if !self.trust_reverse_proxy { if !self.trust_reverse_proxy {
return Err("--allow-remote requires --trust-reverse-proxy".to_owned()); return Err("--allow-remote requires --trust-reverse-proxy".to_owned());
} }
if !self.listen.ip().is_loopback() && self.trusted_proxies.is_empty() {
return Err("non-loopback remote bind requires --trusted-proxy <proxy-ip-or-cidr>; add the IP address or CIDR of the reverse proxy that connects to this server".to_owned());
}
} }
Ok(()) Ok(())
@ -208,7 +277,7 @@ impl ServerConfig {
/// Human-readable CLI usage. /// Human-readable CLI usage.
#[must_use] #[must_use]
pub fn usage() -> String { pub fn usage() -> String {
"usage: idea-serve [--listen IP:PORT] [--app-data-dir PATH] [--web-root PATH] [--allow-remote --public-origin https://host --trust-reverse-proxy]".to_owned() "usage: idea-serve [--listen IP:PORT] [--app-data-dir PATH] [--web-root PATH] [--allow-remote --public-origin https://host --trust-reverse-proxy [--trusted-proxy IP_OR_CIDR]...]".to_owned()
} }
} }
@ -288,8 +357,7 @@ pub async fn run_embedded_with_core(
let local_addr = listener let local_addr = listener
.local_addr() .local_addr()
.map_err(|err| format!("failed to read listener address: {err}"))?; .map_err(|err| format!("failed to read listener address: {err}"))?;
let mut effective_config = config.clone(); let effective_config = config_with_effective_listen(config.clone(), local_addr);
effective_config.listen = local_addr;
let state = Arc::new(ServerState::with_core(effective_config, core)); let state = Arc::new(ServerState::with_core(effective_config, core));
let pairing_code = state.pairing_code().to_owned(); let pairing_code = state.pairing_code().to_owned();
let (shutdown_tx, shutdown_rx) = oneshot::channel(); let (shutdown_tx, shutdown_rx) = oneshot::channel();
@ -303,6 +371,11 @@ pub async fn run_embedded_with_core(
}) })
} }
fn config_with_effective_listen(mut config: ServerConfig, local_addr: SocketAddr) -> ServerConfig {
config.listen = local_addr;
config
}
fn resolve_web_root(explicit: Option<PathBuf>) -> Result<PathBuf, String> { fn resolve_web_root(explicit: Option<PathBuf>) -> Result<PathBuf, String> {
if let Some(path) = explicit { if let Some(path) = explicit {
return validate_web_root(path, "--web-root"); return validate_web_root(path, "--web-root");
@ -433,6 +506,19 @@ enum SecurityLogEvent {
origin: Option<String>, origin: Option<String>,
route: String, route: String,
}, },
UntrustedProxyPeer {
peer: IpAddr,
forwarded_for: Option<String>,
},
ForwardedProtoRejected {
peer: IpAddr,
proto: Option<String>,
},
ForwardedHostMismatch {
peer: IpAddr,
host: Option<String>,
expected: String,
},
WsUpgradeRejected { WsUpgradeRejected {
origin: Option<String>, origin: Option<String>,
reason: &'static str, reason: &'static str,
@ -480,6 +566,31 @@ fn security_log_line(event: &SecurityLogEvent) -> String {
origin_label(origin) origin_label(origin)
) )
} }
SecurityLogEvent::UntrustedProxyPeer {
peer,
forwarded_for,
} => {
format!(
"untrustedProxyPeer peer={peer} forwardedFor={}",
origin_label(forwarded_for)
)
}
SecurityLogEvent::ForwardedProtoRejected { peer, proto } => {
format!(
"forwardedProtoRejected peer={peer} proto={}",
origin_label(proto)
)
}
SecurityLogEvent::ForwardedHostMismatch {
peer,
host,
expected,
} => {
format!(
"forwardedHostMismatch peer={peer} host={} expected={expected}",
origin_label(host)
)
}
SecurityLogEvent::WsUpgradeRejected { origin, reason } => { SecurityLogEvent::WsUpgradeRejected { origin, reason } => {
format!( format!(
"websocket upgrade rejected reason={reason} origin={}", "websocket upgrade rejected reason={reason} origin={}",
@ -496,10 +607,21 @@ fn origin_label(origin: &Option<String>) -> &str {
origin.as_deref().unwrap_or("<missing>") origin.as_deref().unwrap_or("<missing>")
} }
async fn run_server(config: ServerConfig, state: Arc<ServerState>) -> Result<(), String> { async fn run_server(config: ServerConfig) -> Result<(), String> {
let listener = TcpListener::bind(config.listen) let listener = TcpListener::bind(config.listen)
.await .await
.map_err(|err| format!("failed to bind {}: {err}", config.listen))?; .map_err(|err| format!("failed to bind {}: {err}", config.listen))?;
let local_addr = listener
.local_addr()
.map_err(|err| format!("failed to read listener address: {err}"))?;
let effective_config = config_with_effective_listen(config.clone(), local_addr);
let state = Arc::new(ServerState::new(effective_config));
eprintln!("IdeA pairing code: {}", state.pairing_code());
eprintln!(
"idea --serve: app data dir = {}",
config.app_data_dir.display()
);
eprintln!("IdeA server listening on {local_addr}");
let (_shutdown_tx, shutdown_rx) = oneshot::channel(); let (_shutdown_tx, shutdown_rx) = oneshot::channel();
run_listener(listener, state, shutdown_rx).await run_listener(listener, state, shutdown_rx).await
} }
@ -514,10 +636,11 @@ async fn run_listener(
result = listener.accept() => result, result = listener.accept() => result,
_ = &mut shutdown => return Ok(()), _ = &mut shutdown => return Ok(()),
}; };
let (stream, _) = accepted.map_err(|err| format!("failed to accept connection: {err}"))?; let (stream, peer_addr) =
accepted.map_err(|err| format!("failed to accept connection: {err}"))?;
let state = Arc::clone(&state); let state = Arc::clone(&state);
tokio::spawn(async move { tokio::spawn(async move {
if let Err(err) = handle_tcp_connection(stream, state).await { if let Err(err) = handle_tcp_connection(stream, peer_addr, state).await {
eprintln!("idea --serve: connection error: {err}"); eprintln!("idea --serve: connection error: {err}");
} }
}); });
@ -526,6 +649,7 @@ async fn run_listener(
async fn handle_tcp_connection( async fn handle_tcp_connection(
mut stream: tokio::net::TcpStream, mut stream: tokio::net::TcpStream,
peer_addr: SocketAddr,
state: Arc<ServerState>, state: Arc<ServerState>,
) -> Result<(), String> { ) -> Result<(), String> {
let mut buffer = Vec::with_capacity(8192); let mut buffer = Vec::with_capacity(8192);
@ -549,7 +673,7 @@ async fn handle_tcp_connection(
let (method, uri, headers, content_length) = parse_http_request_head(&buffer[..header_end])?; let (method, uri, headers, content_length) = parse_http_request_head(&buffer[..header_end])?;
if method == Method::GET && uri.path() == WS_PATH { if method == Method::GET && uri.path() == WS_PATH {
return handle_ws_upgrade(stream, headers, state).await; return handle_ws_upgrade(stream, peer_addr.ip(), headers, state).await;
} }
let body_start = header_end + 4; let body_start = header_end + 4;
@ -575,6 +699,7 @@ async fn handle_tcp_connection(
uri, uri,
headers, headers,
Bytes::copy_from_slice(&buffer[body_start..body_start + content_length]), Bytes::copy_from_slice(&buffer[body_start..body_start + content_length]),
Some(peer_addr.ip()),
state, state,
) )
.await; .await;
@ -585,6 +710,15 @@ async fn handle_tcp_connection(
async fn handle_request( async fn handle_request(
req: Request<ResponseBody>, req: Request<ResponseBody>,
state: Arc<ServerState>, state: Arc<ServerState>,
) -> Response<ResponseBody> {
handle_request_from_peer(req, Arc::clone(&state), Some(state.config.listen.ip())).await
}
#[cfg(test)]
async fn handle_request_from_peer(
req: Request<ResponseBody>,
state: Arc<ServerState>,
peer_ip: Option<IpAddr>,
) -> Response<ResponseBody> { ) -> Response<ResponseBody> {
let (parts, body) = req.into_parts(); let (parts, body) = req.into_parts();
let body = match body.collect().await { let body = match body.collect().await {
@ -604,6 +738,7 @@ async fn handle_request(
parts.uri, parts.uri,
parts.headers, parts.headers,
body, body,
peer_ip,
Arc::clone(&state), Arc::clone(&state),
) )
.await .await
@ -688,10 +823,11 @@ async fn write_http_response(
async fn handle_ws_upgrade( async fn handle_ws_upgrade(
mut stream: tokio::net::TcpStream, mut stream: tokio::net::TcpStream,
peer_ip: IpAddr,
headers: HeaderMap, headers: HeaderMap,
state: Arc<ServerState>, state: Arc<ServerState>,
) -> Result<(), String> { ) -> Result<(), String> {
let accept = match validate_ws_upgrade(&headers, &state) { let accept = match validate_ws_upgrade(&headers, Some(peer_ip), &state) {
Ok(accept) => accept, Ok(accept) => accept,
Err(response) => return write_http_response(&mut stream, *response).await, Err(response) => return write_http_response(&mut stream, *response).await,
}; };
@ -711,8 +847,13 @@ async fn handle_ws_upgrade(
fn validate_ws_upgrade( fn validate_ws_upgrade(
headers: &HeaderMap, headers: &HeaderMap,
peer_ip: Option<IpAddr>,
state: &ServerState, state: &ServerState,
) -> Result<String, Box<Response<ResponseBody>>> { ) -> Result<String, Box<Response<ResponseBody>>> {
if let Err(response) = validate_reverse_proxy(headers, peer_ip, state, WS_PATH) {
return Err(response);
}
let origin = match validate_request_origin(headers, &state.config) { let origin = match validate_request_origin(headers, &state.config) {
Ok(origin) => origin, Ok(origin) => origin,
Err(response) => { Err(response) => {
@ -1232,6 +1373,7 @@ async fn dispatch_http(
uri: Uri, uri: Uri,
headers: HeaderMap, headers: HeaderMap,
body: Bytes, body: Bytes,
peer_ip: Option<IpAddr>,
state: Arc<ServerState>, state: Arc<ServerState>,
) -> Response<ResponseBody> { ) -> Response<ResponseBody> {
if has_forbidden_query_secret(&uri) { if has_forbidden_query_secret(&uri) {
@ -1244,9 +1386,16 @@ async fn dispatch_http(
} }
if !is_api_path(uri.path()) { if !is_api_path(uri.path()) {
if let Err(response) = validate_reverse_proxy(&headers, peer_ip, &state, uri.path()) {
return *response;
}
return serve_static(&state.config.web_root, method, uri.path()).await; return serve_static(&state.config.web_root, method, uri.path()).await;
} }
if let Err(response) = validate_reverse_proxy(&headers, peer_ip, &state, uri.path()) {
return *response;
}
let origin = match validate_request_origin(&headers, &state.config) { let origin = match validate_request_origin(&headers, &state.config) {
Ok(origin) => origin, Ok(origin) => origin,
Err(response) => { Err(response) => {
@ -1794,6 +1943,126 @@ fn validate_request_origin(
} }
} }
fn validate_reverse_proxy(
headers: &HeaderMap,
peer_ip: Option<IpAddr>,
state: &ServerState,
_route: &str,
) -> Result<(), Box<Response<ResponseBody>>> {
let config = &state.config;
if !config.allow_remote {
return Ok(());
}
let Some(public_origin) = &config.public_origin else {
return Ok(());
};
let peer = peer_ip.unwrap_or(config.listen.ip());
if !trusted_proxy_peer(peer, config) {
let forwarded_for = forwarded_header_first(headers, "x-forwarded-for");
state.log_security(SecurityLogEvent::UntrustedProxyPeer {
peer,
forwarded_for,
});
return Err(Box::new(error_response(
StatusCode::FORBIDDEN,
"FORBIDDEN",
untrusted_proxy_message(peer, config.trusted_proxies.is_empty()),
None,
)));
}
let proto = forwarded_header_first(headers, "x-forwarded-proto");
if !proto
.as_deref()
.is_some_and(|proto| proto.eq_ignore_ascii_case("https"))
{
state.log_security(SecurityLogEvent::ForwardedProtoRejected { peer, proto });
return Err(Box::new(error_response(
StatusCode::FORBIDDEN,
"FORBIDDEN",
"IdeA rejected the request because the trusted proxy did not report X-Forwarded-Proto: https. Configure the reverse proxy to send it; for nginx add: proxy_set_header X-Forwarded-Proto $scheme;",
None,
)));
}
let expected = public_origin_authority(public_origin).unwrap_or_else(|| public_origin.clone());
let host = forwarded_host_diagnostic(headers);
if !host
.as_deref()
.is_some_and(|host| host.trim().eq_ignore_ascii_case(&expected))
{
state.log_security(SecurityLogEvent::ForwardedHostMismatch {
peer,
host,
expected: expected.clone(),
});
}
Ok(())
}
fn trusted_proxy_peer(peer: IpAddr, config: &ServerConfig) -> bool {
if config.listen.ip().is_loopback() {
return peer.is_loopback();
}
config
.trusted_proxies
.iter()
.any(|trusted| trusted.contains(peer))
}
fn untrusted_proxy_message(peer: IpAddr, no_authorized_proxy: bool) -> String {
let reason = if no_authorized_proxy {
"but no authorized proxy is configured"
} else {
"but it is not an authorized proxy"
};
format!(
"IdeA rejected the request because it came from {peer}, {reason}. Add --trusted-proxy {peer} or a restricted proxy CIDR such as --trusted-proxy {peer}/32."
)
}
fn forwarded_header_first(headers: &HeaderMap, name: &str) -> Option<String> {
headers
.get(name)
.and_then(|value| value.to_str().ok())
.and_then(|value| value.split(',').next())
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_owned)
}
fn forwarded_host_diagnostic(headers: &HeaderMap) -> Option<String> {
forwarded_header_first(headers, "x-forwarded-host")
.or_else(|| forwarded_header_host_param(headers))
.or_else(|| forwarded_header_first(headers, "host"))
}
fn forwarded_header_host_param(headers: &HeaderMap) -> Option<String> {
headers
.get("forwarded")
.and_then(|value| value.to_str().ok())
.and_then(|value| {
value.split(',').find_map(|entry| {
entry.split(';').find_map(|part| {
let (name, value) = part.trim().split_once('=')?;
name.trim()
.eq_ignore_ascii_case("host")
.then(|| value.trim().trim_matches('"').to_owned())
})
})
})
.filter(|value| !value.is_empty())
}
fn public_origin_authority(origin: &str) -> Option<String> {
origin.parse::<Uri>().ok().and_then(|uri| {
uri.authority()
.map(|authority| authority.as_str().to_owned())
})
}
fn request_origin(headers: &HeaderMap) -> Option<String> { fn request_origin(headers: &HeaderMap) -> Option<String> {
headers headers
.get(ORIGIN) .get(ORIGIN)
@ -2501,6 +2770,7 @@ mod tests {
public_origin: None, public_origin: None,
allow_remote: false, allow_remote: false,
trust_reverse_proxy: false, trust_reverse_proxy: false,
trusted_proxies: Vec::new(),
app_data_dir: std::env::temp_dir().join(format!("idea-server-test-{}", Uuid::new_v4())), app_data_dir: std::env::temp_dir().join(format!("idea-server-test-{}", Uuid::new_v4())),
web_root: create_web_root(), web_root: create_web_root(),
} }
@ -2534,6 +2804,51 @@ mod tests {
Arc::new(ServerState::new_for_test(test_config(), "PAIR1234")) Arc::new(ServerState::new_for_test(test_config(), "PAIR1234"))
} }
fn trusted_proxy(value: &str) -> TrustedProxy {
TrustedProxy::parse(value).unwrap()
}
fn remote_config() -> ServerConfig {
ServerConfig {
listen: "192.168.1.75:17373".parse().unwrap(),
public_origin: Some("https://idea.example.com".to_owned()),
allow_remote: true,
trust_reverse_proxy: true,
trusted_proxies: vec![trusted_proxy("192.168.1.22")],
..test_config()
}
}
async fn remote_pair_request(
state: Arc<ServerState>,
peer: &str,
origin: &str,
proto: &str,
host: Option<&str>,
extra_headers: &[(&str, &str)],
) -> Response<ResponseBody> {
let mut builder = Request::builder()
.method(Method::POST)
.uri("/api/pair")
.header(ORIGIN, origin)
.header("x-forwarded-proto", proto)
.header(CONTENT_TYPE, "application/json");
if let Some(host) = host {
builder = builder.header("x-forwarded-host", host);
}
for (name, value) in extra_headers {
builder = builder.header(HeaderName::from_bytes(name.as_bytes()).unwrap(), *value);
}
handle_request_from_peer(
builder
.body(Full::new(Bytes::from_static(br#"{"code":"PAIR1234"}"#)))
.unwrap(),
state,
Some(peer.parse().unwrap()),
)
.await
}
#[tokio::test] #[tokio::test]
async fn run_embedded_stop_shuts_down_accept_loop() { async fn run_embedded_stop_shuts_down_accept_loop() {
let config = ServerConfig { let config = ServerConfig {
@ -2993,7 +3308,8 @@ mod tests {
let state = state(); let state = state();
let headers = ws_headers("http://127.0.0.1:17373", None); let headers = ws_headers("http://127.0.0.1:17373", None);
let response = validate_ws_upgrade(&headers, &state).unwrap_err(); let response =
validate_ws_upgrade(&headers, Some("127.0.0.1".parse().unwrap()), &state).unwrap_err();
assert_eq!(response.status(), StatusCode::UNAUTHORIZED); assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
} }
@ -3006,7 +3322,8 @@ mod tests {
Some("idea_session=not-a-valid-session"), Some("idea_session=not-a-valid-session"),
); );
let response = validate_ws_upgrade(&headers, &state).unwrap_err(); let response =
validate_ws_upgrade(&headers, Some("127.0.0.1".parse().unwrap()), &state).unwrap_err();
assert_eq!(response.status(), StatusCode::UNAUTHORIZED); assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
} }
@ -3020,7 +3337,8 @@ mod tests {
Some(&format!("idea_session={token}")), Some(&format!("idea_session={token}")),
); );
let response = validate_ws_upgrade(&headers, &state).unwrap_err(); let response =
validate_ws_upgrade(&headers, Some("127.0.0.1".parse().unwrap()), &state).unwrap_err();
assert_eq!(response.status(), StatusCode::FORBIDDEN); assert_eq!(response.status(), StatusCode::FORBIDDEN);
} }
@ -3034,7 +3352,8 @@ mod tests {
Some(&format!("idea_session={token}")), Some(&format!("idea_session={token}")),
); );
let accept = validate_ws_upgrade(&headers, &state).unwrap(); let accept =
validate_ws_upgrade(&headers, Some("127.0.0.1".parse().unwrap()), &state).unwrap();
assert_eq!(accept, "s3pPLMBiTxaQ9kYGzzhZRbK+xOo="); assert_eq!(accept, "s3pPLMBiTxaQ9kYGzzhZRbK+xOo=");
} }
@ -3682,9 +4001,43 @@ mod tests {
trust_reverse_proxy: true, trust_reverse_proxy: true,
..config ..config
}; };
let err = config.validate().unwrap_err();
assert!(err.contains("--trusted-proxy <proxy-ip-or-cidr>"));
assert!(!err.contains("192.168.1.22"));
let config = ServerConfig {
trusted_proxies: vec![trusted_proxy("192.168.1.22")],
..config
};
assert!(config.validate().is_ok()); assert!(config.validate().is_ok());
} }
#[test]
fn loopback_remote_reverse_proxy_does_not_require_trusted_proxy() {
let config = ServerConfig {
listen: "127.0.0.1:17373".parse().unwrap(),
allow_remote: true,
public_origin: Some("https://idea.example.com".to_owned()),
trust_reverse_proxy: true,
trusted_proxies: Vec::new(),
..test_config()
};
assert!(config.validate().is_ok());
}
#[test]
fn effective_listen_replaces_port_zero_for_origin_checks() {
let config = ServerConfig {
listen: "127.0.0.1:0".parse().unwrap(),
..test_config()
};
let effective = config_with_effective_listen(config, "127.0.0.1:43689".parse().unwrap());
assert!(origin_allowed("http://127.0.0.1:43689", &effective));
assert!(!origin_allowed("http://127.0.0.1:0", &effective));
}
#[test] #[test]
fn allow_remote_requires_https_origin_and_reverse_proxy() { fn allow_remote_requires_https_origin_and_reverse_proxy() {
let base = ServerConfig { let base = ServerConfig {
@ -3710,6 +4063,207 @@ mod tests {
assert!(without_proxy.validate().is_err()); assert!(without_proxy.validate().is_err());
} }
#[tokio::test]
async fn remote_bind_rejects_peer_when_no_authorized_proxy_is_configured() {
let logger = Arc::new(RecordingSecurityLogger::default());
let config = ServerConfig {
trusted_proxies: Vec::new(),
..remote_config()
};
let state = Arc::new(ServerState::new_for_test_with_logger(
config,
"PAIR1234",
logger.clone(),
));
let response = remote_pair_request(
state,
"192.168.1.22",
"https://idea.example.com",
"https",
Some("idea.example.com"),
&[],
)
.await;
let (status, body, _) = response_json(response).await;
assert_eq!(status, StatusCode::FORBIDDEN);
assert!(body["message"]
.as_str()
.is_some_and(|message| message.contains("came from 192.168.1.22")
&& message.contains("no authorized proxy is configured")
&& message.contains("--trusted-proxy 192.168.1.22")));
assert!(logger
.events()
.iter()
.any(|event| matches!(event, SecurityLogEvent::UntrustedProxyPeer { peer, .. } if *peer == "192.168.1.22".parse::<IpAddr>().unwrap())));
}
#[tokio::test]
async fn remote_bind_rejects_untrusted_proxy_peer_without_using_x_forwarded_for() {
let logger = Arc::new(RecordingSecurityLogger::default());
let state = Arc::new(ServerState::new_for_test_with_logger(
remote_config(),
"PAIR1234",
logger.clone(),
));
let response = remote_pair_request(
state,
"192.168.1.23",
"https://idea.example.com",
"https",
Some("idea.example.com"),
&[("x-forwarded-for", "192.168.1.22")],
)
.await;
let (status, _, _) = response_json(response).await;
assert_eq!(status, StatusCode::FORBIDDEN);
assert!(logger.events().iter().any(|event| matches!(
event,
SecurityLogEvent::UntrustedProxyPeer {
peer,
forwarded_for: Some(forwarded_for)
} if *peer == "192.168.1.23".parse::<IpAddr>().unwrap() && forwarded_for == "192.168.1.22"
)));
}
#[tokio::test]
async fn trusted_proxy_requires_forwarded_https_proto() {
let logger = Arc::new(RecordingSecurityLogger::default());
let state = Arc::new(ServerState::new_for_test_with_logger(
remote_config(),
"PAIR1234",
logger.clone(),
));
let response = remote_pair_request(
state,
"192.168.1.22",
"https://idea.example.com",
"http",
Some("idea.example.com"),
&[],
)
.await;
let (status, _, _) = response_json(response).await;
assert_eq!(status, StatusCode::FORBIDDEN);
assert!(logger.events().iter().any(|event| matches!(
event,
SecurityLogEvent::ForwardedProtoRejected {
peer,
proto: Some(proto)
} if *peer == "192.168.1.22".parse::<IpAddr>().unwrap() && proto == "http"
)));
}
#[tokio::test]
async fn trusted_proxy_allows_forwarded_host_mismatch_with_warning() {
let logger = Arc::new(RecordingSecurityLogger::default());
let state = Arc::new(ServerState::new_for_test_with_logger(
remote_config(),
"PAIR1234",
logger.clone(),
));
let response = remote_pair_request(
state,
"192.168.1.22",
"https://idea.example.com",
"https",
Some("other.example.com"),
&[],
)
.await;
let (status, body, _) = response_json(response).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body["paired"], true);
assert!(logger.events().iter().any(|event| matches!(
event,
SecurityLogEvent::ForwardedHostMismatch {
peer,
host: Some(host),
expected
} if *peer == "192.168.1.22".parse::<IpAddr>().unwrap()
&& host == "other.example.com"
&& expected == "idea.example.com"
)));
}
#[tokio::test]
async fn trusted_proxy_allows_missing_forwarded_host_with_warning() {
let logger = Arc::new(RecordingSecurityLogger::default());
let state = Arc::new(ServerState::new_for_test_with_logger(
remote_config(),
"PAIR1234",
logger.clone(),
));
let response = remote_pair_request(
state,
"192.168.1.22",
"https://idea.example.com",
"https",
None,
&[],
)
.await;
let (status, body, _) = response_json(response).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body["paired"], true);
assert!(logger.events().iter().any(|event| matches!(
event,
SecurityLogEvent::ForwardedHostMismatch {
peer,
host: None,
expected
} if *peer == "192.168.1.22".parse::<IpAddr>().unwrap()
&& expected == "idea.example.com"
)));
}
#[tokio::test]
async fn trusted_proxy_with_https_and_matching_host_allows_remote_api() {
let state = Arc::new(ServerState::new_for_test(remote_config(), "PAIR1234"));
let response = remote_pair_request(
state,
"192.168.1.22",
"https://idea.example.com",
"https",
Some("idea.example.com"),
&[("x-forwarded-for", "203.0.113.44")],
)
.await;
let (status, body, _) = response_json(response).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body["paired"], true);
}
#[tokio::test]
async fn trusted_proxy_still_rejects_non_matching_api_origin() {
let state = Arc::new(ServerState::new_for_test(remote_config(), "PAIR1234"));
let response = remote_pair_request(
state,
"192.168.1.22",
"https://evil.example",
"https",
Some("idea.example.com"),
&[],
)
.await;
let (status, body, _) = response_json(response).await;
assert_eq!(status, StatusCode::FORBIDDEN);
assert_eq!(body["message"], "origin not allowed");
}
#[tokio::test] #[tokio::test]
async fn invoke_requires_valid_cookie() { async fn invoke_requires_valid_cookie() {
let state = state(); let state = state();
@ -3783,7 +4337,8 @@ mod tests {
assert_eq!(body["code"], "UNAUTHORIZED"); assert_eq!(body["code"], "UNAUTHORIZED");
let headers = ws_headers("http://127.0.0.1:17373", Some(&cookie)); let headers = ws_headers("http://127.0.0.1:17373", Some(&cookie));
let response = validate_ws_upgrade(&headers, &state).unwrap_err(); let response =
validate_ws_upgrade(&headers, Some("127.0.0.1".parse().unwrap()), &state).unwrap_err();
assert_eq!(response.status(), StatusCode::UNAUTHORIZED); assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
} }
@ -3833,7 +4388,7 @@ mod tests {
"http://127.0.0.1:17373", "http://127.0.0.1:17373",
Some("idea_session=invalid-token-value"), Some("idea_session=invalid-token-value"),
); );
let _ = validate_ws_upgrade(&headers, &state); let _ = validate_ws_upgrade(&headers, Some("127.0.0.1".parse().unwrap()), &state);
let events = logger.events(); let events = logger.events();
assert!(events.iter().any(|event| matches!( assert!(events.iter().any(|event| matches!(
@ -3925,6 +4480,7 @@ mod tests {
public_origin: Some("https://idea.example.com".to_owned()), public_origin: Some("https://idea.example.com".to_owned()),
allow_remote: true, allow_remote: true,
trust_reverse_proxy: true, trust_reverse_proxy: true,
trusted_proxies: vec![trusted_proxy("192.168.1.22")],
..test_config() ..test_config()
}; };
let state = Arc::new(ServerState::new_for_test(config, "PAIR1234")); let state = Arc::new(ServerState::new_for_test(config, "PAIR1234"));
@ -3933,13 +4489,16 @@ mod tests {
.method(Method::POST) .method(Method::POST)
.uri("/api/pair") .uri("/api/pair")
.header(ORIGIN, "https://idea.example.com") .header(ORIGIN, "https://idea.example.com")
.header("x-forwarded-proto", "https")
.header("x-forwarded-host", "idea.example.com")
.header(CONTENT_TYPE, "application/json") .header(CONTENT_TYPE, "application/json")
.body(Full::new(Bytes::from_static(br#"{"code":"PAIR1234"}"#))) .body(Full::new(Bytes::from_static(br#"{"code":"PAIR1234"}"#)))
.unwrap(); .unwrap();
*req.headers_mut().get_mut(CONTENT_TYPE).unwrap() = *req.headers_mut().get_mut(CONTENT_TYPE).unwrap() =
HeaderValue::from_static("application/json"); HeaderValue::from_static("application/json");
let response = handle_request(req, state).await; let response =
handle_request_from_peer(req, state, Some("192.168.1.22".parse().unwrap())).await;
let (status, _, headers) = response_json(response).await; let (status, _, headers) = response_json(response).await;
assert_eq!(status, StatusCode::OK); assert_eq!(status, StatusCode::OK);

View File

@ -61,18 +61,33 @@ idea --serve \
This example is for a reverse proxy running on the same machine as the server. This example is for a reverse proxy running on the same machine as the server.
If the proxy runs on another machine, bind an address reachable by that proxy, If the proxy runs on another machine, bind an address reachable by that proxy,
for example `--listen 192.168.1.75:17373`, and point the proxy upstream to that for example `--listen 192.0.2.75:17373`, and point the proxy upstream to that
address and port. The link from proxy to server is still plain HTTP on the LAN; address and port. Also authorize only that proxy peer:
only the public access is HTTPS.
```bash
idea --serve \
--listen 192.0.2.75:17373 \
--allow-remote \
--public-origin https://idea.example.com \
--trust-reverse-proxy \
--trusted-proxy 192.0.2.22
```
The link from proxy to server is still plain HTTP on the LAN; only the public
access is HTTPS.
For any non-loopback bind, keep the full remote HTTPS configuration: For any non-loopback bind, keep the full remote HTTPS configuration:
`--allow-remote`, `--public-origin https://...`, and `--trust-reverse-proxy`. `--allow-remote`, `--public-origin https://...`, `--trust-reverse-proxy`, and at
`ServerConfig::validate()` rejects a non-loopback bind without it. least one `--trusted-proxy IP_OR_CIDR`. `ServerConfig::validate()` rejects a
non-loopback bind without it.
That check covers the configuration, not reality: it cannot verify that a proxy That check covers the configuration, not reality: it cannot verify that a proxy
is actually terminating HTTPS in front of the server. Do not expose the backend is actually terminating HTTPS in front of the server. Do not expose the backend
on a public non-loopback address without TLS proxying. on a public non-loopback address without TLS proxying.
The proxy must forward the same origin for the SPA, `/api/*`, and `/api/ws`. The proxy must forward the same origin for the SPA, `/api/*`, and `/api/ws`, and
must send `X-Forwarded-Proto: https`. Forwarding the public host is recommended
for diagnostics, but the strict access check remains the `Origin` match against
`--public-origin`.
`--public-origin` is matched by strict equality against the request `Origin`: `--public-origin` is matched by strict equality against the request `Origin`:
open the UI through the configured domain, not through the server IP, otherwise open the UI through the configured domain, not through the server IP, otherwise
API calls are rejected with 403. API calls are rejected with 403.