diff --git a/crates-server/yaak-send-proxy/README.md b/crates-server/yaak-send-proxy/README.md index f54fd1b9..f842e7f1 100644 --- a/crates-server/yaak-send-proxy/README.md +++ b/crates-server/yaak-send-proxy/README.md @@ -39,6 +39,7 @@ arguments; `--help` lists them all. | `--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. | @@ -55,7 +56,9 @@ sits on. So by default it refuses 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 `fe80::/10`), carrier-grade NAT, multicast, reserved and unspecified ranges, - and IPv4 addresses tunnelled inside IPv6 forms (`::ffff:a.b.c.d`, NAT64); + 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`); - anything not `http://` or `https://`. The check runs **on the resolved addresses, after DNS**, for every hop of a diff --git a/crates-server/yaak-send-proxy/src/config.rs b/crates-server/yaak-send-proxy/src/config.rs index 52aab400..b99c9586 100644 --- a/crates-server/yaak-send-proxy/src/config.rs +++ b/crates-server/yaak-send-proxy/src/config.rs @@ -49,6 +49,13 @@ pub struct Config { #[arg(long, env = "YAAK_PROXY_DENY_HOSTS", value_delimiter = ',')] pub deny_hosts: Vec, + /// 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, + /// 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, diff --git a/crates-server/yaak-send-proxy/src/guard.rs b/crates-server/yaak-send-proxy/src/guard.rs index 412ab31b..d1dbffe7 100644 --- a/crates-server/yaak-send-proxy/src/guard.rs +++ b/crates-server/yaak-send-proxy/src/guard.rs @@ -26,6 +26,9 @@ pub struct DestinationPolicy { allow_private_networks: bool, allow_hosts: Vec, deny_hosts: Vec, + /// 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, } #[derive(Clone, Debug)] @@ -60,11 +63,13 @@ impl DestinationPolicy { 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(), } } @@ -105,6 +110,17 @@ 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" @@ -118,8 +134,10 @@ impl DestinationPolicy { /// /// Every range here is one a hosted relay must never be talked into reaching: the machine /// itself, the network it sits on, and the link-local range where cloud metadata services -/// (169.254.169.254) live. IPv4 addresses tunnelled inside IPv6 forms are unwrapped and judged -/// as IPv4, since that is what the socket would connect to. +/// (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`. pub fn non_public_reason(ip: IpAddr) -> Option<&'static str> { match ip { IpAddr::V4(v4) => non_public_v4(v4), @@ -127,7 +145,7 @@ pub fn non_public_reason(ip: IpAddr) -> Option<&'static str> { if let Some(v4) = v6.to_ipv4_mapped() { return non_public_v4(v4); } - if let Some(v4) = nat64_embedded_v4(&v6) { + if let Some(v4) = embedded_v4(&v6) { return non_public_v4(v4); } if v6.is_loopback() { @@ -180,15 +198,33 @@ fn non_public_v4(v4: Ipv4Addr) -> Option<&'static str> { } } -/// The IPv4 address inside a NAT64 (64:ff9b::/96) address, if this is one. -fn nat64_embedded_v4(v6: &Ipv6Addr) -> Option { +/// The IPv4 address an IPv6 address stands for, when it is one of the standard translation +/// forms: NAT64 well-known prefix (64:ff9b::/96), NAT64 local-use prefix (64:ff9b:1::/48, +/// RFC 8215), or 6to4 (2002::/16, where the IPv4 sits in the next 32 bits). +fn embedded_v4(v6: &Ipv6Addr) -> Option { let s = v6.segments(); if s[0] == 0x64 && s[1] == 0xff9b && s[2..6].iter().all(|x| *x == 0) { - let o = v6.octets(); - Some(Ipv4Addr::new(o[12], o[13], o[14], o[15])) - } else { - None + return Some(trailing_v4(v6)); } + if s[0] == 0x64 && s[1] == 0xff9b && s[2] == 1 { + return Some(trailing_v4(v6)); + } + if s[0] == 0x2002 { + return Some(Ipv4Addr::new( + (s[1] >> 8) as u8, + (s[1] & 0xff) as u8, + (s[2] >> 8) as u8, + (s[2] & 0xff) as u8, + )); + } + None +} + +/// The last 32 bits of an IPv6 address as an IPv4 address (where every /96 NAT64 prefix +/// keeps it). +fn trailing_v4(v6: &Ipv6Addr) -> Ipv4Addr { + let o = v6.octets(); + Ipv4Addr::new(o[12], o[13], o[14], o[15]) } /// An [`HttpSender`] that checks each hop's URL against the policy before delegating. @@ -273,12 +309,26 @@ 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()); @@ -290,6 +340,7 @@ mod tests { 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()); @@ -302,7 +353,7 @@ mod tests { #[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()); diff --git a/crates-server/yaak-send-proxy/src/main.rs b/crates-server/yaak-send-proxy/src/main.rs index 9b9f7dcc..6aa96608 100644 --- a/crates-server/yaak-send-proxy/src/main.rs +++ b/crates-server/yaak-send-proxy/src/main.rs @@ -53,6 +53,7 @@ async fn main() { config.allow_private_networks, &config.allow_hosts, &config.deny_hosts, + &config.nat64_prefixes, ); let state = AppState { limits: Arc::new(SendLimits { diff --git a/crates/yaak-models/src/cookies.rs b/crates/yaak-models/src/cookies.rs new file mode 100644 index 00000000..0b450134 --- /dev/null +++ b/crates/yaak-models/src/cookies.rs @@ -0,0 +1,107 @@ +//! Carrying a send's cookie changes back into a jar. +//! +//! A send starts from a snapshot of the jar and hands back the jar as the +//! transaction left it. Writing that whole result over the jar would also +//! write over anything the user changed *while* the send was in flight — a +//! cookie edited or deleted in the jar view, or set by another send. So the +//! send's contribution is taken as a difference (what it added, changed, or +//! removed relative to its snapshot) and applied to whatever the jar holds now. + +use crate::models::{Cookie, CookieDomain}; + +/// The identity of a cookie in a jar: two cookies with the same name, domain +/// and path are the same cookie, whatever their value or attributes. +type CookieKey = (String, CookieDomain, String); + +fn key(c: &Cookie) -> CookieKey { + (c.name.clone(), c.domain.clone(), c.path.clone()) +} + +/// Apply the changes between `before` (the snapshot a send started from) and +/// `after` (the jar as the send left it) to `current` (the jar as it is now). +/// +/// Cookies the send removed are removed; cookies it added or changed replace +/// their counterpart in `current`, or are appended. Cookies the send did not +/// touch are left exactly as `current` has them. +pub fn apply_cookie_changes( + current: Vec, + before: &[Cookie], + after: &[Cookie], +) -> Vec { + let removed: Vec = + before.iter().filter(|b| !after.iter().any(|a| key(a) == key(b))).map(key).collect(); + let changed: Vec<&Cookie> = after.iter().filter(|a| !before.iter().any(|b| b == *a)).collect(); + + let mut result: Vec = + current.into_iter().filter(|c| !removed.contains(&key(c))).collect(); + for cookie in changed { + match result.iter_mut().find(|c| key(c) == key(cookie)) { + Some(existing) => *existing = cookie.clone(), + None => result.push(cookie.clone()), + } + } + result +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::CookieExpires; + + fn cookie(name: &str, value: &str) -> Cookie { + Cookie { + name: name.to_string(), + value: value.to_string(), + domain: CookieDomain::HostOnly("example.com".to_string()), + expires: CookieExpires::SessionEnd, + path: "/".to_string(), + secure: false, + http_only: false, + same_site: None, + } + } + + #[test] + fn a_send_that_changed_nothing_leaves_the_jar_alone() { + let before = vec![cookie("a", "1")]; + let current = vec![cookie("a", "edited"), cookie("b", "2")]; + assert_eq!(apply_cookie_changes(current.clone(), &before, &before), current); + } + + #[test] + fn additions_and_changes_land_without_touching_concurrent_edits() { + let before = vec![cookie("a", "1"), cookie("b", "2")]; + let after = vec![cookie("a", "1"), cookie("b", "3"), cookie("c", "4")]; + // Meanwhile the user edited `a` and added `d`. + let current = vec![cookie("a", "edited"), cookie("b", "2"), cookie("d", "5")]; + assert_eq!( + apply_cookie_changes(current, &before, &after), + vec![ + cookie("a", "edited"), + cookie("b", "3"), + cookie("d", "5"), + cookie("c", "4") + ] + ); + } + + #[test] + fn a_cookie_the_send_removed_is_removed() { + let before = vec![cookie("a", "1"), cookie("b", "2")]; + let after = vec![cookie("b", "2")]; + let current = vec![cookie("a", "1"), cookie("b", "2"), cookie("c", "3")]; + assert_eq!( + apply_cookie_changes(current, &before, &after), + vec![cookie("b", "2"), cookie("c", "3")] + ); + } + + #[test] + fn a_cookie_the_user_deleted_mid_send_stays_deleted_unless_the_send_set_it() { + let before = vec![cookie("a", "1")]; + let after = vec![cookie("a", "1")]; // untouched by the send + assert_eq!(apply_cookie_changes(vec![], &before, &after), vec![]); + let after = vec![cookie("a", "fresh")]; // the send set it again + assert_eq!(apply_cookie_changes(vec![], &before, &after), vec![cookie("a", "fresh")]); + } +} diff --git a/crates/yaak-models/src/lib.rs b/crates/yaak-models/src/lib.rs index 1c95b28d..ee6c7929 100644 --- a/crates/yaak-models/src/lib.rs +++ b/crates/yaak-models/src/lib.rs @@ -10,6 +10,7 @@ use yaak_database::SqlitePool; pub mod blob_manager; pub mod client_db; +pub mod cookies; mod connection_or_tx; pub mod error; pub mod migrate; diff --git a/crates/yaak-web/pkg/yaak_web_bg.js b/crates/yaak-web/pkg/yaak_web_bg.js index 17490067..1d1d8de3 100644 --- a/crates/yaak-web/pkg/yaak_web_bg.js +++ b/crates/yaak-web/pkg/yaak_web_bg.js @@ -694,22 +694,22 @@ export function __wbg_versions_215a3ab1c9d5745a(arg0) { return ret; } export function __wbindgen_cast_0000000000000001(arg0, arg1) { - // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 1116, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`. + // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 1117, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`. const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__ha1c2fa93df0107f3); return ret; } export function __wbindgen_cast_0000000000000002(arg0, arg1) { - // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 211, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. + // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 212, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__ha7903b6e296dd8f4); return ret; } export function __wbindgen_cast_0000000000000003(arg0, arg1) { - // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("IDBVersionChangeEvent")], shim_idx: 164, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`. + // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("IDBVersionChangeEvent")], shim_idx: 83, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`. const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hdf19cb46f9aecb24); return ret; } export function __wbindgen_cast_0000000000000004(arg0, arg1) { - // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 209, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. + // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 210, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__ha1b480b83daa641f); return ret; } diff --git a/crates/yaak-web/pkg/yaak_web_bg.wasm b/crates/yaak-web/pkg/yaak_web_bg.wasm index cf8439a5..cc6bd9ad 100644 Binary files a/crates/yaak-web/pkg/yaak_web_bg.wasm and b/crates/yaak-web/pkg/yaak_web_bg.wasm differ diff --git a/crates/yaak-web/src/lib.rs b/crates/yaak-web/src/lib.rs index 1ebb4d03..ab25802a 100644 --- a/crates/yaak-web/src/lib.rs +++ b/crates/yaak-web/src/lib.rs @@ -29,8 +29,10 @@ use serde::{Deserialize, Serialize}; use std::collections::HashMap; use wasm_bindgen::prelude::*; use yaak_models::blob_manager::{BlobManager, BodyChunk}; +use yaak_models::cookies::apply_cookie_changes; use yaak_models::models::{ - AnyModel, CookieJar, HttpRequest, HttpResponseEvent, HttpResponseEventData, HttpSendSettings, + AnyModel, Cookie, CookieJar, HttpRequest, HttpResponseEvent, HttpResponseEventData, + HttpSendSettings, }; use yaak_models::models_ops; use yaak_models::query_manager::QueryManager; @@ -214,6 +216,14 @@ struct ResponseIdReq { response_id: String, } +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct PersistSendCookiesReq { + cookie_jar_id: String, + before: Vec, + after: Vec, +} + #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct InsertResponseEventsReq { @@ -341,6 +351,20 @@ fn dispatch( ) } + // The cookies a send set or cleared, applied to the jar as it is *now* rather than + // written over it, so an edit made while the send was in flight survives. + "web_persist_send_cookies" => { + let req: PersistSendCookiesReq = from_js(payload)?; + if req.before == req.after { + return to_json(()); + } + let db = host.queries.connect(); + let jar = db.get_cookie_jar(&req.cookie_jar_id).map_err(js_error)?; + let cookies = apply_cookie_changes(jar.cookies.clone(), &req.before, &req.after); + db.upsert_cookie_jar(&CookieJar { cookies, ..jar }, source).map_err(js_error)?; + to_json(()) + } + // The tab's half of the send timeline: the events the proxy streamed back, recorded // under the response they belong to. Same rows the desktop's send task writes, and the // writes fan out to every tab as `model_writes` like any other. diff --git a/packages/platform/src/web/send.ts b/packages/platform/src/web/send.ts index 57e28a99..9ca8d609 100644 --- a/packages/platform/src/web/send.ts +++ b/packages/platform/src/web/send.ts @@ -305,12 +305,14 @@ class TimelineWriter { } } +/** + * Carry the send's cookie changes into the jar. The worker applies them as a + * difference against the jar as it is now (see `apply_cookie_changes` in + * yaak-models), so an edit made while the send was in flight survives rather + * than being written over by the send's stale snapshot. + */ async function persistCookies(db: WorkerConnection, jar: CookieJar, cookies: Cookie[]): Promise { - // The desktop compares before writing so a jar edited mid-send isn't clobbered - // by an unchanged copy. Structural equality is enough here: cookies are plain - // data and the proxy hands back the whole jar. - if (JSON.stringify(cookies) === JSON.stringify(jar.cookies)) return; - await db.rpc("models_upsert", { model: { ...jar, cookies } }); + await db.rpc("web_persist_send_cookies", { cookieJarId: jar.id, before: jar.cookies, after: cookies }); } /**