mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-08-19 09:55:15 +02:00
Drop the token, host lists and NAT64 prefixes; nothing uses them yet
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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}");
|
||||
|
||||
Reference in New Issue
Block a user