From 32c65bc368bfbe880974847243f271757bf7265b Mon Sep 17 00:00:00 2001 From: Sythos Date: Sat, 3 Oct 2026 07:19:25 +0200 Subject: [PATCH] Validate links before opening them and vet each image redirect The system browser got the raw href of a chat link, not the parsed one, and the external image fetch followed redirects blindly, even from https to http. Add one validation (http, https and ftp, no whitespace or control characters, no embedded credentials, at most 2,048 bytes) whose normalised URL is what the opener gets, with a refused link landing on the viewer's existing failed screen, and a redirect policy for the image fetch: at most five hops, only http and https, no credentials, no https to http downgrade, and no hop from a public host to a local one. Fixes #207. --- README.md | 6 +- crates/cordiale-core/src/media.rs | 538 +++++++++++++++++++++++++++++- crates/cordiale-ui/src/main.rs | 48 ++- docs/feature-matrix.md | 2 +- 4 files changed, 565 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index d700798..7f2c697 100644 --- a/README.md +++ b/README.md @@ -200,7 +200,11 @@ Cordiale also covers: image and text uploads on Grappa, and https images elsewhere, open in the media viewer (fit or actual size; text read-only, with Copy), MP3, Ogg and FLAC links play in the radio's player, and the rest opens in the browser. - Like Cicchetto, Cordiale shows no inline previews. + Like Cicchetto, Cordiale shows no inline previews. Only http, https and + ftp links open (not `file:`, `javascript:`, `mailto:` or links with a user + name or password), and the browser gets the normalised URL. A download for the + viewer follows at most five redirects and refuses one that goes from + https to http or from a public host into the local network. - **Slash commands:** the everyday IRC verbs (`/me`, `/msg`, `/notice`, `/query`, `/join`, `/part`, `/cycle`, `/topic`, `/nick`, `/away`, `/ctcp`, `/ping`, op/voice/kick/ban/mode, `/quote`, `/oper`, diff --git a/crates/cordiale-core/src/media.rs b/crates/cordiale-core/src/media.rs index 6d61ac5..8fe07d5 100644 --- a/crates/cordiale-core/src/media.rs +++ b/crates/cordiale-core/src/media.rs @@ -25,6 +25,8 @@ //! upload, or on an https image elsewhere, opens the in-app viewer, and //! anything else goes to the browser. +use std::error::Error as _; +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; use std::ops::Range; /// A link found in a message: where its text is, and the URL to open. @@ -198,9 +200,154 @@ pub fn link_target(href: &str, server_base: &str) -> LinkTarget { LinkTarget::Browser } -/// Whether the system browser may be asked to open `href`. -pub fn is_openable(href: &str) -> bool { - reqwest::Url::parse(href).is_ok_and(|url| matches!(url.scheme(), "http" | "https" | "ftp")) +/// Longest link the browser or the downloader is given; the Windows shell +/// stops at about this length. +const MAX_LINK_LEN: usize = 2048; + +/// Why a link isn't opened or downloaded. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LinkRefusal { + TooLong, + /// Whitespace or a control character anywhere in the text. + UnsafeCharacters, + Invalid, + /// Anything but `http`, `https` and `ftp`. + Scheme, + /// A user name or password in front of the host. + Credentials, +} + +impl std::fmt::Display for LinkRefusal { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + Self::TooLong => "link too long", + Self::UnsafeCharacters => "link has spaces or control characters", + Self::Invalid => "not a valid link", + Self::Scheme => "only http, https and ftp links are opened", + Self::Credentials => "link carries a user name or password", + }) + } +} + +/// The URL `href` stands for, when it is fit to hand to the system browser +/// or to download: `http`, `https` or `ftp` only, no whitespace or control +/// characters, no user name or password, at most 2048 bytes. +/// What gets opened or fetched is the returned (normalised) URL, never the +/// original text, so the string that was checked is the string that is used. +pub fn validated_url(href: &str) -> Result { + if href.len() > MAX_LINK_LEN { + return Err(LinkRefusal::TooLong); + } + if href.chars().any(|ch| ch.is_whitespace() || ch.is_control()) { + return Err(LinkRefusal::UnsafeCharacters); + } + let url = reqwest::Url::parse(href).map_err(|_| LinkRefusal::Invalid)?; + if !matches!(url.scheme(), "http" | "https" | "ftp") { + return Err(LinkRefusal::Scheme); + } + if has_userinfo(&url) { + return Err(LinkRefusal::Credentials); + } + Ok(url) +} + +fn has_userinfo(url: &reqwest::Url) -> bool { + !url.username().is_empty() || url.password().is_some() +} + +/// Most redirects a download follows. +const MAX_REDIRECTS: usize = 5; + +/// Why a redirect isn't followed. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RedirectRefusal { + TooMany, + /// From `https` to `http`. + Downgrade, + Scheme, + Credentials, + /// From a public host to a loopback, private or link-local address. + LocalHost, +} + +impl std::fmt::Display for RedirectRefusal { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + Self::TooMany => "too many redirects", + Self::Downgrade => "redirect from https to http refused", + Self::Scheme => "redirect to a scheme other than http or https refused", + Self::Credentials => "redirect to an address with a user name or password refused", + Self::LocalHost => "redirect to a local address refused", + }) + } +} + +impl std::error::Error for RedirectRefusal {} + +/// Whether a download may follow a redirect to `next`, given the URLs +/// already requested (the first one included, as reqwest reports them). +/// A relative `Location` has already been resolved against the last URL. +pub fn redirect_decision( + previous: &[reqwest::Url], + next: &reqwest::Url, +) -> Result<(), RedirectRefusal> { + if previous.len() > MAX_REDIRECTS { + return Err(RedirectRefusal::TooMany); + } + if !matches!(next.scheme(), "http" | "https") { + return Err(RedirectRefusal::Scheme); + } + if has_userinfo(next) { + return Err(RedirectRefusal::Credentials); + } + if let Some(last) = previous.last() { + if last.scheme() == "https" && next.scheme() == "http" { + return Err(RedirectRefusal::Downgrade); + } + // A link the user clicked may point at a LAN host on purpose, but a + // public host must not bounce the request into the local network. + if is_local_host(next) && !is_local_host(last) { + return Err(RedirectRefusal::LocalHost); + } + } + Ok(()) +} + +/// Whether the host of `url`, as `Url::host_str` writes it, is `localhost` +/// (or a name under it) or an IP literal that is loopback, private, +/// link-local, shared (CGNAT) or unspecified. Nothing is resolved, so a name +/// that merely points at such an address isn't caught. +fn is_local_host(url: &reqwest::Url) -> bool { + let Some(host) = url.host_str() else { + return true; + }; + let host = host.trim_end_matches('.'); + if host == "localhost" || host.ends_with(".localhost") { + return true; + } + match host.trim_matches(['[', ']']).parse::() { + Ok(IpAddr::V4(ip)) => is_local_v4(ip), + Ok(IpAddr::V6(ip)) => ip + .to_ipv4_mapped() + .map_or_else(|| is_local_v6(ip), is_local_v4), + Err(_) => false, + } +} + +fn is_local_v4(ip: Ipv4Addr) -> bool { + let [first, second, ..] = ip.octets(); + ip.is_loopback() + || ip.is_private() + || ip.is_link_local() + || ip.is_unspecified() + || ip.is_broadcast() + || first == 0 + || (first == 100 && second & 0xc0 == 64) +} + +fn is_local_v6(ip: Ipv6Addr) -> bool { + let first = ip.segments()[0]; + ip.is_loopback() || ip.is_unspecified() || first & 0xfe00 == 0xfc00 || first & 0xffc0 == 0xfe80 } /// Largest image and text the viewer downloads. @@ -216,23 +363,51 @@ pub enum FetchError { Failed(String), } +/// The reason a request failed, without the URL reqwest would add (a +/// redirect target can carry credentials) and with the refused redirect's own +/// reason when that is what stopped it. +fn fetch_error(err: reqwest::Error) -> FetchError { + let err = err.without_url(); + let mut source = err.source(); + while let Some(inner) = source { + if let Some(refusal) = inner.downcast_ref::() { + return FetchError::Failed(refusal.to_string()); + } + source = inner.source(); + } + FetchError::Failed(err.to_string()) +} + /// Downloads a file from a host other than Grappa, without credentials, /// up to `max_bytes`; a text file over the limit is cut there (`true`). +/// `href` must pass [`validated_url`] (and be http or https) and every redirect +/// [`redirect_decision`], so the download can't be led to another scheme, +/// down from https to http, or from a public host into the local network. pub async fn fetch_public( href: &str, max_bytes: usize, cut_when_larger: bool, ) -> Result<(Vec, Option, bool), FetchError> { + let url = validated_url(href).map_err(|refusal| FetchError::Failed(refusal.to_string()))?; + if url.scheme() == "ftp" { + return Err(FetchError::Failed( + "only http and https can be downloaded".to_string(), + )); + } + let redirects = reqwest::redirect::Policy::custom(|attempt| { + let decision = redirect_decision(attempt.previous(), attempt.url()); + match decision { + Ok(()) => attempt.follow(), + Err(refusal) => attempt.error(refusal), + } + }); let http = reqwest::Client::builder() .user_agent(crate::EXTERNAL_USER_AGENT) .timeout(std::time::Duration::from_secs(60)) + .redirect(redirects) .build() - .map_err(|err| FetchError::Failed(err.to_string()))?; - let mut response = http - .get(href) - .send() - .await - .map_err(|err| FetchError::Failed(err.to_string()))?; + .map_err(|err| FetchError::Failed(err.without_url().to_string()))?; + let mut response = http.get(url).send().await.map_err(fetch_error)?; let status = response.status().as_u16(); if status == 404 || status == 410 { return Err(FetchError::Gone); @@ -246,11 +421,7 @@ pub async fn fetch_public( .and_then(|value| value.to_str().ok()) .map(str::to_string); let mut bytes = Vec::new(); - while let Some(chunk) = response - .chunk() - .await - .map_err(|err| FetchError::Failed(err.to_string()))? - { + while let Some(chunk) = response.chunk().await.map_err(fetch_error)? { bytes.extend_from_slice(&chunk); if bytes.len() > max_bytes { if cut_when_larger { @@ -352,8 +523,341 @@ mod tests { ); let spaced = "a\u{3000}https://x.io/p"; assert_eq!(find_links(spaced)[0].range, 4..spaced.len()); - assert!(is_openable("ftp://a.b/c")); - assert!(!is_openable("javascript:alert(1)")); - assert!(!is_openable("file:///etc/passwd")); + } + + fn url(text: &str) -> reqwest::Url { + reqwest::Url::parse(text).expect("test URL") + } + + fn checked(href: &str) -> Result { + validated_url(href).map(String::from) + } + + #[test] + fn valid_links_come_back_normalised() { + assert_eq!( + checked("https://example.org/a%20b"), + Ok("https://example.org/a%20b".to_string()) + ); + assert_eq!( + checked("https://example.org"), + Ok("https://example.org/".to_string()) + ); + assert_eq!( + checked("HTTPS://Example.ORG:443/Path?q=1#frag"), + Ok("https://example.org/Path?q=1#frag".to_string()) + ); + assert_eq!( + checked("http://example.org:8080/x"), + Ok("http://example.org:8080/x".to_string()) + ); + assert_eq!( + checked("https://example.org\\@evil.test/x"), + Ok("https://example.org/@evil.test/x".to_string()) + ); + } + + #[test] + fn ftp_links_are_accepted_and_normalised() { + assert_eq!( + checked("FTP://Files.Example.org:21/pub/a.txt"), + Ok("ftp://files.example.org/pub/a.txt".to_string()) + ); + assert_eq!( + checked("ftp://user:pw@files.example.org/a"), + Err(LinkRefusal::Credentials) + ); + } + + #[test] + fn internationalised_hosts_become_punycode() { + assert_eq!( + checked("https://bücher.example/ä"), + Ok("https://xn--bcher-kva.example/%C3%A4".to_string()) + ); + } + + #[test] + fn links_with_whitespace_or_control_characters_are_refused() { + for href in [ + " https://example.org/", + "https://example.org/ ", + "https://exa mple.org/", + "https://example.org/\tx", + "https://example.org/\nx", + "https://example.org/\rx", + "https://example.org/\u{0}x", + "https://example.org/\u{7f}x", + "https://example.org/\u{3000}x", + "java\nscript:alert(1)", + ] { + assert_eq!( + checked(href), + Err(LinkRefusal::UnsafeCharacters), + "{href:?}" + ); + } + } + + #[test] + fn links_with_credentials_are_refused() { + for href in [ + "https://user@example.org/", + "https://user:secret@example.org/", + "https://:secret@example.org/", + "http://user:@example.org/", + ] { + assert_eq!(checked(href), Err(LinkRefusal::Credentials), "{href}"); + } + } + + #[test] + fn only_http_https_and_ftp_links_are_accepted() { + for href in [ + "javascript:alert(1)", + "JavaScript:alert(1)", + "file:///etc/passwd", + "data:text/html,x", + "mailto:someone@example.org", + "vscode://open", + "about:blank", + ] { + assert_eq!(checked(href), Err(LinkRefusal::Scheme), "{href}"); + } + } + + #[test] + fn garbage_and_oversized_links_are_refused() { + for href in [ + "", + "not a url", + "example.org/path", + "https://", + "http://[::1", + ] { + assert!(checked(href).is_err(), "{href}"); + } + let long_path = "a".repeat(MAX_LINK_LEN); + assert_eq!( + checked(&format!("https://example.org/{long_path}")), + Err(LinkRefusal::TooLong) + ); + let fits = "a".repeat(MAX_LINK_LEN - "https://example.org/".len()); + assert!(checked(&format!("https://example.org/{fits}")).is_ok()); + } + + #[test] + fn redirects_keep_https_and_never_downgrade() { + let from_https = [url("https://a.example/x.png")]; + let from_http = [url("http://a.example/x.png")]; + assert_eq!( + redirect_decision(&from_https, &url("https://b.example/y.png")), + Ok(()) + ); + assert_eq!( + redirect_decision(&from_http, &url("https://b.example/y.png")), + Ok(()) + ); + assert_eq!( + redirect_decision(&from_http, &url("http://b.example/y.png")), + Ok(()) + ); + assert_eq!( + redirect_decision(&from_https, &url("http://b.example/y.png")), + Err(RedirectRefusal::Downgrade) + ); + // Same host, other scheme: still a downgrade. + assert_eq!( + redirect_decision(&from_https, &url("http://a.example/x.png")), + Err(RedirectRefusal::Downgrade) + ); + } + + #[test] + fn redirects_stop_after_five_hops() { + let chain = |count: usize| -> Vec { + (0..count) + .map(|n| url(&format!("https://a.example/{n}"))) + .collect() + }; + let next = url("https://a.example/next"); + // `previous` holds the first request plus every redirect followed. + assert_eq!(redirect_decision(&chain(1), &next), Ok(())); + assert_eq!(redirect_decision(&chain(5), &next), Ok(())); + assert_eq!( + redirect_decision(&chain(6), &next), + Err(RedirectRefusal::TooMany) + ); + } + + #[test] + fn relative_redirects_resolve_against_the_last_url() { + let first = url("https://a.example/dir/x.png"); + let previous = [first.clone()]; + for location in ["y.png", "/y.png", "../y.png", "//b.example/y.png", "?v=2"] { + let next = first.join(location).expect("relative location"); + assert_eq!(next.scheme(), "https", "{location}"); + assert_eq!(redirect_decision(&previous, &next), Ok(()), "{location}"); + } + let downgraded = first.join("http://a.example/y.png").expect("absolute"); + assert_eq!( + redirect_decision(&previous, &downgraded), + Err(RedirectRefusal::Downgrade) + ); + } + + #[test] + fn redirects_to_other_schemes_or_with_credentials_are_refused() { + let previous = [url("https://a.example/x.png")]; + for next in [ + "ftp://b.example/y.png", + "file:///etc/passwd", + "data:text/plain,x", + ] { + assert_eq!( + redirect_decision(&previous, &url(next)), + Err(RedirectRefusal::Scheme), + "{next}" + ); + } + for next in [ + "https://user@b.example/y.png", + "https://user:secret@b.example/y.png", + ] { + assert_eq!( + redirect_decision(&previous, &url(next)), + Err(RedirectRefusal::Credentials), + "{next}" + ); + } + } + + #[test] + fn a_public_host_cannot_redirect_into_the_local_network() { + let public = [url("https://a.example/x.png")]; + for next in [ + "https://localhost/y.png", + "https://cam.localhost/y.png", + "https://127.0.0.1/y.png", + "https://127.1/y.png", + "https://2130706433/y.png", + "https://10.0.0.5/y.png", + "https://172.16.3.4/y.png", + "https://192.168.1.1/y.png", + "https://169.254.169.254/latest", + "https://100.64.0.1/y.png", + "https://0.0.0.0/y.png", + "https://[::1]/y.png", + "https://[fe80::1]/y.png", + "https://[fd00::1]/y.png", + "https://[::ffff:127.0.0.1]/y.png", + "https://[::ffff:10.1.2.3]/y.png", + ] { + assert_eq!( + redirect_decision(&public, &url(next)), + Err(RedirectRefusal::LocalHost), + "{next}" + ); + } + for next in [ + "https://8.8.8.8/y.png", + "https://172.32.0.1/y.png", + "https://100.128.0.1/y.png", + "https://[2001:4860:4860::8888]/y.png", + "https://b.example/y.png", + ] { + assert_eq!(redirect_decision(&public, &url(next)), Ok(()), "{next}"); + } + } + + #[test] + fn a_link_to_the_local_network_may_stay_there() { + let lan = [url("https://192.168.1.10/x.png")]; + assert_eq!( + redirect_decision(&lan, &url("https://192.168.1.11/y.png")), + Ok(()) + ); + assert_eq!( + redirect_decision(&lan, &url("https://localhost/y.png")), + Ok(()) + ); + } + + #[tokio::test] + async fn fetch_follows_a_relative_redirect() { + use wiremock::matchers::{method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/old.png")) + .respond_with(ResponseTemplate::new(302).insert_header("location", "/new.png")) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/new.png")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", "image/png") + .set_body_bytes(vec![1u8, 2, 3]), + ) + .mount(&server) + .await; + + let (bytes, content_type, cut) = + fetch_public(&format!("{}/old.png", server.uri()), 1024, false) + .await + .expect("fetch"); + assert_eq!(bytes, [1u8, 2, 3]); + assert_eq!(content_type.as_deref(), Some("image/png")); + assert!(!cut); + } + + #[tokio::test] + async fn fetch_gives_up_on_a_redirect_loop() { + use wiremock::matchers::{method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/loop.png")) + .respond_with(ResponseTemplate::new(302).insert_header("location", "/loop.png")) + .mount(&server) + .await; + + let result = fetch_public(&format!("{}/loop.png", server.uri()), 1024, false).await; + assert!( + matches!(&result, Err(FetchError::Failed(reason)) if reason == "too many redirects"), + "{result:?}" + ); + } + + #[tokio::test] + async fn fetch_refuses_a_redirect_to_another_scheme() { + use wiremock::matchers::{method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/a.png")) + .respond_with( + ResponseTemplate::new(302).insert_header("location", "ftp://files.example/a.png"), + ) + .mount(&server) + .await; + + let result = fetch_public(&format!("{}/a.png", server.uri()), 1024, false).await; + assert!( + matches!(&result, Err(FetchError::Failed(reason)) if reason.contains("scheme")), + "{result:?}" + ); + } + + #[tokio::test] + async fn fetch_refuses_a_link_with_credentials_before_connecting() { + let result = fetch_public("https://user:secret@example.org/a.png", 1024, false).await; + assert!( + matches!(&result, Err(FetchError::Failed(reason)) if !reason.contains("secret")), + "{result:?}" + ); + let result = fetch_public("ftp://example.org/a.png", 1024, false).await; + assert!(matches!(result, Err(FetchError::Failed(_)))); } } diff --git a/crates/cordiale-ui/src/main.rs b/crates/cordiale-ui/src/main.rs index 4d60533..090343f 100644 --- a/crates/cordiale-ui/src/main.rs +++ b/crates/cordiale-ui/src/main.rs @@ -1734,9 +1734,17 @@ async fn run_worker( delete_archive_target(&mut state, &ui, &target).await; } Some(WorkerCommand::OpenLink(href)) => { - match audio_link(&state, &href) { - Some(hint) => play_audio_link(&mut radio_state, &radio, &ui, &href, hint), - None => open_link(&state, &ui, href), + match cordiale_core::media::validated_url(&href) { + Ok(url) => { + let href = url.to_string(); + match audio_link(&state, &href) { + Some(hint) => { + play_audio_link(&mut radio_state, &radio, &ui, &href, hint) + } + None => open_link(&state, &ui, href), + } + } + Err(refusal) => link_refused(&ui, refusal), } } Some(WorkerCommand::RadioTune(key)) => { @@ -7782,12 +7790,17 @@ fn linked_markdown(text: &str) -> String { markdown } -/// Asks the system to open an http(s) or ftp URL in the browser; other -/// schemes are ignored. +/// Asks the system to open an http(s) URL in the browser. The browser gets +/// the validated, normalised URL rather than `href` itself; anything else is +/// refused and only logged (without the link, which may hold a token). fn open_in_browser(href: &str) { - if !cordiale_core::media::is_openable(href) { - return; - } + let url = match cordiale_core::media::validated_url(href) { + Ok(url) => url, + Err(refusal) => { + persistence::log_line(&format!("browser not opened: {refusal}")); + return; + } + }; let mut command = if cfg!(target_os = "windows") { let mut command = std::process::Command::new("rundll32"); command.arg("url.dll,FileProtocolHandler"); @@ -7797,7 +7810,7 @@ fn open_in_browser(href: &str) { } else { std::process::Command::new("xdg-open") }; - if let Err(err) = command.arg(href).spawn() { + if let Err(err) = command.arg(url.as_str()).spawn() { persistence::log_line(&format!("browser not opened: {err}")); } } @@ -7821,7 +7834,22 @@ fn open_folder(dir: &std::path::Path) { } } -/// A clicked chat link: images and text uploads (and https images +/// A clicked link that isn't opened: the viewer says so, as it does for a +/// file that couldn't be shown. The link itself is not logged. +fn link_refused(ui: &slint::Weak, refusal: cordiale_core::media::LinkRefusal) { + persistence::log_line(&format!("link not opened: {refusal}")); + let reason = refusal.to_string(); + let _ = ui.upgrade_in_event_loop(move |ui| { + ui.set_media_url("".into()); + ui.set_media_title("".into()); + ui.set_media_error(reason.into()); + ui.set_media_kind("failed".into()); + ui.set_media_zoom(false); + ui.set_screen("media".into()); + }); +} + +/// A clicked chat link, already validated and normalised: images and text uploads (and https images /// elsewhere) open in the viewer, like Cicchetto; anything else in the /// browser. Grappa files are read with the session; other hosts without /// any credential. diff --git a/docs/feature-matrix.md b/docs/feature-matrix.md index 810ff24..896eca5 100644 --- a/docs/feature-matrix.md +++ b/docs/feature-matrix.md @@ -157,7 +157,7 @@ scelta. Stato per area: | Impostazioni sincronizzate | Fatto | Nick di rete riletto, watchlist di parole chiave dal server, comandi on-connect multilinea | | Notifiche push | Fatto | Interruttori, liste per canale/nick, conversazioni silenziate con durata, suono e `/beep`; sopra il campo messaggio una barra mostra la silenziazione della conversazione aperta (con orario locale e tempo residuo) e un pulsante per toglierla subito. Grappa conserva solo la scadenza, non l'ora della silenziazione: quella vive in `settings.json` di questo dispositivo, quindi le silenziazioni fatte altrove mostrano la barra senza orario. Non verificato contro un server reale | | Upload | Fatto | Graffetta, trascinamento file (non su Wayland), incolla immagini, testo lungo come `paste.txt` | -| Link e visualizzatore media | Fatto | Link cliccabili come il linkify di Cicchetto; immagini e testo nel visualizzatore, audio nel player, il resto nel browser; nessuna anteprima inline, come Cicchetto | +| Link e visualizzatore media | Fatto | Link cliccabili come il linkify di Cicchetto; immagini e testo nel visualizzatore, audio nel player, il resto nel browser; nessuna anteprima inline, come Cicchetto. Si aprono solo link http, https e ftp, senza utente/password né spazi o caratteri di controllo, e al browser va l'URL normalizzato (niente `file:`, `javascript:`, `mailto:`). Il download per il visualizzatore è solo http/https e segue al massimo 5 redirect e rifiuta il passaggio da https a http o da un host pubblico a un indirizzo locale (loopback, rete privata, link-local; solo IP letterali e `localhost`, nessuna risoluzione DNS) | | Radio | Fatto | Stazioni di Cicchetto più stazioni personalizzate salvate solo in locale; MP3, Ogg Vorbis, FLAC; `/np` | | Comandi | Fatto | `/np`, `/beep`, vista di `/umode` senza argomenti | | Chat e roster | Fatto | mIRC multicolore a capo, MODE secondo PREFIX/CHANMODES, DM prima dello snapshot recuperati, evidenziazione delle menzioni |