Refuse private ranges unconditionally; the proxy's network is never the user's

This commit is contained in:
Gregory Schier
2026-08-17 11:00:39 -07:00
parent 58d625cc39
commit 679e49d1eb
5 changed files with 17 additions and 40 deletions
+4 -5
View File
@@ -35,7 +35,6 @@ 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. |
| `--allow-private-networks` | off | Let sends reach private, loopback and link-local addresses. **Off by default; see below.** |
| `--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. |
@@ -47,7 +46,7 @@ arguments; `--help` lists them all.
A hosted proxy is, by construction, a machine that makes HTTP requests on
behalf of strangers. Left alone that is an open relay into whatever network it
sits on. So by default it refuses to connect to:
sits on. So it refuses, always, to connect to:
- loopback (`127/8`, `::1`), private (`10/8`, `172.16/12`, `192.168/16`,
`fc00::/7`), link-local (`169.254/16` — where cloud metadata lives — and
@@ -62,9 +61,9 @@ caught, and so is a `Location:` header that points at one. It also refuses body
types that would read files on the proxy's disk (`binary`, multipart file
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`.
Refusals are logged with the reason. There is no switch to turn this off: the
proxy's private network is the cloud's, not the user's, so a `localhost` or LAN
API can never be reached through it — that is what the desktop app is for.
## Self-hosting
@@ -22,18 +22,6 @@ pub struct Config {
)]
pub allowed_origins: Vec<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
/// self-hosted instance on a private network legitimately needs this on to reach the
/// services next to it.
#[arg(
long,
env = "YAAK_PROXY_ALLOW_PRIVATE_NETWORKS",
default_value_t = false
)]
pub allow_private_networks: bool,
/// 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,
+8 -18
View File
@@ -20,17 +20,13 @@ use yaak_http::dns::AddressFilter;
use yaak_http::sender::{HttpResponse, HttpResponseEvent, HttpSender};
use yaak_http::types::SendableHttpRequest;
/// The destination policy, built once from config and shared by every send.
#[derive(Clone)]
pub struct DestinationPolicy {
allow_private_networks: bool,
}
/// The destination policy, shared by every send: public addresses only, always. A hosted
/// proxy's "private network" is the cloud's, not the user's, so there is no configuration
/// that makes reaching it right.
#[derive(Clone, Default)]
pub struct DestinationPolicy;
impl DestinationPolicy {
pub fn new(allow_private_networks: bool) -> Self {
Self { allow_private_networks }
}
/// 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> {
@@ -57,9 +53,6 @@ impl DestinationPolicy {
}
pub fn check_ip(&self, ip: IpAddr) -> Result<(), String> {
if self.allow_private_networks {
return Ok(());
}
match non_public_reason(ip) {
Some(reason) => Err(format!(
"Refusing to connect to {ip}: {reason}. This proxy only sends to public addresses"
@@ -248,11 +241,8 @@ mod tests {
}
#[test]
fn private_networks_can_be_opted_in() {
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);
fn literal_private_addresses_in_urls_are_refused() {
let policy = DestinationPolicy;
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());
@@ -260,7 +250,7 @@ mod tests {
#[test]
fn only_http_schemes() {
let policy = DestinationPolicy::new(true);
let policy = DestinationPolicy;
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());
+2 -4
View File
@@ -49,7 +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);
let policy = DestinationPolicy;
let state = AppState {
limits: Arc::new(SendLimits {
policy,
@@ -81,8 +81,7 @@ async fn main() {
std::process::exit(1);
});
info!(
"yaak-send-proxy listening on http://{bind} (private networks: {}, rate limit: {}/min)",
if state.config.allow_private_networks { "allowed" } else { "refused" },
"yaak-send-proxy listening on http://{bind} (rate limit: {}/min)",
state.config.rate_limit_per_minute,
);
@@ -108,7 +107,6 @@ async fn health(State(state): State<AppState>) -> impl IntoResponse {
Json(json!({
"ok": true,
"version": env!("CARGO_PKG_VERSION"),
"privateNetworks": state.config.allow_private_networks,
"maxResponseBytes": state.config.max_response_bytes,
"maxTimeoutSecs": state.config.max_timeout_secs,
}))
+3 -1
View File
@@ -208,7 +208,9 @@ template *function* (`${[ timestamp() ]}`) or an authentication plugin (bearer,
basic, OAuth, …) is refused before anything leaves the tab, with a message naming
what it needs; those light up when plugins run in the browser. Requests with a
file body or multipart file fields are refused by the proxy (it has no access to
your files, and must not read its own).
your files, and must not read its own). And a request to `localhost` or a LAN
address can't work from a browser: the proxy runs elsewhere and refuses private
ranges outright — reaching your own machine's APIs is what the desktop app is for.
The proxy URL is `VITE_YAAK_SEND_PROXY_URL` at build time, defaulting to
`http://127.0.0.1:9227` (see `proxy.ts`). Run one with `cargo run -p yaak-send-proxy`.