#3692: Zotero owner check refuses only another user's listener; unlisted falls back to loopback

Ultrareview finding 3: on Linux, when /proc/net/tcp(6) doesn't list the
listener on Zotero's port (WSL2 mirrored networking with Zotero on
Windows, a local proxy, a sandbox), every connection was refused, so
Zotero couldn't be used at all there. Per the owner's decision, refuse
only when both sockets are found and the listener's uid differs from
ours; otherwise log at debug and apply the plain loopback rule, as on
macOS/Windows/BSD. Test: an_unlisted_listener_falls_back_to_the_loopback_rule.
docs/zotero.md and the progress-log row say so. Non-Linux owner check
stays with #3831.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Martin Ball
2026-10-25 23:49:08 +00:00
co-authored by Claude Opus 5.5
parent a9cc06dc0f
commit 7ee101a5b9
2 changed files with 29 additions and 16 deletions
+21 -10
View File
@@ -3,8 +3,9 @@
//! only connects to this computer (a loopback address), and on Linux checks that the program
//! listening there runs as the same 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. Other systems have no such table to read without OS calls this crate doesn't make;
//! there the loopback rule is the guard (see `docs/zotero.md`).
//! 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, as on other systems, which have no
//! such table to read without OS calls this crate doesn't make (see `docs/zotero.md`).
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr, TcpStream};
@@ -50,9 +51,9 @@ fn owner_check(_local: SocketAddr, _peer: SocketAddr) -> Result<(), ZoteroError>
Ok(())
}
/// From a `/proc/net/tcp`-format socket table: whether the socket at `peer` connected to
/// `local` (the listener's side) belongs to the same user as ours. A peer that can't be found
/// is refused too: its owner can't be established.
/// 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. When either socket isn't
/// listed (or the table couldn't be read) the 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) -> Result<(), ZoteroError> {
let key = |a: SocketAddr| (a.ip().to_canonical(), a.port());
@@ -76,7 +77,10 @@ pub(crate) fn same_owner(table: &str, local: SocketAddr, peer: SocketAddr) -> Re
(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!("WordCraft couldn't tell which user's program is on port {}", peer.1))),
_ => {
log::debug!("Zotero: port {} isn't in this system's socket table; trusting the loopback address alone", peer.1);
Ok(())
}
}
}
@@ -142,15 +146,22 @@ mod tests {
assert_eq!(same_owner(&same, local, peer), Ok(()));
let other = format!("{HEADER}{}{}", row(local, peer, 1000), row(peer, local, 1001));
assert!(matches!(same_owner(&other, local, peer), Err(ZoteroError::Refused(e)) if e.contains("another user")));
// Not found (or the table couldn't be read): the owner can't be established.
let missing = format!("{HEADER}{}", row(local, peer, 1000));
assert!(matches!(same_owner(&missing, local, peer), Err(ZoteroError::Refused(_))));
assert!(matches!(same_owner("", local, peer), Err(ZoteroError::Refused(_))));
// Junk lines are skipped.
let junk = format!("{HEADER}garbage\n 1: zz:zz yy:yy 01 x x x x\n{}{}", row(local, peer, 7), row(peer, local, 7));
assert_eq!(same_owner(&junk, local, peer), Ok(()));
}
/// WSL2 mirrored networking (Zotero on Windows), a local proxy or a sandbox: 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), Ok(()));
// The table couldn't be read.
assert_eq!(same_owner("", local, peer), Ok(()));
}
#[test]
fn a_dual_stack_listener_is_matched_through_its_mapped_address() {
let (local, peer) = addrs();
+8 -6
View File
@@ -70,13 +70,15 @@ program can read the citations and edit the open document. WordCraft limits who
is refused before connecting.
- On Linux it checks, in the kernel's socket table (`/proc/net/tcp`, `/proc/net/tcp6`), that the
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, or the owner
can't be established (the table can't be read), WordCraft refuses with an error and the
document isn't touched.
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.
- Remaining risk: macOS, Windows and the BSDs have no such table to read without OS calls
WordCraft doesn't make (it uses no `unsafe` code), so there only the loopback rule applies:
on a computer shared with other accounts, a program another user leaves on port 23116 while
Zotero is closed would be taken for Zotero. A program running as you is trusted on every
WordCraft doesn't make (it uses no `unsafe` code), so there only the loopback rule applies
(and on Linux when the listener isn't in the table): on a computer shared with other
accounts, a program another user leaves on port 23116 while Zotero is closed would be taken
for Zotero. A program running as you is trusted on every
system; it could change your documents anyway.
- `wordcraft-cli zotero` prints Zotero's messages (and `--trace` lines) with control
characters, line breaks and backslashes shown as escapes (`\n`, `\u{1b}`, `\\`), so a peer