diff --git a/crates-server/yaak-send-proxy/README.md b/crates-server/yaak-send-proxy/README.md index f842e7f1..81ab1c6d 100644 --- a/crates-server/yaak-send-proxy/README.md +++ b/crates-server/yaak-send-proxy/README.md @@ -35,11 +35,7 @@ arguments; `--help` lists them all. | --- | --- | --- | | `--bind` | `127.0.0.1:9227` | Listen address. `0.0.0.0:9227` inside a container. | | `--allowed-origins` | `*` | CORS origins, comma-separated. A hosted instance should name its web origin. | -| `--token` | unset | Require `Authorization: Bearer `. Unset means anonymous, which is what the hosted funnel wants alongside the rate limit. | | `--allow-private-networks` | off | Let sends reach private, loopback and link-local addresses. **Off by default; see below.** | -| `--allow-hosts` | empty | Only these hosts (`api.example.com`, `*.example.com`). Empty means any host not denied. | -| `--deny-hosts` | empty | Never these hosts. Checked before the allow list. | -| `--nat64-prefixes` | empty | Network-specific NAT64 prefixes (`/96`) whose embedded IPv4 should be judged; the well-known and local-use prefixes always are. | | `--max-request-bytes` | 16 MiB | Largest rendered request accepted from the tab. | | `--max-response-bytes` | 64 MiB | Largest upstream body relayed before the send is cut off. | | `--max-timeout-secs` | 60 | Ceiling on a send's timeout; a request asking for more (or none) gets this. | @@ -57,8 +53,7 @@ sits on. So by default it refuses to connect to: `fc00::/7`), link-local (`169.254/16` — where cloud metadata lives — and `fe80::/10`), carrier-grade NAT, multicast, reserved and unspecified ranges, and IPv4 addresses carried inside IPv6 forms (`::ffff:a.b.c.d`, the - well-known and local-use NAT64 prefixes, 6to4 — plus any NAT64 prefix you - name with `--nat64-prefixes`); + well-known and local-use NAT64 prefixes, 6to4); - anything not `http://` or `https://`. The check runs **on the resolved addresses, after DNS**, for every hop of a @@ -69,7 +64,7 @@ fields), since no browser tab could legitimately mean those. Refusals are logged with the reason. A self-hosted instance on a private network that legitimately needs to reach the services next to it turns the range check -off with `--allow-private-networks`, and can narrow that with `--allow-hosts`. +off with `--allow-private-networks`. ## Self-hosting @@ -79,13 +74,16 @@ One binary, no dependencies. Build it and run it wherever you like: cargo build --release -p yaak-send-proxy YAAK_PROXY_BIND=0.0.0.0:9227 \ YAAK_PROXY_ALLOWED_ORIGINS=https://yaak.example.com \ -YAAK_PROXY_TOKEN=change-me \ ./target/release/yaak-send-proxy ``` -Put TLS in front of it (a reverse proxy) — the token travels as a header. If the -reverse proxy buffers responses, tell it not to: the reply is a stream and the -`X-Accel-Buffering: no` header it sets is honoured by nginx-shaped ones. +There is no authentication: an instance is anonymous and protected by the +per-client rate limit and the destination policy, which is what the hosted +funnel wants. Anything more (a shared token, per-user quotas) is a later slice +and would sit in front of `send_http` in `main.rs`. Put TLS in front of it (a +reverse proxy). If the reverse proxy buffers responses, tell it not to: the reply +is a stream and the `X-Accel-Buffering: no` header it sets is honoured by +nginx-shaped ones. ## The wire @@ -116,8 +114,8 @@ happened: | `error` | last, on failure | the reason, and any cookies collected before the failure | Refusals that happen before anything is sent (a blocked destination, a bad body, -rate limit, missing token) are plain HTTP errors (`403`, `400`, `429`, `401`) -with `{"error": "…"}`, not streams. +rate limit, capacity) are plain HTTP errors (`403`, `400`, `429`, `503`) with +`{"error": "…"}`, not streams. Why a streamed HTTP response and not a WebSocket: one `POST` is stateless by construction, cancellable by closing the connection, readable with `curl`, and @@ -135,8 +133,8 @@ wire on one side is a type error on the other. Not built, by design, but the router is shaped for it: a WebSocket relay (`/v1/ws/relay`) and a gRPC relay (`/v1/grpc/relay`) would be long-lived, -bidirectional endpoints on the same binary, behind the same destination policy, -limits and token. They differ from this endpoint in holding per-connection +bidirectional endpoints on the same binary, behind the same destination policy +and limits. They differ from this endpoint in holding per-connection in-memory state while a connection is open (never persisted), which brings connection limits and a larger abuse surface — the reason they are separate work. diff --git a/crates-server/yaak-send-proxy/src/config.rs b/crates-server/yaak-send-proxy/src/config.rs index b99c9586..fe3880ae 100644 --- a/crates-server/yaak-send-proxy/src/config.rs +++ b/crates-server/yaak-send-proxy/src/config.rs @@ -22,11 +22,6 @@ pub struct Config { )] pub allowed_origins: Vec, - /// Require `Authorization: Bearer ` on every send. Unset means anonymous access, - /// which is what the hosted funnel wants alongside the rate limit. - #[arg(long, env = "YAAK_PROXY_TOKEN")] - pub token: Option, - /// Allow sends to private, loopback, link-local and other non-public addresses. /// /// Off by default: a hosted instance must not become a relay into its own network. A @@ -39,23 +34,6 @@ pub struct Config { )] pub allow_private_networks: bool, - /// Only allow sends to these hosts (exact host, or `*.suffix`), comma-separated. Empty means - /// every host not on the deny list. - #[arg(long, env = "YAAK_PROXY_ALLOW_HOSTS", value_delimiter = ',')] - pub allow_hosts: Vec, - - /// Never send to these hosts (exact host, or `*.suffix`), comma-separated. Checked before the - /// allow list. - #[arg(long, env = "YAAK_PROXY_DENY_HOSTS", value_delimiter = ',')] - pub deny_hosts: Vec, - - /// NAT64 prefixes (/96) in use on this network, comma-separated, e.g. `2001:db8:64::`. The - /// well-known (64:ff9b::/96) and local-use (64:ff9b:1::/48) prefixes are always recognised; - /// a network-specific prefix has to be named here or an IPv6 address under it could reach - /// an internal IPv4 host through the translator. - #[arg(long, env = "YAAK_PROXY_NAT64_PREFIXES", value_delimiter = ',')] - pub nat64_prefixes: Vec, - /// Largest request the proxy accepts from the tab (the rendered request JSON, body included). #[arg(long, env = "YAAK_PROXY_MAX_REQUEST_BYTES", default_value_t = 16 * 1024 * 1024)] pub max_request_bytes: usize, @@ -69,7 +47,8 @@ pub struct Config { #[arg(long, env = "YAAK_PROXY_MAX_TIMEOUT_SECS", default_value_t = 60)] pub max_timeout_secs: u64, - /// Sends allowed per client IP per minute. 0 disables the limit. + /// Sends allowed per client IP per minute. 0 disables the limit. This and the concurrency + /// cap are the whole of what protects an instance: there is no authentication. #[arg(long, env = "YAAK_PROXY_RATE_LIMIT_PER_MINUTE", default_value_t = 120)] pub rate_limit_per_minute: u32, diff --git a/crates-server/yaak-send-proxy/src/guard.rs b/crates-server/yaak-send-proxy/src/guard.rs index d1dbffe7..d09177af 100644 --- a/crates-server/yaak-send-proxy/src/guard.rs +++ b/crates-server/yaak-send-proxy/src/guard.rs @@ -24,57 +24,15 @@ use yaak_http::types::SendableHttpRequest; #[derive(Clone)] pub struct DestinationPolicy { allow_private_networks: bool, - allow_hosts: Vec, - deny_hosts: Vec, - /// NAT64 prefixes (/96) in use on this network beyond the well-known ones. An IPv6 - /// address under one of these is really an IPv4 destination, and is judged as that. - nat64_prefixes: Vec, -} - -#[derive(Clone, Debug)] -enum HostPattern { - Exact(String), - /// `*.example.com`: any subdomain, and the bare domain too. - Suffix(String), -} - -impl HostPattern { - fn parse(raw: &str) -> Option { - let raw = raw.trim().trim_end_matches('.').to_ascii_lowercase(); - if raw.is_empty() { - return None; - } - Some(match raw.strip_prefix("*.") { - Some(suffix) => Self::Suffix(suffix.to_string()), - None => Self::Exact(raw), - }) - } - - fn matches(&self, host: &str) -> bool { - match self { - Self::Exact(h) => host == h, - Self::Suffix(s) => host == s || host.strip_suffix(s).is_some_and(|p| p.ends_with('.')), - } - } } impl DestinationPolicy { - pub fn new( - allow_private_networks: bool, - allow_hosts: &[String], - deny_hosts: &[String], - nat64_prefixes: &[Ipv6Addr], - ) -> Self { - Self { - allow_private_networks, - allow_hosts: allow_hosts.iter().filter_map(|h| HostPattern::parse(h)).collect(), - deny_hosts: deny_hosts.iter().filter_map(|h| HostPattern::parse(h)).collect(), - nat64_prefixes: nat64_prefixes.to_vec(), - } + pub fn new(allow_private_networks: bool) -> Self { + Self { allow_private_networks } } - /// Check a URL before a hop is attempted: scheme, host lists, and literal IPs. A hostname - /// that passes here still has its resolved addresses checked by [`Self::address_filter`]. + /// Check a URL before a hop is attempted: scheme and literal IPs. A hostname that passes + /// here still has its resolved addresses checked by [`Self::address_filter`]. pub fn check_url(&self, raw: &str) -> Result<(), String> { let url = Url::parse(raw).map_err(|e| format!("Invalid URL {raw:?}: {e}"))?; match url.scheme() { @@ -82,15 +40,7 @@ impl DestinationPolicy { other => return Err(format!("Refusing to send over {other:?}; only http and https")), } let host = url.host_str().ok_or_else(|| format!("URL {raw:?} has no host"))?; - let host = - host.trim_matches(|c| c == '[' || c == ']').trim_end_matches('.').to_ascii_lowercase(); - - if self.deny_hosts.iter().any(|p| p.matches(&host)) { - return Err(format!("Host {host:?} is on this proxy's deny list")); - } - if !self.allow_hosts.is_empty() && !self.allow_hosts.iter().any(|p| p.matches(&host)) { - return Err(format!("Host {host:?} is not on this proxy's allow list")); - } + let host = host.trim_matches(|c| c == '[' || c == ']'); // A literal IP never reaches the resolver, so it is checked here. Hostnames are checked // where their addresses become known. @@ -110,17 +60,6 @@ impl DestinationPolicy { if self.allow_private_networks { return Ok(()); } - // An address under a configured NAT64 prefix reaches an IPv4 host; judge that host. - if let IpAddr::V6(v6) = ip - && self.nat64_prefixes.iter().any(|p| p.segments()[..6] == v6.segments()[..6]) - { - let v4 = trailing_v4(&v6); - if let Some(reason) = non_public_v4(v4) { - return Err(format!( - "Refusing to connect to {ip} (NAT64 for {v4}): {reason}. This proxy only sends to public addresses" - )); - } - } match non_public_reason(ip) { Some(reason) => Err(format!( "Refusing to connect to {ip}: {reason}. This proxy only sends to public addresses" @@ -136,8 +75,7 @@ impl DestinationPolicy { /// itself, the network it sits on, and the link-local range where cloud metadata services /// (169.254.169.254) live. IPv4 addresses carried inside IPv6 forms — IPv4-mapped, the /// well-known and local-use NAT64 prefixes, 6to4 — are unwrapped and judged as IPv4, since -/// that is where the packets end up. A network-specific NAT64 prefix is not knowable here; -/// see `--nat64-prefix`. +/// that is where the packets end up. (A network-specific NAT64 prefix is not knowable here.) pub fn non_public_reason(ip: IpAddr) -> Option<&'static str> { match ip { IpAddr::V4(v4) => non_public_v4(v4), @@ -309,51 +247,20 @@ mod tests { } } - #[test] - fn a_configured_nat64_prefix_is_judged_by_the_address_it_carries() { - // A made-up global prefix, standing in for whatever the network's translator uses - let prefix: Ipv6Addr = "2a02:1234:64::".parse().unwrap(); - let policy = DestinationPolicy::new(false, &[], &[], &[prefix]); - assert!(policy.check_ip(ip("2a02:1234:64::a9fe:a9fe")).is_err(), "metadata behind NAT64"); - assert!(policy.check_ip(ip("2a02:1234:64::0a00:1")).is_err(), "10.0.0.1 behind NAT64"); - assert!(policy.check_ip(ip("2a02:1234:64::0101:0101")).is_ok(), "1.1.1.1 behind NAT64"); - // Without the prefix configured the same address is an ordinary global v6, and passes: - // that is exactly the gap the option exists to close. - let policy = DestinationPolicy::new(false, &[], &[], &[]); - assert!(policy.check_ip(ip("2a02:1234:64::a9fe:a9fe")).is_ok()); - } - #[test] fn private_networks_can_be_opted_in() { - let policy = DestinationPolicy::new(true, &[], &[], &[]); + let policy = DestinationPolicy::new(true); assert!(policy.check_url("http://127.0.0.1/").is_ok()); assert!(policy.check_url("http://169.254.169.254/").is_ok()); - let policy = DestinationPolicy::new(false, &[], &[], &[]); + let policy = DestinationPolicy::new(false); assert!(policy.check_url("http://127.0.0.1/").is_err()); assert!(policy.check_url("http://[::1]/").is_err()); assert!(policy.check_url("http://169.254.169.254/latest/meta-data").is_err()); } - #[test] - fn host_lists() { - let policy = DestinationPolicy::new( - false, - &["*.example.com".into(), "api.test".into()], - &["bad.example.com".into()], - &[], - ); - assert!(policy.check_url("https://example.com/").is_ok()); - assert!(policy.check_url("https://a.b.example.com/").is_ok()); - assert!(policy.check_url("https://api.test/").is_ok()); - assert!(policy.check_url("https://API.TEST./").is_ok()); - assert!(policy.check_url("https://bad.example.com/").is_err(), "deny wins over allow"); - assert!(policy.check_url("https://notexample.com/").is_err()); - assert!(policy.check_url("https://httpbin.org/").is_err(), "not on the allow list"); - } - #[test] fn only_http_schemes() { - let policy = DestinationPolicy::new(true, &[], &[], &[]); + let policy = DestinationPolicy::new(true); assert!(policy.check_url("ftp://example.com/").is_err()); assert!(policy.check_url("file:///etc/passwd").is_err()); assert!(policy.check_url("https://example.com/").is_ok()); diff --git a/crates-server/yaak-send-proxy/src/main.rs b/crates-server/yaak-send-proxy/src/main.rs index 6aa96608..e35b749a 100644 --- a/crates-server/yaak-send-proxy/src/main.rs +++ b/crates-server/yaak-send-proxy/src/main.rs @@ -49,12 +49,7 @@ async fn main() { env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init(); let config = Config::parse(); - let policy = DestinationPolicy::new( - config.allow_private_networks, - &config.allow_hosts, - &config.deny_hosts, - &config.nat64_prefixes, - ); + let policy = DestinationPolicy::new(config.allow_private_networks); let state = AppState { limits: Arc::new(SendLimits { policy, @@ -68,11 +63,10 @@ async fn main() { let cors = CorsLayer::new() .allow_methods([Method::GET, Method::POST, Method::OPTIONS]) - .allow_headers([header::CONTENT_TYPE, header::AUTHORIZATION]) + .allow_headers([header::CONTENT_TYPE]) .allow_origin(allowed_origins(&state.config.allowed_origins)); let app = Router::new() - .route("/", get(root)) .route("/v1/health", get(health)) // A WebSocket or gRPC relay would sit beside this as `/v1/ws/relay` and `/v1/grpc/relay` // on the same router, behind the same policy, limits and auth. Not built; see README. @@ -87,9 +81,8 @@ async fn main() { std::process::exit(1); }); info!( - "yaak-send-proxy listening on http://{bind} (private networks: {}, token: {}, rate limit: {}/min)", + "yaak-send-proxy listening on http://{bind} (private networks: {}, rate limit: {}/min)", if state.config.allow_private_networks { "allowed" } else { "refused" }, - if state.config.token.is_some() { "required" } else { "none" }, state.config.rate_limit_per_minute, ); @@ -111,16 +104,11 @@ fn allowed_origins(origins: &[String]) -> AllowOrigin { AllowOrigin::list(parsed) } -async fn root() -> impl IntoResponse { - "yaak-send-proxy: POST /v1/http/send (see https://github.com/mountain-loop/yaak)\n" -} - async fn health(State(state): State) -> impl IntoResponse { Json(json!({ "ok": true, "version": env!("CARGO_PKG_VERSION"), "privateNetworks": state.config.allow_private_networks, - "tokenRequired": state.config.token.is_some(), "maxResponseBytes": state.config.max_response_bytes, "maxTimeoutSecs": state.config.max_timeout_secs, })) @@ -144,34 +132,12 @@ fn client_ip(config: &Config, headers: &HeaderMap, peer: SocketAddr) -> IpAddr { peer.ip() } -fn authorized(config: &Config, headers: &HeaderMap) -> bool { - let Some(expected) = config.token.as_deref() else { - return true; - }; - headers - .get(header::AUTHORIZATION) - .and_then(|v| v.to_str().ok()) - .and_then(|v| v.strip_prefix("Bearer ")) - .is_some_and(|got| constant_time_eq(got.as_bytes(), expected.as_bytes())) -} - -fn constant_time_eq(a: &[u8], b: &[u8]) -> bool { - if a.len() != b.len() { - return false; - } - a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0 -} - async fn send_http( State(state): State, ConnectInfo(peer): ConnectInfo, headers: HeaderMap, Json(body): Json, ) -> Response { - if !authorized(&state.config, &headers) { - return error_response(StatusCode::UNAUTHORIZED, "This proxy requires a token"); - } - let ip = client_ip(&state.config, &headers, peer); if let Err(wait) = state.rate_limiter.check(ip) { warn!("Rate limited {ip}");