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. |
| `--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-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.
+2 -23
View File
@@ -22,11 +22,6 @@ pub struct Config {
)]
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.
///
/// 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<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).
#[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,
+9 -102
View File
@@ -24,57 +24,15 @@ use yaak_http::types::SendableHttpRequest;
#[derive(Clone)]
pub struct DestinationPolicy {
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 {
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());
+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();
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<AppState>) -> 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<AppState>,
ConnectInfo(peer): ConnectInfo<SocketAddr>,
headers: HeaderMap,
Json(body): Json<SendRequest>,
) -> 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}");