Ctrl K
ComunidadGuías…

sdk/src/loopback_forwarding.rs

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
//! Loopback forwarding, a workaround for the limitations of iOS WebViews.
//!
//! Normally, in forward-proxy mode, a WebView (or browser tab) sends all its
//! requests through the proxy (or at least we're able to configure it to do
//! so). On iOS, the WebKit insists on skipping the proxy for loopback names.
//! This breaks primarily in-development apps that host backends for example on
//! `localhost:1234`.
//!
//! The workaround is to identify the share's lookback origins, try and bind the
//! same ports on the device's own loopback, then forward requests reaching
//! those ports. The LoopbackForwarder implements this.

use crate::forward_proxy::{forward, open_tunnel};
use crate::loopback::{
    BoxedBody, RequiredCookie, error_response, gone_response, means_browser_went_away,
    strip_cookies,
};
use crate::{Client, Lookup, ShareId, SharedApp};
use anyhow::{Context, Result};
use hyper::StatusCode;
use hyper::body::Incoming;
use hyper::server::conn::http1 as http1_server;
use hyper_util::rt::TokioIo;
use std::collections::BTreeMap;
use std::convert::Infallible;
use std::sync::Arc;
use tokio::net::{TcpListener, TcpStream};
use tracing::warn;
use wire::{AppProxyMode, Origin};
use wispers_access_wire as wire;

/// The name of the forwarding cookie.
pub const COOKIE_NAME: &str = "__wispers_loopback_auth";

/// The ports of the share's `http` loopback origins, each with the hosts the
/// origins on it name (`localhost`, `127.0.0.1`, `[::1]`). Origins that don't
/// parse are left out, as they are everywhere.
pub fn loopback_ports(apps: &[SharedApp]) -> BTreeMap<u16, Vec<String>> {
    let mut ports: BTreeMap<u16, Vec<String>> = BTreeMap::new();
    let origins = apps
        .iter()
        .filter(|app| app.mode == AppProxyMode::ForwardProxy)
        .flat_map(|app| &app.proxied_origins)
        .filter_map(|origin| origin.parse::<Origin>().ok())
        .filter(|origin| origin.is_loopback() && !origin.is_https());
    for origin in origins {
        let hosts = ports.entry(origin.port()).or_default();
        if !hosts.iter().any(|host| host == origin.host()) {
            hosts.push(origin.host().to_owned());
        }
    }
    ports
}

/// Every origin of the share's forward-proxy apps (except those that don't parse).
pub fn share_origins(apps: &[SharedApp]) -> Vec<Origin> {
    apps.iter()
        .filter(|app| app.mode == AppProxyMode::ForwardProxy)
        .flat_map(|app| &app.proxied_origins)
        .filter_map(|origin| origin.parse::<Origin>().ok())
        .collect()
}

/// Serves every connection to one forwarded port, until cancelled.
pub async fn accept_loop(listener: TcpListener, port: Arc<ForwardedPort>) {
    loop {
        match listener.accept().await {
            Ok((tcp_stream, _)) => {
                let port = port.clone();
                tokio::spawn(async move {
                    if let Err(e) = handle_connection(tcp_stream, port).await {
                        warn!(error = format!("{e:#}"), "connection error");
                    }
                });
            }
            Err(e) => {
                warn!(error = format!("{e:#}"), "accept error");
                // A reclaimed socket (iOS) fails every accept; don't spin.
                tokio::time::sleep(std::time::Duration::from_millis(100)).await;
            }
        }
    }
}

/// One forwarded port of a share: what its requests need checked and where
/// they go.
pub struct ForwardedPort {
    pub client: Client,
    pub share: ShareId,
    pub port: u16,
    /// The hosts the share's loopback origins on this port name.
    pub hosts: Vec<String>,
    pub cookie: RequiredCookie,
    /// All the share's origins, loopback or not, which let a request in
    /// without the cookie (see `is_from_the_share`).
    pub share_origins: Vec<Origin>,
}

async fn handle_connection(tcp_stream: TcpStream, port: Arc<ForwardedPort>) -> Result<()> {
    let tcp_stream = TokioIo::new(tcp_stream);
    let service = hyper::service::service_fn(move |req| handle_request(req, port.clone()));
    let served = http1_server::Builder::new()
        .serve_connection(tcp_stream, service)
        // A 101 hands the browser socket over for raw relaying (WebSocket).
        .with_upgrades()
        .await;
    match served {
        Ok(()) => Ok(()),
        Err(e) if means_browser_went_away(&e) => Ok(()),
        Err(e) => Err(e).context("HTTP/1 connection error"),
    }
}

