Files
punktfunk/crates/pf-clipboard/src/host.rs
T
enricobuehler 8103958169 fix(security): the plugin lane stops being a way in
Acts on the 2026-08-05 host security review. 36 of its 38 findings; the two
exceptions are recorded below and in the review doc.

The review's headline is that `plugin_may_access` was the one authorization
gate in the system that was allow-by-default — a hand-maintained denylist of
route prefixes, where every sibling gate is deny-by-default. Its own doc
comment names the two capabilities it exists to withhold, and both were
reachable one route over, because ~1450 commits of new routes were added and
the list was never one of the things anyone remembered to update.

So the gate is now an allowlist, and a test walks the live route table and
fails the build for any route that has not been deliberately classified for
both non-admin lanes. That test is the actual fix: it is what stops the next
route from arriving pre-authorized.

Route reachability and field authority turned out to be different questions.
A provider plugin has to be able to reconcile its own library entries — that
is what a scanner plugin IS — but `prep` and a `command` launch inside that
payload are handed to `/bin/sh -c` as the host user, and every execution site
documents them as operator-typed. Requests now carry the lane that authorized
them, and those two fields are refused to everyone but the operator's own
token.

The art proxy read any absolute path off disk in the host process, which on
Windows is LocalSystem, from a path the plugin lane could write and then read
back — so it yielded `mgmt-token`, which is full admin. It now serves only
real images (extension AND magic bytes, so a renamed secret fails), only from
inside an allowed root, only after canonicalization, and never over UNC; and
a path it would refuse to serve can no longer be persisted in the first place.

On Windows, the config-dir hardening was skipped exactly when it was needed —
it ran only in the branch that CREATES host.env, so the case it was written
for (a local user pre-created the directory and planted one) was the one case
it never ran in. It is now unconditional and first, an existing host.env is
re-owned, and the inheritable OWNER RIGHTS ACE that kept an attacker's files
theirs after the directory was re-owned is gone. The identity and token
readers were hardening the directory only on the path that GENERATED a new
secret, so a planted cert/key or token was adopted verbatim and permanently;
they harden before the first read now.

`ensure_admin_only_source` is implemented. The 2026-07-05 audit recorded it as
FIXED and it was in no commit in this repository's history — the local EoP it
described was live, and it is the payload half of the config-dir chain above.

Also: the three input planes are bounded and lossy like the mic plane on the
same loop already was; Android's library client no longer accepts any
publicly-trusted certificate for the pinned host; the usbip vhci nodes get
their own group instead of riding on `input`, which every packaging scriptlet
tells users to join; a registry URL can no longer inject a TOML table into
bunfig.toml; the pairing cooldown is charged before the arming state is read,
so armed/disarmed is no longer a free oracle; and the whole Low tier, of which
the two worth naming are a clipboard MIME NUL that panicked the host on one
control message, and an unauthenticated global logout that let any LAN peer
sign the operator out on a loop.

NOT fixed, deliberately:

  H-3 (plugin UIs framed allow-same-origin). Dropping allow-same-origin does
  not work: the document's origin goes opaque, its subresource requests are
  then cross-site, the SameSite=Lax session cookie is not sent, and every
  plugin asset 302s to /login. The "open in new tab" link is the same
  escalation with no iframe at all, so the sandbox attribute is not where this
  gets fixed either. It needs a second listener — a distinct origin that is
  still the same site — which changes the console's deploy model and wants
  on-glass validation. The mechanism and the dead end are written down at the
  iframe.

  H-6 registry authentication, whose other half lives in unom/infra. The
  in-repo halves are done: workflow_dispatch inputs no longer interpolate into
  run: blocks (one of them in the step holding UPDATE_MANIFEST_KEY), and the
  syft installer is pinned to its tag instead of main. Digest pinning is left
  until the registry is authenticated, because a tag — content-keyed or not —
  can simply be overwritten while anonymous pushes are accepted.

M-5 is half done: the oracle is closed, but binding the arming window needs
the console to learn the fingerprint first, which is a knock-then-bind flow
rather than an edit.

