Files
figcraft/crates/collab-net/src/requests.rs
T
Dylan M. Taylor 0f08269067 fix(collab-net): never send the server key over plain ws:// to another machine (#1923)
* fix(collab-net): never send the server key over plain ws:// to another machine

Publishing a plugin, uploading or sharing with the server's key accepted a
ws:// address for a remote host, so the reusable administrative key went
out in clear text. The key now goes only over wss://, or over ws:// to
this machine (loopback or localhost); browsing without a key is unchanged.

* address review: connect only to loopback addresses for a plain ws://localhost endpoint
2026-11-11 08:36:29 -05:00

175 lines
8.2 KiB
Rust

//! What the app asks a server outside a session, and what the answers mean, free of the
//! transport: the native one (`manage.rs`, `guest.rs`) and the browser's (`exchange.rs`) send the
//! same requests and read the answers the same way.
use std::sync::Arc;
use figcraft_collab::Replica;
use figcraft_collab::message::{Access, Admin, AdminReply, NewLink};
use figcraft_doc::Document;
use figcraft_engine::live::{Published, Shared, SharedLink};
use crate::invite::{Endpoint, Invite};
/// What publishing uploads, and where.
pub struct Publish {
pub server: Endpoint,
/// The server's key (`None`: publish as anyone may, on a server open to the public).
pub key: Option<String>,
/// The file's name on the server.
pub name: String,
pub doc: Arc<Document>,
/// Share the file once it is there: links and join codes for others
/// ([`Event::Shared`](figcraft_engine::live::Event::Shared)).
pub share: bool,
}
/// How an error starts when the server said no (then its reason), rather than not answering.
pub(crate) const REFUSED: &str = "the server refused: ";
/// The server's key: the one given, else the address's `?key=…`; a blank one is none.
pub(crate) fn server_key(given: Option<String>, query: Vec<(String, String)>) -> Option<String> {
given.or_else(|| query.into_iter().find(|(k, _)| k == "key").map(|(_, v)| v)).filter(|k| !k.trim().is_empty())
}
/// Refuse to send the server's key (its whole administrative authority, reusable) in the clear:
/// `ws://` is only for a server on this machine; anywhere else needs `wss://`.
pub(crate) fn key_over_tls(server: &Endpoint, key: Option<&str>) -> Result<(), String> {
if key.is_some_and(|k| !k.trim().is_empty()) && !server.secure && !server.loopback() {
return Err("the server's key is only sent over wss:// (or ws:// to this machine): it would be readable on the network".into());
}
Ok(())
}
/// Why asking `server` about its hosting failed with `e`: a server older than open hosting can't
/// read the question.
pub(crate) fn hosting_error(server: &Endpoint, e: String) -> String {
if e.contains("couldn't be read") {
format!("{} takes files only with its key (it is older than this app): add ?key=… to the address", server.display())
} else {
e
}
}
/// The request that keeps `doc` on a server as a new file named `name`.
pub(crate) fn upload(name: &str, doc: &Document) -> Result<Admin, String> {
let snapshot = Replica::new(doc.clone()).snapshot().map_err(|e| e.to_string())?;
Ok(Admin::Publish { name: name.to_string(), snapshot })
}
/// What `server`'s answer to [`upload`] means: the edit link to join the file with, and where it
/// is with its links (and its owner token, published without the key).
pub(crate) fn uploaded(server: &Endpoint, reply: Option<AdminReply>) -> Result<(Invite, Published), String> {
let (file, edit, view, owner, expires) = match reply {
Some(AdminReply::Published { file, edit, view }) => (file, edit, view, None, None),
Some(AdminReply::Hosted { file, edit, view, owner, expires }) => (file, edit, view, Some(owner), expires),
Some(AdminReply::Failed { reason }) => return Err(reason),
_ => return Err("the server didn't say where the file is".into()),
};
let link = |token: String| Invite { server: server.clone(), file: file.id.clone(), token };
let (edit, view) = (link(edit.token), link(view.token));
let published = Published { server: server.display(), file: file.id, name: file.name, edit: edit.link(), view: view.link(), owner, expires };
Ok((edit, published))
}
/// The links made for others: an edit and a view link, and a comment link from a server that
/// makes them.
pub(crate) struct Links {
pub edit: NewLink,
pub view: NewLink,
pub comment: Option<NewLink>,
}
/// The request for a new link of `access` to `file`, for others.
fn link_request(file: &str, access: Access) -> Admin {
Admin::CreateLink { file: file.to_string(), access, label: format!("Shared ({})", access.id()) }
}
/// The requests that revoke `file`'s links with the ids in `revoke`, then, to `create`, make a new
/// edit link and a new view link.
pub(crate) fn link_requests(file: &str, revoke: &[String], create: bool) -> Vec<Admin> {
let mut requests: Vec<Admin> = revoke.iter().map(|id| Admin::Revoke { file: file.to_string(), link: id.clone() }).collect();
requests.extend(create.then(|| [link_request(file, Access::Edit), link_request(file, Access::View)]).into_iter().flatten());
requests
}
/// The request for a comment link to `file`, asked on a connection of its own once
/// [`made_links`] gave the others: a server from before comment links can't read it and closes
/// the connection (the file is shared without one then).
pub(crate) fn comment_request(file: &str) -> Vec<Admin> {
vec![link_request(file, Access::Comment)]
}
/// The comment link the answers to [`comment_request`] made, if any.
pub(crate) fn made_comment(replies: Vec<AdminReply>) -> Option<NewLink> {
replies.into_iter().find_map(|r| if let AdminReply::Link(l) = r { Some(l) } else { None })
}
/// The edit and view links the answers to [`link_requests`] made (`None`: none were asked for),
/// or why the server didn't do it; the comment link comes after ([`made_comment`]).
pub(crate) fn made_links(replies: Vec<AdminReply>, create: bool) -> Result<Option<Links>, String> {
let mut made = Vec::new();
for reply in replies {
match reply {
AdminReply::Link(l) => made.push(l),
// A link that is gone already is as revoked as it gets.
AdminReply::Failed { reason } if made.is_empty() && reason.contains("no such link") => {}
AdminReply::Failed { reason } => return Err(reason),
_ => {}
}
}
match (made.pop(), made.pop()) {
(Some(view), Some(edit)) => Ok(Some(Links { edit, view, comment: None })),
_ if create => Err("the server didn't make the links".into()),
_ => Ok(None),
}
}
/// The requests for the join codes of the links [`made_links`] gave (asked on a connection of
/// their own: a server from before join codes can't read them and closes it).
pub(crate) fn code_requests(file: &str, links: &Links) -> Vec<Admin> {
[&links.edit, &links.view].into_iter().chain(&links.comment).map(|l| Admin::Code { file: file.to_string(), token: l.token.clone() }).collect()
}
/// What others join `file` on `server` with: the links, with the codes `codes` (the answers to
/// [`code_requests`]; none from a server older than join codes).
pub(crate) fn shared(server: &Endpoint, file: &str, Links { edit, view, comment }: Links, codes: Vec<AdminReply>) -> Shared {
let mut codes = codes.into_iter().map(|r| if let AdminReply::Code { code } = r { Some(code) } else { None });
let mut link = |l: NewLink| SharedLink {
link: Invite { server: server.clone(), file: file.to_string(), token: l.token }.link(),
id: l.link.id,
code: codes.next().flatten(),
};
let (edit, view) = (link(edit), Some(link(view)));
Shared { edit, view, comment: comment.map(link) }
}
/// A hosting answer as the app shows it: a new link whole, in place of its token.
pub(crate) fn hosted(server: &Endpoint, reply: Option<AdminReply>) -> Option<AdminReply> {
reply.map(|r| match r {
AdminReply::Link(mut l) => {
l.token = Invite { server: server.clone(), file: l.link.file.clone(), token: l.token }.link();
AdminReply::Link(l)
}
other => other,
})
}
#[cfg(test)]
mod key_tests {
use super::*;
#[test]
fn the_key_never_goes_over_plain_tcp_to_another_machine() {
let at = |url: &str| Endpoint::parse(url).unwrap().0;
assert!(key_over_tls(&at("ws://share.example:9000/"), Some("k")).is_err());
assert!(key_over_tls(&at("ws://10.0.0.5:9000/"), Some("k")).is_err());
assert!(key_over_tls(&at("wss://share.example/"), Some("k")).is_ok());
assert!(key_over_tls(&at("ws://127.0.0.1:9000/"), Some("k")).is_ok());
assert!(key_over_tls(&at("ws://localhost:9000/"), Some("k")).is_ok());
// Browsing or publishing as anyone sends no key, so plain is fine.
assert!(key_over_tls(&at("ws://share.example:9000/"), None).is_ok());
assert!(key_over_tls(&at("ws://share.example:9000/"), Some(" ")).is_ok());
}
}