From 4eda77a2de238e642cf4c33ca2b1521b7fbe23e2 Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Mon, 26 Oct 2026 02:08:04 +0000 Subject: [PATCH 1/3] =?UTF-8?q?#3876:=20review=20=E2=80=94=20Zotero=20owne?= =?UTF-8?q?r=20check=20streams=20the=20socket=20table,=20and=20an=20unread?= =?UTF-8?q?able=20table=20refuses?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Linux same-user check read /proc/net/tcp{,6} through a 64 MiB take(). Hitting the cap was silent, so a local user binding ~450k loopback listeners pushed the connection rows past it; both uids came back unknown and the "trust loopback" arm let their fake Zotero listener on 23116 through. A truncated last line could also glue to the next header. Now each table is streamed line by line (BufRead, nothing kept, no total cap, lines capped at 4 KiB) and stops once both rows are found. Any read error refuses. "Unknown, trust loopback" remains only for a listener absent from a table read in full (WSL2, proxies). Tests: rows after 70 MiB of filler, and a read error mid-table. rich.rs: document that the MAX_RUNS cap still allows one first run per paragraph (bounded by MAX_PARAS). Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/zotero/src/peer.rs | 157 ++++++++++++++++++++++++++++++++------ crates/zotero/src/rich.rs | 2 +- 2 files changed, 135 insertions(+), 24 deletions(-) diff --git a/crates/zotero/src/peer.rs b/crates/zotero/src/peer.rs index 07df98e79..68e218536 100644 --- a/crates/zotero/src/peer.rs +++ b/crates/zotero/src/peer.rs @@ -35,23 +35,25 @@ pub(crate) fn check(stream: &TcpStream) -> Result<(), ZoteroError> { } /// Refuse a listener at `peer` that the kernel's socket tables say belongs to another user. +/// A table that can't be read in full (I/O error) is a refusal, never "unknown". #[cfg(any(target_os = "linux", target_os = "android"))] fn owner_check(local: SocketAddr, peer: SocketAddr) -> Result<(), ZoteroError> { - use std::io::Read; - /// Longest socket table read (a busy server's table is a few MB). - const MAX_TABLE: u64 = 64 * 1024 * 1024; - let mut table = String::new(); + use std::io::BufReader; + let mut rows = Rows::default(); for path in ["/proc/net/tcp", "/proc/net/tcp6"] { - if let Ok(f) = std::fs::File::open(path) { - let mut part = String::new(); - if f.take(MAX_TABLE).read_to_string(&mut part).is_ok() { - table.push_str(&part); - } + // A missing file (no IPv6) is an absent table; any other failure is a refusal. + match std::fs::File::open(path) { + Ok(f) => scan_table(BufReader::new(f), local, peer, &mut rows)?, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => return Err(unreadable(&e)), + } + if rows.done() { + break; } } // Our uid when our own socket's row is missing: `/proc/self` belongs to this process's user. let me = std::fs::metadata("/proc/self").ok().map(|m| std::os::unix::fs::MetadataExt::uid(&m)); - same_owner(&table, local, peer, me) + decide(&rows, peer, me) } /// Windows, macOS, the BSDs: no same-user check (no socket table to read without OS calls this @@ -61,16 +63,39 @@ fn owner_check(_local: SocketAddr, _peer: SocketAddr) -> Result<(), ZoteroError> Ok(()) } -/// From a `/proc/net/tcp`-format socket table: refuse when the socket at `peer` connected to -/// `local` (the listener's side) belongs to another user than ours. Our uid is our own socket's -/// row, else `me` (this process's). When the listener isn't listed (or the table couldn't be -/// read, or our uid is unknown) its owner is unknown and the loopback rule decides. -#[cfg_attr(not(any(target_os = "linux", target_os = "android")), allow(dead_code))] -pub(crate) fn same_owner(table: &str, local: SocketAddr, peer: SocketAddr, me: Option) -> Result<(), ZoteroError> { +/// The uids found for the two rows of one connection. +#[derive(Default)] +struct Rows { + ours: Option, + theirs: Option, +} + +impl Rows { + fn done(&self) -> bool { + self.ours.is_some() && self.theirs.is_some() + } +} + +fn unreadable(e: &std::io::Error) -> ZoteroError { + ZoteroError::Refused(format!("couldn't read the system's socket table ({e}), so the program on the Zotero port can't be checked")) +} + +/// Stream a `/proc/net/tcp`-format table line by line (nothing is kept, so the table's size +/// doesn't matter and nothing is truncated), recording the uids of the connection's two rows. +/// Stops once both are found. Any read error is a refusal: a table we couldn't finish reading +/// must never count as "the listener isn't listed". +fn scan_table(mut table: impl std::io::BufRead, local: SocketAddr, peer: SocketAddr, rows: &mut Rows) -> Result<(), ZoteroError> { let key = |a: SocketAddr| (a.ip().to_canonical(), a.port()); let (local, peer) = (key(local), key(peer)); - let (mut ours, mut theirs) = (None, None); - for line in table.lines() { + let mut line = Vec::new(); + loop { + line.clear(); + // Bounded line: a row is ~150 bytes; read at most 4 KiB of one (the rest is skipped). + let n = read_line_capped(&mut table, &mut line).map_err(|e| unreadable(&e))?; + if n == 0 { + return Ok(()); + } + let line = String::from_utf8_lossy(&line); let f: Vec<&str> = line.split_whitespace().take(8).collect(); let (Some(l), Some(r), Some(uid)) = (f.get(1).and_then(|a| parse_addr(a)), f.get(2).and_then(|a| parse_addr(a)), f.get(7)) else { continue; @@ -78,26 +103,67 @@ pub(crate) fn same_owner(table: &str, local: SocketAddr, peer: SocketAddr, me: O let Ok(uid) = uid.parse::() else { continue }; let (l, r) = (key(l), key(r)); if l == local && r == peer { - ours = Some(uid); + rows.ours = Some(uid); } else if l == peer && r == local { - theirs = Some(uid); + rows.theirs = Some(uid); + } + if rows.done() { + return Ok(()); } } - match (ours.or(me), theirs) { +} + +/// Read one line into `out`, keeping at most 4 KiB of it; returns the bytes consumed (0 at the end). +fn read_line_capped(r: &mut impl std::io::BufRead, out: &mut Vec) -> std::io::Result { + const CAP: usize = 4096; + let mut total = 0; + loop { + let buf = r.fill_buf()?; + if buf.is_empty() { + return Ok(total); + } + let (take, found) = match buf.iter().position(|b| *b == b'\n') { + Some(i) => (i + 1, true), + None => (buf.len(), false), + }; + let keep = take.min(CAP.saturating_sub(out.len())); + out.extend_from_slice(buf.get(..keep).unwrap_or_default()); + r.consume(take); + total += take; + if found { + return Ok(total); + } + } +} + +/// Refuse when the connection's rows show another user. Our uid is our own socket's row, else +/// `me` (this process's). When the listener isn't listed in a table that was read in full (WSL2, +/// a proxy, a sandbox) or our uid is unknown, its owner is unknown and the loopback rule decides. +fn decide(rows: &Rows, peer: SocketAddr, me: Option) -> Result<(), ZoteroError> { + match (rows.ours.or(me), rows.theirs) { (Some(a), Some(b)) if a == b => Ok(()), (Some(_), Some(b)) => { - Err(ZoteroError::Refused(format!("the program on port {} runs as another user (uid {b}), so it isn't your Zotero", peer.1))) + Err(ZoteroError::Refused(format!("the program on port {} runs as another user (uid {b}), so it isn't your Zotero", peer.port()))) } _ => { log::debug!( "Zotero: couldn't tell which user runs the program on port {} (not in the socket table); trusting the loopback address alone", - peer.1 + peer.port() ); Ok(()) } } } +/// From an in-memory `/proc/net/tcp`-format table (tests and callers with a string): see +/// [`scan_table`] and [`decide`]. +#[cfg_attr(not(test), allow(dead_code))] +pub(crate) fn same_owner(table: &str, local: SocketAddr, peer: SocketAddr, me: Option) -> Result<(), ZoteroError> { + let mut rows = Rows::default(); + scan_table(table.as_bytes(), local, peer, &mut rows)?; + decide(&rows, peer, me) +} + /// `0100007F:5A5C` (IPv4) or 32 hex digits and a port (IPv6): each 32-bit word as the kernel /// prints it, in this machine's byte order. #[cfg_attr(not(any(target_os = "linux", target_os = "android")), allow(dead_code))] @@ -205,6 +271,51 @@ mod tests { assert!(matches!(same_owner(&table, local, peer, None), Err(ZoteroError::Refused(e)) if e.contains("another user"))); } + /// A reader that yields `data`, then fails. + struct Failing(std::io::Cursor>); + impl std::io::Read for Failing { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + match self.0.read(buf)? { + 0 => Err(std::io::Error::other("injected")), + n => Ok(n), + } + } + } + + /// The real attack: a hostile user floods the table with listener rows so the connection's + /// rows sit past any fixed cap. Streaming finds them wherever they are. + #[test] + fn rows_far_into_a_huge_table_are_still_found() { + let (local, peer) = addrs(); + let filler = row("127.0.0.1:1".parse().unwrap(), "0.0.0.0:0".parse().unwrap(), 1001); + let mut table = String::from(HEADER); + // About 70 MiB of rows, past the old 64 MiB cap. + for _ in 0..(70 * 1024 * 1024 / filler.len()) { + table.push_str(&filler); + } + table.push_str(&row(local, peer, 1000)); + table.push_str(&row(peer, local, 1001)); + assert!(table.len() > 64 * 1024 * 1024); + assert!(matches!(same_owner(&table, local, peer, None), Err(ZoteroError::Refused(e)) if e.contains("another user"))); + } + + /// A table that fails mid-read is a refusal, not "listener unknown, trust loopback". + #[test] + fn a_table_that_fails_to_read_is_refused() { + let (local, peer) = addrs(); + let data = format!("{HEADER}{}", row(local, peer, 1000)).into_bytes(); + let mut rows = Rows::default(); + let r = scan_table(std::io::BufReader::new(Failing(std::io::Cursor::new(data))), local, peer, &mut rows); + assert!(matches!(r, Err(ZoteroError::Refused(ref e)) if e.contains("socket table")), "{r:?}"); + } + + #[test] + fn an_overlong_line_is_skipped_without_gluing_to_the_next() { + let (local, peer) = addrs(); + let table = format!("{HEADER}{}\n{}{}", "x".repeat(100_000), row(local, peer, 1000), row(peer, local, 1001)); + assert!(matches!(same_owner(&table, local, peer, None), Err(ZoteroError::Refused(_)))); + } + #[test] fn only_this_computer_is_contacted() { for ok in ["127.0.0.1:23116", "127.9.9.9:1", "[::1]:23116", "[::ffff:127.0.0.1]:23116"] { diff --git a/crates/zotero/src/rich.rs b/crates/zotero/src/rich.rs index 7ff3377f1..705a7e15e 100644 --- a/crates/zotero/src/rich.rs +++ b/crates/zotero/src/rich.rs @@ -42,7 +42,7 @@ fn style_props(base: &CharProps, src: &CharProps) -> CharProps { /// The body paragraphs of `d` (Zotero's RTF or HTML, read), at most [`MAX_PARAS`], formatted on /// top of `base` with the run styling they carry (past [`MAX_RUNS`] runs, text keeps the -/// formatting before it). Objects are dropped. +/// formatting before it; each paragraph still needs its first run, so at most [`MAX_PARAS`] more). Objects are dropped. fn from_doc(d: &Document, base: &CharProps) -> Vec { let mut out = Vec::new(); let mut runs = 0usize; From e216e7afc1bd103080f151b3cd9ff9f12b96c412 Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Mon, 26 Oct 2026 02:36:16 +0000 Subject: [PATCH 2/3] =?UTF-8?q?#3876:=20review=20=E2=80=94=20docs=20say=20?= =?UTF-8?q?an=20unreadable=20table=20refuses;=20EINTR=20is=20retried;=20th?= =?UTF-8?q?e=20huge-table=20test=20drives=20the=20real=20read=20path?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - docs/zotero.md and peer.rs module docs: a read error, or an open error other than NotFound, refuses (sandboxes denying /proc/net/tcp: AppArmor, Snap, Flatpak, are refused). - read_line_capped retries ErrorKind::Interrupted like std's read_line. - New scan_tables (what owner_check calls) takes the table opener; the 70 MiB test drives it, so reinstating take(64 MiB) there fails the test. Added tests for EINTR and a denied open. - Row parsing uses split_whitespace().nth() instead of collecting a Vec. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/zotero/src/peer.rs | 125 +++++++++++++++++++++++++++++--------- docs/zotero.md | 6 +- 2 files changed, 101 insertions(+), 30 deletions(-) diff --git a/crates/zotero/src/peer.rs b/crates/zotero/src/peer.rs index 68e218536..3175487a0 100644 --- a/crates/zotero/src/peer.rs +++ b/crates/zotero/src/peer.rs @@ -4,9 +4,10 @@ //! - **Every system:** WordCraft only connects to this computer (a loopback address). //! - **Linux only:** the program listening there must not run as another user, from the //! kernel's socket table (`/proc/net/tcp`): another account on a shared machine that took the -//! port while Zotero wasn't running is refused. A listener the table doesn't show (WSL2 -//! mirrored networking with Zotero on Windows, a local proxy, a sandbox) leaves only the -//! loopback rule. +//! port while Zotero wasn't running is refused. A listener the tables don't show (WSL2 +//! mirrored networking with Zotero on Windows, a local proxy) leaves only the loopback rule, +//! but only when the tables were read in full: a read error, or an open error other than +//! "not found" (a sandbox that denies `/proc/net/tcp`: AppArmor, Snap, Flatpak), refuses. //! //! On Windows, macOS and the BSDs there is **no same-user check**: the loopback rule is the //! only one, so any local account's listener on the port is taken for Zotero. Those systems have @@ -38,19 +39,7 @@ pub(crate) fn check(stream: &TcpStream) -> Result<(), ZoteroError> { /// A table that can't be read in full (I/O error) is a refusal, never "unknown". #[cfg(any(target_os = "linux", target_os = "android"))] fn owner_check(local: SocketAddr, peer: SocketAddr) -> Result<(), ZoteroError> { - use std::io::BufReader; - let mut rows = Rows::default(); - for path in ["/proc/net/tcp", "/proc/net/tcp6"] { - // A missing file (no IPv6) is an absent table; any other failure is a refusal. - match std::fs::File::open(path) { - Ok(f) => scan_table(BufReader::new(f), local, peer, &mut rows)?, - Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} - Err(e) => return Err(unreadable(&e)), - } - if rows.done() { - break; - } - } + let rows = scan_tables(["/proc/net/tcp", "/proc/net/tcp6"], |p| std::fs::File::open(p), local, peer)?; // Our uid when our own socket's row is missing: `/proc/self` belongs to this process's user. let me = std::fs::metadata("/proc/self").ok().map(|m| std::os::unix::fs::MetadataExt::uid(&m)); decide(&rows, peer, me) @@ -63,6 +52,28 @@ fn owner_check(_local: SocketAddr, _peer: SocketAddr) -> Result<(), ZoteroError> Ok(()) } +/// Scan the tables named by `paths` (opened by `open`) in order, stopping once both rows are +/// found. A table that doesn't exist (no IPv6) is skipped; any other open or read error refuses. +fn scan_tables( + paths: [&str; 2], + open: impl Fn(&str) -> std::io::Result, + local: SocketAddr, + peer: SocketAddr, +) -> Result { + let mut rows = Rows::default(); + for path in paths { + match open(path) { + Ok(f) => scan_table(std::io::BufReader::new(f), local, peer, &mut rows)?, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => return Err(unreadable(&e)), + } + if rows.done() { + break; + } + } + Ok(rows) +} + /// The uids found for the two rows of one connection. #[derive(Default)] struct Rows { @@ -96,8 +107,8 @@ fn scan_table(mut table: impl std::io::BufRead, local: SocketAddr, peer: SocketA return Ok(()); } let line = String::from_utf8_lossy(&line); - let f: Vec<&str> = line.split_whitespace().take(8).collect(); - let (Some(l), Some(r), Some(uid)) = (f.get(1).and_then(|a| parse_addr(a)), f.get(2).and_then(|a| parse_addr(a)), f.get(7)) else { + let mut f = line.split_whitespace(); + let (Some(l), Some(r), Some(uid)) = (f.nth(1).and_then(parse_addr), f.next().and_then(parse_addr), f.nth(4)) else { continue; }; let Ok(uid) = uid.parse::() else { continue }; @@ -118,7 +129,11 @@ fn read_line_capped(r: &mut impl std::io::BufRead, out: &mut Vec) -> std::io const CAP: usize = 4096; let mut total = 0; loop { - let buf = r.fill_buf()?; + let buf = match r.fill_buf() { + Ok(b) => b, + Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue, + Err(e) => return Err(e), + }; if buf.is_empty() { return Ok(total); } @@ -282,21 +297,75 @@ mod tests { } } + /// Repeats `unit` for `left` bytes, then ends: a huge table without holding it in memory. + struct Table { + parts: Vec<(Vec, usize)>, + part: usize, + at: usize, + } + impl std::io::Read for Table { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + let Some((unit, times)) = self.parts.get(self.part) else { return Ok(0) }; + if self.at >= unit.len() * times { + self.part += 1; + self.at = 0; + return self.read(buf); + } + let n = buf.len().min(unit.len() - self.at % unit.len()); + let off = self.at % unit.len(); + buf[..n].copy_from_slice(&unit[off..off + n]); + self.at += n; + Ok(n) + } + } + /// The real attack: a hostile user floods the table with listener rows so the connection's - /// rows sit past any fixed cap. Streaming finds them wherever they are. + /// rows sit past any fixed cap. The real read path (`scan_tables`, which `owner_check` + /// calls on `/proc/net/tcp*`) finds them wherever they are. #[test] fn rows_far_into_a_huge_table_are_still_found() { let (local, peer) = addrs(); let filler = row("127.0.0.1:1".parse().unwrap(), "0.0.0.0:0".parse().unwrap(), 1001); - let mut table = String::from(HEADER); - // About 70 MiB of rows, past the old 64 MiB cap. - for _ in 0..(70 * 1024 * 1024 / filler.len()) { - table.push_str(&filler); + // More than 64 MiB of rows (the old cap), then the connection's two rows. + let times = 70 * 1024 * 1024 / filler.len(); + assert!(filler.len() * times > 64 * 1024 * 1024); + let tail = format!("{}{}", row(local, peer, 1000), row(peer, local, 1001)).into_bytes(); + let open = |p: &str| match p { + "t4" => { + Ok(Table { parts: vec![(HEADER.as_bytes().to_vec(), 1), (filler.clone().into_bytes(), times), (tail.clone(), 1)], part: 0, at: 0 }) + } + _ => Err(std::io::Error::from(std::io::ErrorKind::NotFound)), + }; + let rows = scan_tables(["t4", "t6"], open, local, peer).unwrap(); + assert_eq!((rows.ours, rows.theirs), (Some(1000), Some(1001))); + assert!(matches!(decide(&rows, peer, None), Err(ZoteroError::Refused(e)) if e.contains("another user"))); + } + + /// A signal interrupting a read is retried, not taken for an unreadable table. + #[test] + fn an_interrupted_read_is_retried() { + struct Eintr(bool, std::io::Cursor>); + impl std::io::Read for Eintr { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + if !std::mem::replace(&mut self.0, true) { + return Err(std::io::ErrorKind::Interrupted.into()); + } + self.1.read(buf) + } } - table.push_str(&row(local, peer, 1000)); - table.push_str(&row(peer, local, 1001)); - assert!(table.len() > 64 * 1024 * 1024); - assert!(matches!(same_owner(&table, local, peer, None), Err(ZoteroError::Refused(e)) if e.contains("another user"))); + let (local, peer) = addrs(); + let data = format!("{HEADER}{}{}", row(local, peer, 7), row(peer, local, 7)).into_bytes(); + let open = |_: &str| Ok(Eintr(false, std::io::Cursor::new(data.clone()))); + let rows = scan_tables(["a", "b"], open, local, peer).unwrap(); + assert_eq!((rows.ours, rows.theirs), (Some(7), Some(7))); + } + + /// A sandbox that denies the table (not "not found") refuses. + #[test] + fn a_table_that_cannot_be_opened_is_refused() { + let (local, peer) = addrs(); + let open = |_: &str| -> std::io::Result<&[u8]> { Err(std::io::ErrorKind::PermissionDenied.into()) }; + assert!(matches!(scan_tables(["a", "b"], open, local, peer), Err(ZoteroError::Refused(_)))); } /// A table that fails mid-read is a refusal, not "listener unknown, trust loopback". diff --git a/docs/zotero.md b/docs/zotero.md index cf5813494..9a0eb14fb 100644 --- a/docs/zotero.md +++ b/docs/zotero.md @@ -74,8 +74,10 @@ isn't another user's. On Windows and macOS (and the BSDs) the loopback rule is t program listening on the port runs as the same user as WordCraft, before sending anything. If another account on a shared machine took the port while Zotero wasn't running, WordCraft refuses with an error and the document isn't touched. When the table doesn't show the - listener (WSL2 mirrored networking with Zotero running on Windows, a local proxy, a sandbox) - or can't be read, its owner is unknown and only the loopback rule applies, as below. + listener (WSL2 mirrored networking with Zotero running on Windows, a local proxy), its owner + is unknown and only the loopback rule applies, as below. That holds only when the tables were + read in full: a read error, or an open error other than "not found", **refuses**. Sandboxes + that deny `/proc/net/tcp` (AppArmor, Snap, Flatpak confinement) are therefore refused. - Windows, macOS and the BSDs: **no same-user check.** They have no such table to read without OS calls WordCraft doesn't make (it uses no `unsafe` code), so only the loopback rule applies there (and on Linux when the listener isn't in the table). Remaining risk: on a From 8bc825825f7fc9c2da839d8b3c3f8e7b40e8bf77 Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Mon, 26 Oct 2026 02:50:03 +0000 Subject: [PATCH 3/3] =?UTF-8?q?#3876:=20review=20=E2=80=94=20scan=5Ftables?= =?UTF-8?q?=20allowed=20unused=20off=20Linux;=20sandbox=20and=20empty-tabl?= =?UTF-8?q?e=20comments=20match=20the=20refusal=20rule?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01HUodYU9r4mPHed4pPsjHC2 --- crates/zotero/src/peer.rs | 9 +++++---- docs/zotero.md | 2 +- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/crates/zotero/src/peer.rs b/crates/zotero/src/peer.rs index 3175487a0..09abd8de4 100644 --- a/crates/zotero/src/peer.rs +++ b/crates/zotero/src/peer.rs @@ -7,7 +7,7 @@ //! port while Zotero wasn't running is refused. A listener the tables don't show (WSL2 //! mirrored networking with Zotero on Windows, a local proxy) leaves only the loopback rule, //! but only when the tables were read in full: a read error, or an open error other than -//! "not found" (a sandbox that denies `/proc/net/tcp`: AppArmor, Snap, Flatpak), refuses. +//! "not found" (e.g. a sandbox profile that denies `/proc/net/tcp`), refuses. //! //! On Windows, macOS and the BSDs there is **no same-user check**: the loopback rule is the //! only one, so any local account's listener on the port is taken for Zotero. Those systems have @@ -54,6 +54,7 @@ fn owner_check(_local: SocketAddr, _peer: SocketAddr) -> Result<(), ZoteroError> /// Scan the tables named by `paths` (opened by `open`) in order, stopping once both rows are /// found. A table that doesn't exist (no IPv6) is skipped; any other open or read error refuses. +#[cfg_attr(not(any(target_os = "linux", target_os = "android")), allow(dead_code))] fn scan_tables( paths: [&str; 2], open: impl Fn(&str) -> std::io::Result, @@ -153,7 +154,7 @@ fn read_line_capped(r: &mut impl std::io::BufRead, out: &mut Vec) -> std::io /// Refuse when the connection's rows show another user. Our uid is our own socket's row, else /// `me` (this process's). When the listener isn't listed in a table that was read in full (WSL2, -/// a proxy, a sandbox) or our uid is unknown, its owner is unknown and the loopback rule decides. +/// a proxy) or our uid is unknown, its owner is unknown and the loopback rule decides. fn decide(rows: &Rows, peer: SocketAddr, me: Option) -> Result<(), ZoteroError> { match (rows.ours.or(me), rows.theirs) { (Some(a), Some(b)) if a == b => Ok(()), @@ -248,14 +249,14 @@ mod tests { assert_eq!(same_owner(&junk, local, peer, None), Ok(())); } - /// WSL2 mirrored networking (Zotero on Windows), a local proxy or a sandbox: the listener + /// WSL2 mirrored networking (Zotero on Windows) or a local proxy: the listener /// isn't in our table, so the loopback rule decides, as on other systems. #[test] fn an_unlisted_listener_falls_back_to_the_loopback_rule() { let (local, peer) = addrs(); let missing = format!("{HEADER}{}", row(local, peer, 1000)); assert_eq!(same_owner(&missing, local, peer, None), Ok(())); - // The table couldn't be read. + // An empty table: the listener isn't listed. assert_eq!(same_owner("", local, peer, None), Ok(())); assert_eq!(same_owner("", local, peer, Some(1000)), Ok(())); } diff --git a/docs/zotero.md b/docs/zotero.md index 9a0eb14fb..4e96520ad 100644 --- a/docs/zotero.md +++ b/docs/zotero.md @@ -77,7 +77,7 @@ isn't another user's. On Windows and macOS (and the BSDs) the loopback rule is t listener (WSL2 mirrored networking with Zotero running on Windows, a local proxy), its owner is unknown and only the loopback rule applies, as below. That holds only when the tables were read in full: a read error, or an open error other than "not found", **refuses**. Sandboxes - that deny `/proc/net/tcp` (AppArmor, Snap, Flatpak confinement) are therefore refused. + that deny `/proc/net/tcp` (some AppArmor or Snap profiles, for example) are therefore refused. - Windows, macOS and the BSDs: **no same-user check.** They have no such table to read without OS calls WordCraft doesn't make (it uses no `unsafe` code), so only the loopback rule applies there (and on Linux when the listener isn't in the table). Remaining risk: on a