Verified: cargo fmt --all --check clean; cargo check --all-targets green on
Linux and on Windows (confirmed non-vacuous — a planted type error in
windows/install.rs fails the build); scripts/xcheck.sh windows check green;
cargo test -p punktfunk-host --bins 416 passed, the single failure being
gamestream::stream::tests::sender_delivers_batches, the known qemu-environmental
UDP-loopback flake that fails identically on clean main in the same container;
cargo test -p pf-clipboard 13 passed; web console typechecks.
2026-08-05 17:12:12 +02:00

485 lines
20 KiB
Rust

//! Host-side shared-clipboard backend.
//!
//! The wire protocol and the client half live in `punktfunk-core`
//! (`punktfunk_core::quic` + `punktfunk_core::clipboard`); this module drives the **host's** real
//! session clipboard so it can offer what a host app copied and paste what the remote client
//! offered (`design/clipboard-and-file-transfer.md` §4).
//!
//! Concrete backends, selected at session start ([`HostClipboard::open`]) and presented as one
//! [`HostClipboard`] to the [`session`] coordinator:
//! * [`wayland`] (Linux) — `ext-data-control-v1` (KWin, wlroots / Sway, Hyprland). Preferred when present.
//! * [`mutter`] (Linux) — GNOME. Mutter implements **no** wlr/ext data-control, but its *direct*
//! `org.gnome.Mutter.RemoteDesktop.Session` D-Bus API carries the same clipboard operations (the
//! xdg `org.freedesktop.portal.Clipboard` would need an interactive grant a headless host can't
//! answer — so we skip it and talk to Mutter directly, as the input injector already does).
//! * [`windows`] — the Win32 clipboard: a hidden message-only window watches `WM_CLIPBOARDUPDATE`
//! and serves client content via OLE delayed rendering (`WM_RENDERFORMAT`).
//!
//! The `zwlr-data-control-unstable-v1` fallback (older wlroots/KWin) is a follow-up. The module
//! compiles on Linux and Windows; the [`session`] coordinator is backend-agnostic.
#[cfg(target_os = "linux")]
mod mutter;
#[cfg(target_os = "linux")]
mod wayland;
#[cfg(target_os = "windows")]
mod windows;
/// Pure Win32-clipboard ↔ wire byte conversions (CF_HTML offset math, UTF-16 text, RTF NUL
/// trimming). Free of any Win32 dependency, so it compiles — and its unit tests run — on any host
/// (`cfg(test)`); the Windows backend is the only production consumer.
#[cfg(any(target_os = "windows", test))]
mod winfmt;
pub mod session;
#[cfg(target_os = "linux")]
use std::io::Write as _;
#[cfg(target_os = "linux")]
use std::os::fd::OwnedFd;
use std::sync::Arc;
/// A clipboard event surfaced by a host backend to the [`session`] coordinator. Both the
/// data-control and Mutter backends emit this identical shape.
pub enum ClipEvent {
/// The host selection changed (a host app copied). `mimes` are the **wire** MIMEs offered (empty
/// = the clipboard was cleared). The coordinator forwards these as a `ClipOffer` to the client;
/// bytes cross only if the client later fetches.
Selection { mimes: Vec<String> },
/// A host app is pasting content the client offered. The coordinator fetches the wire-`mime`
/// bytes from the client and hands them to `responder`.
Paste {
mime: String,
responder: PasteResponder,
},
/// The backend ended (compositor / session gone).
Closed,
}
/// How a backend receives the bytes answering a [`ClipEvent::Paste`]. The two host clipboard
/// mechanisms complete a paste differently, so the coordinator stays agnostic by handing bytes to
/// whichever responder the backend attached.
pub enum PasteResponder {
/// data-control: the compositor handed us the destination pipe on the `send` event — write the
/// bytes and close it (EOF completes the paste).
#[cfg(target_os = "linux")]
Fd(OwnedFd),
/// Mutter: hand the bytes back to the backend actor, which owns the `SelectionWrite` fd and the
/// trailing `SelectionWriteDone` call that Mutter's transfer requires.
#[cfg(target_os = "linux")]
Channel(tokio::sync::oneshot::Sender<Vec<u8>>),
/// Windows: hand the bytes to the `WM_RENDERFORMAT` handler blocking the clipboard message-loop
/// thread, which then `SetClipboardData`s them for the pasting app (`std::sync::mpsc`, since that
/// thread waits synchronously — see [`windows`]).
#[cfg(target_os = "windows")]
Sync(std::sync::mpsc::Sender<Vec<u8>>),
}
impl PasteResponder {
/// Deliver the fetched bytes (empty on a failed fetch → an empty paste, never a hang).
pub async fn respond(self, bytes: Vec<u8>) {
match self {
#[cfg(target_os = "linux")]
PasteResponder::Fd(fd) => {
let _ = tokio::task::spawn_blocking(move || fulfill_paste(fd, &bytes)).await;
}
#[cfg(target_os = "linux")]
PasteResponder::Channel(tx) => {
let _ = tx.send(bytes);
}
#[cfg(target_os = "windows")]
PasteResponder::Sync(tx) => {
let _ = tx.send(bytes);
}
}
}
}
/// Write `bytes` into a paste pipe `fd` and close it (EOF signals the reader). Blocking — run off the
/// reactor for large payloads.
#[cfg(target_os = "linux")]
fn fulfill_paste(fd: OwnedFd, bytes: &[u8]) -> std::io::Result<()> {
let mut file = std::fs::File::from(fd);
file.write_all(bytes)?;
Ok(())
}
/// The active host clipboard backend, chosen per session: `ext-data-control`
/// (KWin/wlroots/Hyprland/Sway) or Mutter's direct RemoteDesktop clipboard (GNOME) on Linux, or the
/// Win32 clipboard on Windows. Presented as one type so the [`session`] coordinator is
/// backend-agnostic.
pub enum HostClipboard {
#[cfg(target_os = "linux")]
DataControl(wayland::ClipboardBackend),
#[cfg(target_os = "linux")]
Mutter(mutter::MutterClipboard),
#[cfg(target_os = "windows")]
Windows(windows::WindowsClipboard),
}
impl HostClipboard {
/// Open whichever backend this session supports. Linux tries data-control first
/// (KWin/wlroots/Hyprland/Sway) then Mutter's direct clipboard (GNOME); Windows opens the Win32
/// clipboard. Errors when none is available (gamescope, no live compositor) — the caller then
/// reports `BACKEND_UNAVAILABLE`.
pub async fn open() -> anyhow::Result<(
HostClipboard,
tokio::sync::mpsc::UnboundedReceiver<ClipEvent>,
)> {
#[cfg(target_os = "linux")]
{
// data-control's bind does blocking Wayland roundtrips — keep them off the reactor.
let dc = tokio::task::spawn_blocking(wayland::ClipboardBackend::open)
.await
.map_err(|e| anyhow::anyhow!("data-control open join: {e}"))?;
match dc {
Ok((b, rx)) => return Ok((HostClipboard::DataControl(b), rx)),
Err(e) => tracing::debug!(
error = format!("{e:#}"),
"no ext-data-control — trying Mutter direct clipboard"
),
}
let (m, rx) = mutter::MutterClipboard::open().await.map_err(|e| {
e.context("no clipboard backend (neither ext-data-control nor Mutter)")
})?;
Ok((HostClipboard::Mutter(m), rx))
}
#[cfg(target_os = "windows")]
{
let (b, rx) = windows::WindowsClipboard::open().await?;
Ok((HostClipboard::Windows(b), rx))
}
}
/// The current host selection's wire MIMEs (empty = nothing to offer).
pub fn current_wire_mimes(&self) -> Vec<String> {
match self {
#[cfg(target_os = "linux")]
HostClipboard::DataControl(b) => b.current_wire_mimes(),
#[cfg(target_os = "linux")]
HostClipboard::Mutter(m) => m.current_wire_mimes(),
#[cfg(target_os = "windows")]
HostClipboard::Windows(w) => w.current_wire_mimes(),
}
}
/// Install a client's offered formats as the host selection.
pub fn set_offer(&self, wire_mimes: &[String]) -> anyhow::Result<()> {
match self {
#[cfg(target_os = "linux")]
HostClipboard::DataControl(b) => b.set_offer(wire_mimes),
#[cfg(target_os = "linux")]
HostClipboard::Mutter(m) => {
m.set_offer(wire_mimes);
Ok(())
}
#[cfg(target_os = "windows")]
HostClipboard::Windows(w) => {
w.set_offer(wire_mimes);
Ok(())
}
}
}
/// Drop the host selection we own.
pub fn clear_offer(&self) -> anyhow::Result<()> {
match self {
#[cfg(target_os = "linux")]
HostClipboard::DataControl(b) => b.clear_offer(),
#[cfg(target_os = "linux")]
HostClipboard::Mutter(m) => {
m.clear_offer();
Ok(())
}
#[cfg(target_os = "windows")]
HostClipboard::Windows(w) => {
w.clear_offer();
Ok(())
}
}
}
/// Read one wire format of the current host selection (a client's fetch). Async: data-control
/// blocks on a pipe (offloaded), Mutter round-trips D-Bus + reads a pipe, Windows reads the
/// clipboard on a blocking thread.
pub async fn read_current(self: &Arc<Self>, wire_mime: &str) -> anyhow::Result<Vec<u8>> {
match &**self {
#[cfg(target_os = "linux")]
HostClipboard::DataControl(_) => {
let me = Arc::clone(self);
let wire = wire_mime.to_string();
tokio::task::spawn_blocking(move || match &*me {
HostClipboard::DataControl(b) => b.read_current(&wire),
_ => unreachable!("variant checked above"),
})
.await
.map_err(|e| anyhow::anyhow!("data-control read join: {e}"))?
}
#[cfg(target_os = "linux")]
HostClipboard::Mutter(m) => m.read_current(wire_mime).await,
#[cfg(target_os = "windows")]
HostClipboard::Windows(w) => w.read_current(wire_mime).await,
}
}
}
// ---- Format normalization (design/clipboard-and-file-transfer.md §3.5) ------------------------
//
// One portable vocabulary crosses the wire; each end maps to platform types at fetch time. Phase 1
// covers text / RTF / HTML / PNG (files are Phase 2). The wire MIMEs match the core's table.
/// Wire MIME for UTF-8 plain text.
pub const WIRE_TEXT: &str = "text/plain;charset=utf-8";
/// Wire MIME for HTML.
pub const WIRE_HTML: &str = "text/html";
/// Wire MIME for rich text.
pub const WIRE_RTF: &str = "text/rtf";
/// Wire MIME for a PNG image.
pub const WIRE_PNG: &str = "image/png";
/// Wire MIME for a JPEG image — passed through VERBATIM when the source clipboard carries one
/// (no PNG transcode: a lossy original re-encoded lossless is pure bloat). [`WIRE_PNG`] remains
/// the universal fallback every peer must accept; JPEG/GIF are richer options beside it.
pub const WIRE_JPEG: &str = "image/jpeg";
/// Wire MIME for a GIF image — verbatim pass-through preserves animation end to end.
pub const WIRE_GIF: &str = "image/gif";
/// Map a Wayland selection MIME to its canonical wire MIME, or `None` to drop it (internal targets
/// like `TARGETS`/`TIMESTAMP`/`SAVE_TARGETS`, and formats we don't sync in Phase 1). Aliases
/// collapse onto one canonical wire name so the offered list dedups cleanly.
#[cfg(target_os = "linux")]
pub fn wayland_to_wire(wl: &str) -> Option<&'static str> {
// Strip any parameter noise for the plain-text aliases (some apps send `text/plain;charset=...`
// with odd charsets, or bare `text/plain`).
let base = wl.split(';').next().unwrap_or(wl).trim();
match wl {
"text/html" => Some(WIRE_HTML),
"text/rtf" | "application/rtf" | "text/richtext" => Some(WIRE_RTF),
"image/png" => Some(WIRE_PNG),
"image/jpeg" => Some(WIRE_JPEG),
"image/gif" => Some(WIRE_GIF),
_ => match base {
"text/plain" | "UTF8_STRING" | "STRING" | "TEXT" => Some(WIRE_TEXT),
_ => None,
},
}
}
/// The Wayland MIME candidates to request, in preference order, when a client fetches `wire` from
/// the host clipboard. The first one present in the current offer is used.
#[cfg(target_os = "linux")]
pub fn wayland_candidates(wire: &str) -> &'static [&'static str] {
match wire {
WIRE_TEXT => &[
"text/plain;charset=utf-8",
"text/plain",
"UTF8_STRING",
"STRING",
"TEXT",
],
WIRE_HTML => &["text/html"],
WIRE_RTF => &["text/rtf", "application/rtf", "text/richtext"],
WIRE_PNG => &["image/png"],
WIRE_JPEG => &["image/jpeg"],
WIRE_GIF => &["image/gif"],
_ => &[],
}
}
/// Pick the Wayland MIME to `receive()` for a wire fetch: the first [`wayland_candidates`] entry the
/// current selection actually advertises.
#[cfg(target_os = "linux")]
pub fn pick_wayland_mime(wire: &str, available: &[String]) -> Option<String> {
wayland_candidates(wire)
.iter()
.find(|c| available.iter().any(|a| a == *c))
.map(|c| c.to_string())
}
/// Normalize a raw Wayland offer's MIME list into the deduplicated wire MIME list announced to the
/// client (drops internal targets; collapses aliases; preserves a stable order).
#[cfg(target_os = "linux")]
pub fn offer_wire_mimes(raw: &[String]) -> Vec<&'static str> {
let mut out: Vec<&'static str> = Vec::new();
for m in raw {
if let Some(wire) = wayland_to_wire(m) {
if !out.contains(&wire) {
out.push(wire);
}
}
}
out
}
/// Whether a non-canonical, client-supplied MIME is safe to hand to Wayland as a string argument.
///
/// Deliberately strict: printable ASCII only (so no NUL and no other control byte can reach the
/// `CString` in the generated encoder), bounded length, and it must actually look like a MIME type.
/// A real `type/subtype[;params]` passes; nothing that could crash or confuse the compositor does.
#[cfg(target_os = "linux")]
fn valid_passthrough_mime(m: &str) -> bool {
let Some((ty, rest)) = m.split_once('/') else {
return false;
};
!ty.is_empty()
&& !rest.is_empty()
&& m.len() <= 255
// 0x21..=0x7E: printable ASCII without space. Excludes NUL, every other control byte, and
// any non-ASCII byte.
&& m.bytes().all(|b| (0x21..=0x7E).contains(&b))
}
/// The Wayland MIMEs to advertise when installing a source for a client's offer. Each wire MIME
/// expands to its canonical Wayland name(s); a rich-text-only offer also advertises `text/plain`
/// so plain-text targets always paste (§3.5 synthesis — destination-side, one direction only).
#[cfg(target_os = "linux")]
pub fn wayland_offers_for(wire_mimes: &[String]) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
let mut push = |s: &str| {
if !out.iter().any(|o| o == s) {
out.push(s.to_string());
}
};
let mut has_plain = false;
let mut has_rich = false;
for w in wire_mimes {
match w.as_str() {
WIRE_TEXT => {
has_plain = true;
push("text/plain;charset=utf-8");
push("text/plain");
push("UTF8_STRING");
push("STRING");
}
WIRE_HTML => {
has_rich = true;
push("text/html");
}
WIRE_RTF => {
has_rich = true;
push("text/rtf");
}
WIRE_PNG => push("image/png"),
WIRE_JPEG => push("image/jpeg"),
WIRE_GIF => push("image/gif"),
// A MIME we don't canonicalize is passed through verbatim — so it is the one value on
// this path the CLIENT fully controls, and it ends up as a Wayland string argument.
// The wayland-scanner-generated request encoder builds a `CString` and `unwrap()`s it,
// so a single interior NUL turns one control message into a host clipboard panic
// (2026-08-05 review L-8). `String::from_utf8_lossy` on the wire preserves `\0`, so
// nothing upstream removes it. Validate here, at the boundary where the value stops
// being ours and becomes libwayland's.
other if valid_passthrough_mime(other) => push(other),
other => {
tracing::debug!(mime = %other.escape_debug(), "clipboard: dropping a malformed client MIME");
}
}
}
// Synthesis: rich text without plain text → also advertise plain (the source derives it lazily).
if has_rich && !has_plain {
push("text/plain;charset=utf-8");
push("text/plain");
push("UTF8_STRING");
push("STRING");
}
out
}
#[cfg(all(test, target_os = "linux"))]
mod tests {
use super::*;
#[test]
fn wayland_to_wire_canonicalizes_and_drops_targets() {
assert_eq!(wayland_to_wire("text/plain"), Some(WIRE_TEXT));
assert_eq!(wayland_to_wire("UTF8_STRING"), Some(WIRE_TEXT));
assert_eq!(wayland_to_wire("text/plain;charset=utf-8"), Some(WIRE_TEXT));
assert_eq!(wayland_to_wire("text/html"), Some(WIRE_HTML));
assert_eq!(wayland_to_wire("application/rtf"), Some(WIRE_RTF));
assert_eq!(wayland_to_wire("image/png"), Some(WIRE_PNG));
// Original image formats now map to their own wire kinds (verbatim pass-through).
assert_eq!(wayland_to_wire("image/jpeg"), Some(WIRE_JPEG));
assert_eq!(wayland_to_wire("image/gif"), Some(WIRE_GIF));
// Internal targets and unsupported formats are dropped.
assert_eq!(wayland_to_wire("TARGETS"), None);
assert_eq!(wayland_to_wire("TIMESTAMP"), None);
assert_eq!(wayland_to_wire("image/webp"), None);
}
#[test]
fn offer_wire_mimes_dedups_aliases() {
let raw = vec![
"TARGETS".to_string(),
"UTF8_STRING".to_string(),
"text/plain;charset=utf-8".to_string(),
"text/plain".to_string(),
"text/html".to_string(),
];
// text aliases collapse to one WIRE_TEXT; TARGETS dropped; html kept.
assert_eq!(offer_wire_mimes(&raw), vec![WIRE_TEXT, WIRE_HTML]);
}
/// One control message must not be able to panic the host clipboard coordinator
/// (2026-08-05 review L-8). The passthrough branch is the only place a client string becomes a
/// Wayland argument, and the generated encoder `unwrap()`s a `CString` built from it.
#[test]
fn passthrough_mimes_cannot_carry_a_nul_or_control_byte() {
// The crash payload: an interior NUL survives `String::from_utf8_lossy` on the wire.
assert!(!valid_passthrough_mime("image/webp\0"));
assert!(!valid_passthrough_mime("\0"));
assert!(!valid_passthrough_mime("image/\0webp"));
// Other control bytes and whitespace are refused for the same reason.
assert!(!valid_passthrough_mime("image/web\np"));
assert!(!valid_passthrough_mime("image/web p"));
assert!(!valid_passthrough_mime("image/web\tp"));
// Shapes that are not a MIME type at all.
assert!(!valid_passthrough_mime(""));
assert!(!valid_passthrough_mime("noslash"));
assert!(!valid_passthrough_mime("/nosubtype"));
assert!(!valid_passthrough_mime("notype/"));
assert!(!valid_passthrough_mime(&format!(
"image/{}",
"x".repeat(300)
)));
// Legitimate uncanonicalized MIMEs still pass through.
assert!(valid_passthrough_mime("image/webp"));
assert!(valid_passthrough_mime("application/x-custom+json"));
assert!(valid_passthrough_mime("text/plain;charset=utf-8"));
// End to end: the offer list is built without the malformed entry, and does not panic.
let offers = wayland_offers_for(&["image/webp\0".to_string(), WIRE_PNG.to_string()]);
assert_eq!(offers, vec!["image/png".to_string()]);
}
#[test]
fn pick_wayland_mime_prefers_canonical() {
let avail = vec!["text/plain".to_string(), "UTF8_STRING".to_string()];
// Canonical charset form isn't present, so it falls to the next candidate.
assert_eq!(
pick_wayland_mime(WIRE_TEXT, &avail),
Some("text/plain".to_string())
);
let avail2 = vec![
"text/plain;charset=utf-8".to_string(),
"text/plain".to_string(),
];
assert_eq!(
pick_wayland_mime(WIRE_TEXT, &avail2),
Some("text/plain;charset=utf-8".to_string())
);
assert_eq!(pick_wayland_mime(WIRE_PNG, &avail2), None);
}
#[test]
fn wayland_offers_synthesizes_plain_for_rich_only() {
let offers = wayland_offers_for(&[WIRE_HTML.to_string()]);
assert!(offers.iter().any(|m| m == "text/html"));
assert!(
offers.iter().any(|m| m == "text/plain;charset=utf-8"),
"rich-only offer must synthesize plain text: {offers:?}"
);
// Plain already present → no duplicate synthesis, and text aliases included.
let offers2 = wayland_offers_for(&[WIRE_TEXT.to_string()]);
assert!(offers2.iter().any(|m| m == "UTF8_STRING"));
assert_eq!(offers2.iter().filter(|m| *m == "text/plain").count(), 1);
}
}