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,
}))