async fn handle_request(
    mut req: hyper::Request<Incoming>,
    port: Arc<ForwardedPort>,
) -> Result<hyper::Response<BoxedBody>, Infallible> {
    // Only the share's own WebViews get past here, and the app itself never
    // sees the cookie.
    if !port.cookie.is_presented_in(req.headers()) && !is_from_the_share(&req, &port.share_origins)
    {
        return Ok(error_response(StatusCode::FORBIDDEN, "forbidden"));
    }
    strip_cookies(req.headers_mut(), |name| name == port.cookie.name);

    let node = match port.client.restore_guest_node(port.share.as_str()).await {
        Ok(Lookup::Live(node)) => node,
        Ok(Lookup::Dead(state)) => return Ok(gone_response(state)),
        Ok(Lookup::Unknown) => {
            return Ok(error_response(StatusCode::FORBIDDEN, "unknown share"));
        }
        Err(e) => {
            warn!(share = %port.share, error = format!("{e:#}"), "could not restore the share");
            return Ok(error_response(
                StatusCode::BAD_GATEWAY,
                "Wispers Access server unavailable",
            ));
        }
    };
    let host = tunnel_host(&req, &port.hosts);
    let stream = match open_tunnel(&node, host, port.port).await {
        Ok(stream) => stream,
        Err(refusal) => return Ok(refusal.into_response(host, port.port)),
    };
    Ok(forward(req, stream, node.label()).await)
}

/// Whether the request's `Origin` header names one of the share's origins.
/// Browsers send cookies on a cross-origin `fetch` or XHR only if the page
/// asks for it, so a page on one loopback origin calling an API on another
/// gets here without the cookie. They do always send `Origin` then, and a web
/// page can't set it. Other apps on the device can, so this lets in what the
/// cookie alone would refuse to browsers, nothing more.
fn is_from_the_share(req: &hyper::Request<Incoming>, share_origins: &[Origin]) -> bool {
    req.headers()
        .get(hyper::header::ORIGIN)
        .and_then(|v| v.to_str().ok())
        .and_then(|v| v.parse::<Origin>().ok())
        .is_some_and(|origin| share_origins.contains(&origin))
}

