Drop the token, host lists and NAT64 prefixes; nothing uses them yet

This commit is contained in:
Gregory Schier
2026-08-17 10:59:26 -07:00
parent 93212e750d
commit 58d625cc39
4 changed files with 27 additions and 177 deletions
+13 -15
View File
@@ -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. | | `--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. | | `--allowed-origins` | `*` | CORS origins, comma-separated. A hosted instance should name its web origin. |
| `--token` | unset | Require `Authorization: Bearer <token>`. 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-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-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-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. | | `--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 `fc00::/7`), link-local (`169.254/16` — where cloud metadata lives — and
`fe80::/10`), carrier-grade NAT, multicast, reserved and unspecified ranges, `fe80::/10`), carrier-grade NAT, multicast, reserved and unspecified ranges,
and IPv4 addresses carried inside IPv6 forms (`::ffff:a.b.c.d`, the 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 well-known and local-use NAT64 prefixes, 6to4);
name with `--nat64-prefixes`);
- anything not `http://` or `https://`. - anything not `http://` or `https://`.
The check runs **on the resolved addresses, after DNS**, for every hop of a 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 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 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 ## 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 cargo build --release -p yaak-send-proxy
YAAK_PROXY_BIND=0.0.0.0:9227 \ YAAK_PROXY_BIND=0.0.0.0:9227 \
YAAK_PROXY_ALLOWED_ORIGINS=https://yaak.example.com \ YAAK_PROXY_ALLOWED_ORIGINS=https://yaak.example.com \
YAAK_PROXY_TOKEN=change-me \
./target/release/yaak-send-proxy ./target/release/yaak-send-proxy
``` ```
Put TLS in front of it (a reverse proxy) — the token travels as a header. If the There is no authentication: an instance is anonymous and protected by the
reverse proxy buffers responses, tell it not to: the reply is a stream and the per-client rate limit and the destination policy, which is what the hosted
`X-Accel-Buffering: no` header it sets is honoured by nginx-shaped ones. 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 ## The wire
@@ -116,8 +114,8 @@ happened:
| `error` | last, on failure | the reason, and any cookies collected before the failure | | `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, 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`) rate limit, capacity) are plain HTTP errors (`403`, `400`, `429`, `503`) with
with `{"error": "…"}`, not streams. `{"error": "…"}`, not streams.
Why a streamed HTTP response and not a WebSocket: one `POST` is stateless by Why a streamed HTTP response and not a WebSocket: one `POST` is stateless by
construction, cancellable by closing the connection, readable with `curl`, and 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 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, (`/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, bidirectional endpoints on the same binary, behind the same destination policy
limits and token. They differ from this endpoint in holding per-connection and limits. They differ from this endpoint in holding per-connection
in-memory state while a connection is open (never persisted), which brings in-memory state while a connection is open (never persisted), which brings
connection limits and a larger abuse surface — the reason they are separate connection limits and a larger abuse surface — the reason they are separate
work. work.
+2 -23
View File
@@ -22,11 +22,6 @@ pub struct Config {
)] )]
pub allowed_origins: Vec<String>, pub allowed_origins: Vec<String>,
/// Require `Authorization: Bearer <token>` 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<String>,
/// Allow sends to private, loopback, link-local and other non-public addresses. /// 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 /// 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, 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<String>,
/// 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<String>,
/// 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<std::net::Ipv6Addr>,
/// Largest request the proxy accepts from the tab (the rendered request JSON, body included). /// 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)] #[arg(long, env = "YAAK_PROXY_MAX_REQUEST_BYTES", default_value_t = 16 * 1024 * 1024)]
pub max_request_bytes: usize, pub max_request_bytes: usize,
@@ -69,7 +47,8 @@ pub struct Config {
#[arg(long, env = "YAAK_PROXY_MAX_TIMEOUT_SECS", default_value_t = 60)] #[arg(long, env = "YAAK_PROXY_MAX_TIMEOUT_SECS", default_value_t = 60)]
pub max_timeout_secs: u64, 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)] #[arg(long, env = "YAAK_PROXY_RATE_LIMIT_PER_MINUTE", default_value_t = 120)]
pub rate_limit_per_minute: u32, pub rate_limit_per_minute: u32,
+9 -102
View File
@@ -24,57 +24,15 @@ use yaak_http::types::SendableHttpRequest;
#[derive(Clone)] #[derive(Clone)]
pub struct DestinationPolicy { pub struct DestinationPolicy {
allow_private_networks: bool, allow_private_networks: bool,
allow_hosts: Vec<HostPattern>,
deny_hosts: Vec<HostPattern>,
/// 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<Ipv6Addr>,
}
#[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<Self> {
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 { impl DestinationPolicy {
pub fn new( pub fn new(allow_private_networks: bool) -> Self {
allow_private_networks: bool, Self { allow_private_networks }
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(),
}
} }
/// Check a URL before a hop is attempted: scheme, host lists, and literal IPs. A hostname /// Check a URL before a hop is attempted: scheme and literal IPs. A hostname that passes
/// that passes here still has its resolved addresses checked by [`Self::address_filter`]. /// here still has its resolved addresses checked by [`Self::address_filter`].
pub fn check_url(&self, raw: &str) -> Result<(), String> { pub fn check_url(&self, raw: &str) -> Result<(), String> {
let url = Url::parse(raw).map_err(|e| format!("Invalid URL {raw:?}: {e}"))?; let url = Url::parse(raw).map_err(|e| format!("Invalid URL {raw:?}: {e}"))?;
match url.scheme() { match url.scheme() {
@@ -82,15 +40,7 @@ impl DestinationPolicy {
other => return Err(format!("Refusing to send over {other:?}; only http and https")), 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 = url.host_str().ok_or_else(|| format!("URL {raw:?} has no host"))?;
let host = let host = host.trim_matches(|c| c == '[' || c == ']');
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"));
}
// A literal IP never reaches the resolver, so it is checked here. Hostnames are checked // A literal IP never reaches the resolver, so it is checked here. Hostnames are checked
// where their addresses become known. // where their addresses become known.
@@ -110,17 +60,6 @@ impl DestinationPolicy {
if self.allow_private_networks { if self.allow_private_networks {
return Ok(()); 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) { match non_public_reason(ip) {
Some(reason) => Err(format!( Some(reason) => Err(format!(
"Refusing to connect to {ip}: {reason}. This proxy only sends to public addresses" "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 /// 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 /// (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 /// 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; /// that is where the packets end up. (A network-specific NAT64 prefix is not knowable here.)
/// see `--nat64-prefix`.
pub fn non_public_reason(ip: IpAddr) -> Option<&'static str> { pub fn non_public_reason(ip: IpAddr) -> Option<&'static str> {
match ip { match ip {
IpAddr::V4(v4) => non_public_v4(v4), 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] #[test]
fn private_networks_can_be_opted_in() { 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://127.0.0.1/").is_ok());
assert!(policy.check_url("http://169.254.169.254/").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://127.0.0.1/").is_err());
assert!(policy.check_url("http://[::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()); 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] #[test]
fn only_http_schemes() { 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("ftp://example.com/").is_err());
assert!(policy.check_url("file:///etc/passwd").is_err()); assert!(policy.check_url("file:///etc/passwd").is_err());
assert!(policy.check_url("https://example.com/").is_ok()); assert!(policy.check_url("https://example.com/").is_ok());
+3 -37
View File
@@ -49,12 +49,7 @@ async fn main() {
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init(); env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
let config = Config::parse(); let config = Config::parse();
let policy = DestinationPolicy::new( let policy = DestinationPolicy::new(config.allow_private_networks);
config.allow_private_networks,
&config.allow_hosts,
&config.deny_hosts,
&config.nat64_prefixes,
);
let state = AppState { let state = AppState {
limits: Arc::new(SendLimits { limits: Arc::new(SendLimits {
policy, policy,
@@ -68,11 +63,10 @@ async fn main() {
let cors = CorsLayer::new() let cors = CorsLayer::new()
.allow_methods([Method::GET, Method::POST, Method::OPTIONS]) .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)); .allow_origin(allowed_origins(&state.config.allowed_origins));
let app = Router::new() let app = Router::new()
.route("/", get(root))
.route("/v1/health", get(health)) .route("/v1/health", get(health))
// A WebSocket or gRPC relay would sit beside this as `/v1/ws/relay` and `/v1/grpc/relay` // 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. // 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); std::process::exit(1);
}); });
info!( 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.allow_private_networks { "allowed" } else { "refused" },
if state.config.token.is_some() { "required" } else { "none" },
state.config.rate_limit_per_minute, state.config.rate_limit_per_minute,
); );
@@ -111,16 +104,11 @@ fn allowed_origins(origins: &[String]) -> AllowOrigin {
AllowOrigin::list(parsed) 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<AppState>) -> impl IntoResponse { async fn health(State(state): State<AppState>) -> impl IntoResponse {
Json(json!({ Json(json!({
"ok": true, "ok": true,
"version": env!("CARGO_PKG_VERSION"), "version": env!("CARGO_PKG_VERSION"),
"privateNetworks": state.config.allow_private_networks, "privateNetworks": state.config.allow_private_networks,
"tokenRequired": state.config.token.is_some(),
"maxResponseBytes": state.config.max_response_bytes, "maxResponseBytes": state.config.max_response_bytes,
"maxTimeoutSecs": state.config.max_timeout_secs, "maxTimeoutSecs": state.config.max_timeout_secs,
})) }))
@@ -144,34 +132,12 @@ fn client_ip(config: &Config, headers: &HeaderMap, peer: SocketAddr) -> IpAddr {
peer.ip() 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( async fn send_http(
State(state): State<AppState>, State(state): State<AppState>,
ConnectInfo(peer): ConnectInfo<SocketAddr>, ConnectInfo(peer): ConnectInfo<SocketAddr>,
headers: HeaderMap, headers: HeaderMap,
Json(body): Json<SendRequest>, Json(body): Json<SendRequest>,
) -> Response { ) -> Response {
if !authorized(&state.config, &headers) {
return error_response(StatusCode::UNAUTHORIZED, "This proxy requires a token");
}
let ip = client_ip(&state.config, &headers, peer); let ip = client_ip(&state.config, &headers, peer);
if let Err(wait) = state.rate_limiter.check(ip) { if let Err(wait) = state.rate_limiter.check(ip) {
warn!("Rate limited {ip}"); warn!("Rate limited {ip}");