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:
@ -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);
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user