//! Proxy filtrante de Wayland para qorpa — [ADR 0015](../../../docs/adr/0015-imagenes-ajenas.md) //! §NO-resuelve 1 y §Orden de trabajo 8. //! //! ── EL AGUJERO QUE CIERRA ─────────────────────────────────────────────────────────────────────── //! Pasar el socket de Wayland crudo **no es abrir un caño: es un borde de privilegio**. El //! compositor anuncia TODOS sus globals, y entre ellos hay protocolos que hacen cosas que nadie //! concedió: `zwlr_screencopy_manager_v1` captura la pantalla, `zwp_virtual_keyboard_manager_v1` //! sintetiza teclas, `zwlr_data_control_manager_v1` lee el portapapeles sin que nadie copie, //! `zwlr_layer_shell_v1` dibuja encima de todo. Una instancia ajena con el socket crudo puede las //! cuatro. Flatpak no pasa el socket: pasa un proxy que filtra. Esto es ese proxy. //! //! ── POR QUÉ LISTA BLANCA Y NO NEGRA ───────────────────────────────────────────────────────────── //! Al revés que la denylist de syscalls de harkaq, acá manda la lista blanca, y la asimetría tiene //! razón: el conjunto PELIGROSO de Wayland **crece** —cada compositor inventa protocolos //! privilegiados y aparecen versiones nuevas—, mientras que el conjunto NECESARIO para dibujar una //! ventana es corto y estable. Una denylist estaría desactualizada el día que alguien actualice el //! compositor, y sin un solo error visible. //! //! ── CÓMO FILTRA, Y POR QUÉ ALCANZA CON MIRAR TAN POCO ─────────────────────────────────────────── //! El proxy relaya bytes salvo dos mensajes: //! · `wl_registry.global` (evento del compositor): si el interface no está en la lista, **no se //! reenvía**. El cliente nunca se entera de que existe. //! · `wl_registry.bind` (petición del cliente): un cliente malicioso puede intentar bindear un //! `name` numérico que nunca se le anunció. Se corta con un `wl_display.error` y se cierra. //! Nada más hace falta inspeccionar, y eso es lo que hace esto viable en un fichero: **los mensajes //! que se descartan no llevan descriptores**, así que los fds se pueden relayar en orden de llegada //! sin asociarlos a su mensaje. use std::collections::HashSet; use std::io; use std::os::fd::{AsRawFd, FromRawFd, OwnedFd, RawFd}; use std::os::unix::net::{UnixListener, UnixStream}; use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; use nix::sys::socket::{recvmsg, sendmsg, ControlMessage, ControlMessageOwned, MsgFlags}; /// Lo que una app necesita para dibujar, decorarse, recibir entrada y presentar frames. /// /// Incluye a propósito lo que los JUEGOS piden y suele olvidarse: `pointer_constraints` y /// `relative_pointer` (mirar con el mouse sin que el cursor se escape), `tearing_control`, /// `idle_inhibit` (que no se apague la pantalla a media partida). pub const GLOBALS_PERMITIDOS: &[&str] = &[ "wl_compositor", "wl_subcompositor", "wl_shm", "wl_seat", "wl_output", "wl_data_device_manager", "wl_drm", "xdg_wm_base", "zxdg_decoration_manager_v1", "xdg_activation_v1", "wp_viewporter", "wp_presentation", "wp_single_pixel_buffer_manager_v1", "wp_fractional_scale_manager_v1", "wp_cursor_shape_manager_v1", "wp_tearing_control_manager_v1", "wp_content_type_manager_v1", "zwp_linux_dmabuf_v1", "zwp_relative_pointer_manager_v1", "zwp_pointer_constraints_v1", "zwp_pointer_gestures_v1", "zwp_idle_inhibit_manager_v1", "zwp_text_input_manager_v3", "zwp_primary_selection_device_manager_v1", "zwp_tablet_manager_v2", "xdg_toplevel_icon_manager_v1", ]; const OBJ_DISPLAY: u32 = 1; const OP_DISPLAY_GET_REGISTRY: u16 = 1; const OP_DISPLAY_ERROR: u16 = 0; const OP_REGISTRY_GLOBAL: u16 = 0; const OP_REGISTRY_BIND: u16 = 0; /// `wl_display.error` con `code = 1` es «objeto/global inválido» — el más cercano a la verdad: /// para este cliente ese global no existe. const ERR_INVALID_OBJECT: u32 = 1; /// Estado compartido por las dos direcciones de una conexión. #[derive(Default)] struct Estado { /// Los `wl_registry` que este cliente creó. Puede haber más de uno. registries: HashSet, /// Los `name` numéricos que le ocultamos. Bindearlos es un intento de saltarse el filtro. ocultos: HashSet, } pub struct Proxy { /// El socket que se bindea DENTRO de la instancia, en vez del real. pub socket: PathBuf, _dir: PathBuf, } /// Arranca el proxy en segundo plano. Devuelve el socket a bindear en la jaula. /// /// Los hilos son detached a propósito: viven lo que viva el proceso, que es exactamente lo que dura /// la instancia (`takana qorpa run` bloquea en bwrap). pub fn arrancar(dir: &Path, socket_real: &Path, permitidos: HashSet) -> io::Result { std::fs::create_dir_all(dir)?; let socket = dir.join("wayland-0"); let _ = std::fs::remove_file(&socket); let escucha = UnixListener::bind(&socket)?; let real = socket_real.to_path_buf(); let permitidos = Arc::new(permitidos); std::thread::spawn(move || { for c in escucha.incoming() { let Ok(cliente) = c else { continue }; let real = real.clone(); let permitidos = permitidos.clone(); std::thread::spawn(move || { if let Err(e) = atender(cliente, &real, permitidos) { eprintln!("[qorpa-wl] conexión terminada: {e}"); } }); } }); Ok(Proxy { socket, _dir: dir.to_path_buf() }) } fn atender(cliente: UnixStream, real: &Path, permitidos: Arc>) -> io::Result<()> { let servidor = UnixStream::connect(real)?; let estado = Arc::new(Mutex::new(Estado::default())); let (c2, s2, e2, p2) = (cliente.try_clone()?, servidor.try_clone()?, estado.clone(), permitidos.clone()); let subida = std::thread::spawn(move || bombear(&c2, &s2, Sentido::HaciaServidor, e2, p2)); let r = bombear(&servidor, &cliente, Sentido::HaciaCliente, estado, permitidos); let _ = cliente.shutdown(std::net::Shutdown::Both); let _ = servidor.shutdown(std::net::Shutdown::Both); let _ = subida.join(); r } #[derive(PartialEq, Clone, Copy)] enum Sentido { HaciaServidor, HaciaCliente } fn bombear( de: &UnixStream, a: &UnixStream, sentido: Sentido, estado: Arc>, permitidos: Arc>, ) -> io::Result<()> { let mut pendiente: Vec = Vec::new(); loop { let (datos, fds) = leer(de)?; if datos.is_empty() && fds.is_empty() { return Ok(()); // el otro extremo cerró } pendiente.extend_from_slice(&datos); let mut salida: Vec = Vec::new(); let mut consumido = 0usize; while let Some((obj, op, tam)) = cabecera(&pendiente[consumido..]) { let msg = &pendiente[consumido..consumido + tam]; if filtrar(msg, obj, op, sentido, &estado, &permitidos, a)? { salida.extend_from_slice(msg); } consumido += tam; } pendiente.drain(..consumido); // Los fds van con lo que haya: ningún mensaje que descartamos lleva descriptores, así que // el orden se conserva sin necesidad de saber a qué mensaje pertenece cada uno. if !salida.is_empty() || !fds.is_empty() { escribir(a, &salida, &fds)?; } } } /// Devuelve `true` si el mensaje se reenvía. fn filtrar( msg: &[u8], obj: u32, op: u16, sentido: Sentido, estado: &Arc>, permitidos: &HashSet, otro: &UnixStream, ) -> io::Result { let cuerpo = &msg[8..]; match sentido { Sentido::HaciaServidor => { if obj == OBJ_DISPLAY && op == OP_DISPLAY_GET_REGISTRY { if let Some(id) = leer_u32(cuerpo, 0) { estado.lock().unwrap().registries.insert(id); } return Ok(true); } let es_registry = estado.lock().unwrap().registries.contains(&obj); if es_registry && op == OP_REGISTRY_BIND { if let Some(name) = leer_u32(cuerpo, 0) { if estado.lock().unwrap().ocultos.contains(&name) { // Bindear un global que nunca se anunció es un intento de saltarse el // filtro, no un accidente: se corta la conexión diciendo por qué. let _ = escribir(otro, &error_display(obj, ERR_INVALID_OBJECT, "qorpa: ese global no existe para esta instancia"), &[]); return Err(io::Error::other("bind a un global filtrado")); } } } Ok(true) } Sentido::HaciaCliente => { let es_registry = estado.lock().unwrap().registries.contains(&obj); if es_registry && op == OP_REGISTRY_GLOBAL { let name = leer_u32(cuerpo, 0); let interface = leer_str(cuerpo, 4); if let (Some(name), Some(iface)) = (name, interface) { if !permitidos.contains(&iface) { estado.lock().unwrap().ocultos.insert(name); return Ok(false); // el cliente nunca se entera } } } Ok(true) } } } // ── el cable ──────────────────────────────────────────────────────────────────────────────────── /// Cabecera de un mensaje de Wayland: `object_id:u32`, luego `u32` con el tamaño en los 16 bits /// altos y el opcode en los bajos. Devuelve `None` si todavía no llegó el mensaje entero. fn cabecera(b: &[u8]) -> Option<(u32, u16, usize)> { if b.len() < 8 { return None; } let obj = u32::from_ne_bytes(b[0..4].try_into().ok()?); let w = u32::from_ne_bytes(b[4..8].try_into().ok()?); let tam = (w >> 16) as usize; let op = (w & 0xffff) as u16; // Un tamaño menor que la cabecera es un stream corrupto; tratarlo como "falta" colgaría el // proxy en un bucle, así que se corta. if tam < 8 || b.len() < tam { return None; } Some((obj, op, tam)) } fn leer_u32(cuerpo: &[u8], off: usize) -> Option { cuerpo.get(off..off + 4).map(|s| u32::from_ne_bytes(s.try_into().unwrap())) } /// Un string de Wayland: `len:u32` INCLUYENDO el NUL, y luego el texto alineado a 4 bytes. fn leer_str(cuerpo: &[u8], off: usize) -> Option { let len = leer_u32(cuerpo, off)? as usize; if len == 0 { return Some(String::new()); } let ini = off + 4; let txt = cuerpo.get(ini..ini + len - 1)?; Some(String::from_utf8_lossy(txt).into_owned()) } fn escribir_str(out: &mut Vec, s: &str) { let bytes = s.as_bytes(); let len = bytes.len() + 1; out.extend_from_slice(&(len as u32).to_ne_bytes()); out.extend_from_slice(bytes); out.push(0); while out.len() % 4 != 0 { out.push(0); } } fn error_display(obj: u32, code: u32, msg: &str) -> Vec { let mut cuerpo = Vec::new(); cuerpo.extend_from_slice(&obj.to_ne_bytes()); cuerpo.extend_from_slice(&code.to_ne_bytes()); escribir_str(&mut cuerpo, msg); let tam = 8 + cuerpo.len(); let mut out = Vec::with_capacity(tam); out.extend_from_slice(&OBJ_DISPLAY.to_ne_bytes()); out.extend_from_slice(&(((tam as u32) << 16) | OP_DISPLAY_ERROR as u32).to_ne_bytes()); out.extend_from_slice(&cuerpo); out } fn leer(s: &UnixStream) -> io::Result<(Vec, Vec)> { let mut buf = [0u8; 8192]; let mut espacio = nix::cmsg_space!([RawFd; 32]); let mut iov = [io::IoSliceMut::new(&mut buf)]; let r = recvmsg::<()>(s.as_raw_fd(), &mut iov, Some(&mut espacio), MsgFlags::empty()) .map_err(io::Error::from)?; let mut fds = Vec::new(); for c in r.cmsgs().map_err(io::Error::from)? { if let ControlMessageOwned::ScmRights(rs) = c { for fd in rs { fds.push(unsafe { OwnedFd::from_raw_fd(fd) }); } } } let n = r.bytes; Ok((buf[..n].to_vec(), fds)) } fn escribir(s: &UnixStream, datos: &[u8], fds: &[OwnedFd]) -> io::Result<()> { let crudos: Vec = fds.iter().map(|f| f.as_raw_fd()).collect(); let cmsgs: Vec = if crudos.is_empty() { vec![] } else { vec![ControlMessage::ScmRights(&crudos)] }; // Un socket de dominio unix puede aceptar menos bytes de los que se le dan: hay que insistir, // y los fds viajan sólo con el primer envío para no duplicarlos. let mut off = 0; loop { let resto = &datos[off..]; let iov = [io::IoSlice::new(if resto.is_empty() { b"" } else { resto })]; let usar = if off == 0 { &cmsgs[..] } else { &[][..] }; let n = sendmsg::<()>(s.as_raw_fd(), &iov, usar, MsgFlags::empty(), None) .map_err(io::Error::from)?; off += n; if off >= datos.len() { return Ok(()); } } } #[cfg(test)] mod tests { use super::*; use std::io::{Read, Write}; fn msg(obj: u32, op: u16, cuerpo: &[u8]) -> Vec { let tam = 8 + cuerpo.len(); let mut v = Vec::with_capacity(tam); v.extend_from_slice(&obj.to_ne_bytes()); v.extend_from_slice(&(((tam as u32) << 16) | op as u32).to_ne_bytes()); v.extend_from_slice(cuerpo); v } fn global(registry: u32, name: u32, iface: &str, version: u32) -> Vec { let mut c = Vec::new(); c.extend_from_slice(&name.to_ne_bytes()); escribir_str(&mut c, iface); c.extend_from_slice(&version.to_ne_bytes()); msg(registry, OP_REGISTRY_GLOBAL, &c) } /// Un compositor de mentira: espera el `get_registry`, y anuncia lo que se le diga. fn compositor_falso(sock: &Path, anuncios: Vec<(u32, String)>) -> std::thread::JoinHandle<()> { let l = UnixListener::bind(sock).unwrap(); std::thread::spawn(move || { let (mut c, _) = l.accept().unwrap(); let mut buf = [0u8; 4096]; let n = c.read(&mut buf).unwrap(); // Lo único que se parsea: el id de registry que pidió el cliente. let (obj, op, _) = cabecera(&buf[..n]).unwrap(); assert_eq!((obj, op), (OBJ_DISPLAY, OP_DISPLAY_GET_REGISTRY)); let reg = leer_u32(&buf[8..n], 0).unwrap(); let mut out = Vec::new(); for (name, iface) in &anuncios { out.extend_from_slice(&global(reg, *name, iface, 1)); } c.write_all(&out).unwrap(); // Se queda viva para que el proxy no vea EOF antes de tiempo. let _ = c.read(&mut buf); }) } fn interfaces_vistas(datos: &[u8], reg: u32) -> Vec { let mut v = Vec::new(); let mut off = 0; while let Some((obj, op, tam)) = cabecera(&datos[off..]) { if obj == reg && op == OP_REGISTRY_GLOBAL { if let Some(i) = leer_str(&datos[off + 8..off + tam], 4) { v.push(i); } } off += tam; } v } #[test] fn el_cliente_no_se_entera_de_los_globals_peligrosos() { let d = tempfile::tempdir().unwrap(); let real = d.path().join("compositor.sock"); let h = compositor_falso(&real, vec![ (1, "wl_compositor".into()), (2, "zwlr_screencopy_manager_v1".into()), // captura de pantalla (3, "wl_seat".into()), (4, "zwp_virtual_keyboard_manager_v1".into()), // sintetiza teclas (5, "zwlr_data_control_manager_v1".into()), // lee el portapapeles (6, "xdg_wm_base".into()), (7, "zwlr_layer_shell_v1".into()), // dibuja encima de todo ]); let permitidos: HashSet = GLOBALS_PERMITIDOS.iter().map(|s| s.to_string()).collect(); let p = arrancar(&d.path().join("wl"), &real, permitidos).unwrap(); let mut c = UnixStream::connect(&p.socket).unwrap(); let mut cuerpo = Vec::new(); cuerpo.extend_from_slice(&2u32.to_ne_bytes()); // new_id del registry c.write_all(&msg(OBJ_DISPLAY, OP_DISPLAY_GET_REGISTRY, &cuerpo)).unwrap(); c.set_read_timeout(Some(std::time::Duration::from_secs(5))).unwrap(); let mut buf = [0u8; 8192]; let n = c.read(&mut buf).unwrap(); let vistas = interfaces_vistas(&buf[..n], 2); assert!(vistas.contains(&"wl_compositor".to_string()), "{vistas:?}"); assert!(vistas.contains(&"wl_seat".to_string()), "{vistas:?}"); assert!(vistas.contains(&"xdg_wm_base".to_string()), "{vistas:?}"); for peligroso in ["zwlr_screencopy_manager_v1", "zwp_virtual_keyboard_manager_v1", "zwlr_data_control_manager_v1", "zwlr_layer_shell_v1"] { assert!(!vistas.iter().any(|v| v == peligroso), "se coló {peligroso}: {vistas:?}"); } assert_eq!(vistas.len(), 3, "ni más ni menos que lo permitido: {vistas:?}"); // NO se hace `join` del compositor falso: sigue bloqueado leyendo, y la otra dirección del // proxy tampoco se entera hasta que intente escribir. Es un hilo de test, muere con él. drop(c); drop(h); } #[test] fn bindear_un_global_oculto_corta_la_conexion() { let d = tempfile::tempdir().unwrap(); let real = d.path().join("c.sock"); let h = compositor_falso(&real, vec![ (1, "wl_compositor".into()), (99, "zwlr_screencopy_manager_v1".into()), ]); let permitidos: HashSet = GLOBALS_PERMITIDOS.iter().map(|s| s.to_string()).collect(); let p = arrancar(&d.path().join("wl"), &real, permitidos).unwrap(); let mut c = UnixStream::connect(&p.socket).unwrap(); let mut cuerpo = Vec::new(); cuerpo.extend_from_slice(&2u32.to_ne_bytes()); c.write_all(&msg(OBJ_DISPLAY, OP_DISPLAY_GET_REGISTRY, &cuerpo)).unwrap(); c.set_read_timeout(Some(std::time::Duration::from_secs(5))).unwrap(); let mut buf = [0u8; 8192]; let _ = c.read(&mut buf).unwrap(); // Un cliente malicioso adivina el `name` que nunca se le anunció. let mut b = Vec::new(); b.extend_from_slice(&99u32.to_ne_bytes()); escribir_str(&mut b, "zwlr_screencopy_manager_v1"); b.extend_from_slice(&1u32.to_ne_bytes()); b.extend_from_slice(&3u32.to_ne_bytes()); c.write_all(&msg(2, OP_REGISTRY_BIND, &b)).unwrap(); // Tiene que llegar un wl_display.error y cerrarse. Ocultar el global sin cortar el bind // dejaría el filtro en decorativo: el `name` es sólo un número que se puede adivinar. let n = c.read(&mut buf).unwrap_or(0); let hubo_error = n > 0 && cabecera(&buf[..n]).map(|(o, op, _)| (o, op)) == Some((OBJ_DISPLAY, OP_DISPLAY_ERROR)); let cerro = { let n2 = c.read(&mut buf).unwrap_or(0); n2 == 0 }; assert!(hubo_error || cerro, "el bind al global oculto no fue cortado"); // NO se hace `join` del compositor falso: sigue bloqueado leyendo, y la otra dirección del // proxy tampoco se entera hasta que intente escribir. Es un hilo de test, muere con él. drop(c); drop(h); } #[test] fn la_lista_es_BLANCA_no_negra() { // Un protocolo privilegiado que todavía no existe tampoco pasa: es la propiedad que una // denylist no puede dar, y la razón de elegir lista blanca acá y denylist en las syscalls. let permitidos: HashSet = GLOBALS_PERMITIDOS.iter().map(|s| s.to_string()).collect(); assert!(!permitidos.contains("zwlr_screencopy_manager_v1")); assert!(!permitidos.contains("protocolo_privilegiado_del_futuro_v9")); assert!(permitidos.contains("wl_compositor")); } #[test] fn el_parser_no_se_cuelga_con_un_stream_corrupto() { // Un `size` menor que la cabecera haría avanzar 0 bytes ⇒ bucle infinito dentro del proxy. let mut malo = Vec::new(); malo.extend_from_slice(&1u32.to_ne_bytes()); malo.extend_from_slice(&((4u32 << 16) | 1).to_ne_bytes()); // size=4, imposible assert!(cabecera(&malo).is_none()); assert!(cabecera(&[0u8; 3]).is_none()); // mensaje incompleto } }