/// Which of the port's hosts the request is for: the one its `Host` header
/// names, else the first. The share can list `localhost` and `127.0.0.1` on
/// the same port, and they may well be different servers on the host.
fn tunnel_host<'a>(req: &hyper::Request<Incoming>, hosts: &'a [String]) -> &'a str {
    let named = req
        .headers()
        .get(hyper::header::HOST)
        .and_then(|v| v.to_str().ok())
        .and_then(|host| host.parse::<hyper::http::uri::Authority>().ok());
    named
        .and_then(|authority| {
            hosts
                .iter()
                .find(|host| host.eq_ignore_ascii_case(authority.host()))
        })
        .unwrap_or(&hosts[0])
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::test_support::{Setup, send_to};
    use crate::{SdkError, transports::TerminalState};
    use tokio::io::{AsyncReadExt, AsyncWriteExt};

    /// A port that is free now.
    async fn free_port() -> u16 {
        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
        listener.local_addr().unwrap().port()
    }

    #[tokio::test]
    async fn requests_with_the_cookie_reach_the_host_nodes_loopback() {
        let port = free_port().await;
        let setup = Setup::with_origins_redirected(vec![format!("http://localhost:{port}")]).await;
        let forwarder = setup
            .proxy
            .forward_loopback_origins(setup.share.clone())
            .await
            .unwrap();
        assert_eq!(forwarder.ports(), [port]);
        let cookie = forwarder.cookie();
        let (mut tcp, head) = send_to(
            port,
            &format!(
                "GET /api?x=1 HTTP/1.1\r\nHost: localhost:{port}\r\n\
                 Cookie: a=1; {}={}\r\nConnection: close\r\n\r\n",
                cookie.name, cookie.value
            ),
        )
        .await;
        assert!(head.starts_with("http/1.1 200"), "{head}");
        let mut body = String::new();
        tcp.read_to_string(&mut body).await.unwrap();
        // The app sees its own cookies, not ours.
        assert_eq!(body, "GET /api?x=1 proxy-authorization=false cookie=a=1");
        assert_eq!(setup.targets(), [("localhost".to_owned(), port)]);
    }

    #[tokio::test]
    async fn requests_without_the_cookie_get_a_403_without_a_stream() {
        let port = free_port().await;
        let setup = Setup::with_origins_redirected(vec![format!("http://localhost:{port}")]).await;
        let _forwarder = setup
            .proxy
            .forward_loopback_origins(setup.share.clone())
            .await
            .unwrap();
        let (_, head) = send_to(
            port,
            &format!("GET / HTTP/1.1\r\nHost: localhost:{port}\r\nCookie: a=1\r\n\r\n"),
        )
        .await;
        assert!(head.starts_with("http/1.1 403"), "{head}");
        assert_eq!(setup.streams(), 0);
    }

    #[tokio::test]
    async fn cross_origin_requests_from_the_share_need_no_cookie() {
        let (page, api) = (free_port().await, free_port().await);
        let setup = Setup::with_origins_redirected(vec![
            format!("http://localhost:{page}"),
            format!("http://localhost:{api}"),
        ])
        .await;
        let _forwarder = setup
            .proxy
            .forward_loopback_origins(setup.share.clone())
            .await
            .unwrap();
        let from = |origin: &str| {
            format!(
                "GET /api HTTP/1.1\r\nHost: localhost:{api}\r\nOrigin: {origin}\r\n\
                 Connection: close\r\n\r\n"
            )
        };
        let (_, head) = send_to(api, &from(&format!("http://localhost:{page}"))).await;
        assert!(head.starts_with("http/1.1 200"), "{head}");
        let (_, head) = send_to(api, &from("http://evil.example")).await;
        assert!(head.starts_with("http/1.1 403"), "{head}");
    }

    #[tokio::test]
    async fn rebinding_keeps_the_ports_served() {
        let port = free_port().await;
        let setup = Setup::with_origins_redirected(vec![format!("http://localhost:{port}")]).await;
        let forwarder = setup
            .proxy
            .forward_loopback_origins(setup.share.clone())
            .await
            .unwrap();
        setup.proxy.rebind().await;
        let cookie = forwarder.cookie();
        let (_, head) = send_to(
            port,
            &format!(
                "GET / HTTP/1.1\r\nHost: localhost:{port}\r\nCookie: {}={}\r\n\
                 Connection: close\r\n\r\n",
                cookie.name, cookie.value
            ),
        )
        .await;
        assert!(head.starts_with("http/1.1 200"), "{head}");
        // The proxy's own port is served again too.
        let (_, head) = setup
            .send(&format!(
                "CONNECT localhost:{port} HTTP/1.1\r\nHost: localhost:{port}\r\n\r\n"
            ))
            .await;
        assert!(head.starts_with("http/1.1 407"), "{head}");
    }

    #[tokio::test]
    async fn the_host_header_picks_among_hosts_on_one_port() {
        let port = free_port().await;
        let setup = Setup::with_origins_redirected(vec![
            format!("http://localhost:{port}"),
            format!("http://127.0.0.1:{port}"),
        ])
        .await;
        let forwarder = setup
            .proxy
            .forward_loopback_origins(setup.share.clone())
            .await
            .unwrap();
        let cookie = forwarder.cookie();
        let (mut tcp, _) = send_to(
            port,
            &format!(
                "GET / HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\nCookie: {}={}\r\n\
                 Connection: close\r\n\r\n",
                cookie.name, cookie.value
            ),
        )
        .await;
        tcp.read_to_end(&mut Vec::new()).await.unwrap();
        assert_eq!(setup.targets(), [("127.0.0.1".to_owned(), port)]);
    }

    #[tokio::test]
    async fn forwarders_are_shared_and_release_their_ports() {
        let port = free_port().await;
        let setup = Setup::with_origins_redirected(vec![format!("http://localhost:{port}")]).await;
        let first = setup
            .proxy
            .forward_loopback_origins(setup.share.clone())
            .await
            .unwrap();
        let second = setup
            .proxy
            .forward_loopback_origins(setup.share.clone())
            .await
            .unwrap();
        assert!(Arc::ptr_eq(&first, &second));
        drop(first);
        assert!(TcpListener::bind(("127.0.0.1", port)).await.is_err());
        drop(second);
        // The accept loops are aborted on drop; give them a moment to close.
        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
        assert!(TcpListener::bind(("127.0.0.1", port)).await.is_ok());
    }

    #[tokio::test]
    async fn a_taken_port_is_reported() {
        let occupant = TcpListener::bind("127.0.0.1:0").await.unwrap();
        let port = occupant.local_addr().unwrap().port();
        let setup = Setup::with_origins_redirected(vec![format!("http://localhost:{port}")]).await;
        let taken = setup
            .proxy
            .forward_loopback_origins(setup.share.clone())
            .await;
        assert!(
            matches!(taken, Err(SdkError::PortInUse(_))),
            "{:?}",
            taken.err()
        );
    }

    #[tokio::test]
    async fn a_removed_share_answers_410() {
        let port = free_port().await;
        let setup = Setup::with_origins_redirected(vec![format!("http://localhost:{port}")]).await;
        let forwarder = setup
            .proxy
            .forward_loopback_origins(setup.share.clone())
            .await
            .unwrap();
        setup
            .client
            .row(&setup.share)
            .unwrap()
            .write_terminal_state(TerminalState::Removed.as_str())
            .unwrap();
        let cookie = forwarder.cookie();
        let (mut tcp, head) = send_to(
            port,
            &format!(
                "GET / HTTP/1.1\r\nHost: localhost:{port}\r\nCookie: {}={}\r\n\r\n",
                cookie.name, cookie.value
            ),
        )
        .await;
        assert!(head.starts_with("http/1.1 410"), "{head}");
        tcp.shutdown().await.ok();
        assert_eq!(setup.streams(), 0);
    }

    #[test]
    fn only_http_loopback_origins_are_forwarded() {
        let app = SharedApp {
            id: "a".into(),
            name: "a".into(),
            kind: wire::AppKind::Web,
            mode: AppProxyMode::ForwardProxy,
            start_url: None,
            proxied_origins: [
                "http://localhost:4200",
                "http://127.0.0.1:4200",
                "http://localhost:4200",
                "https://localhost:8443",
                "http://dev.internal:4200",
                "nonsense",
            ]
            .map(String::from)
            .to_vec(),
        };
        assert_eq!(
            loopback_ports(&[app]),
            BTreeMap::from([(4200, vec!["localhost".into(), "127.0.0.1".into()])])
        );
    }
}