Security: refuse mailto attachments, show link hosts in punycode (follow-up to #104) (#337)

* Security: refuse email links that attach or insert a local file

Follow-up to #104. A document's mailto: link could ask the email app to
attach a file from the user's computer (Evolution, KMail and Claws Mail
honour attach=, Evolution and KMail also attachment=) or read one into
the body (Claws Mail's insert=).

document_url now refuses a mailto: address in which any word starting
with attach or insert (any case) is followed by =, wherever it stands
and after each layer of percent-decoding. It also refuses text that
some email tools decode before reading the address: RFC 2047 encoded
words, which KMail decodes across the whole address, and backslash
escapes, which xdg-email's echo decodes. The link is refused rather
than rewritten: PdfCraft doesn't change what a document asks to open,
and the email apps' parsers disagree with one another.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Security: show a link's host as the browser receives it, in punycode

Follow-up to #104. The link prompt's host now comes from
links::display_host, which reads it with url::Url: the webbrowser crate
hands the OS that crate's serialisation, and the web build's browser
parses by the same WHATWG standard. International hosts are shown in
punycode with a note, a label mixing writing systems (UTS #39 resolved
script sets) gets a red warning, numeric hosts such as 3232235777 show
as 192.168.1.1, and a host the browser would reject is refused as
malformed. The Unicode form isn't repeated in the prompt, so a
whole-script lookalike can't read as the real site.

url, idna and icu_properties become direct dependencies of
pdfcraft-engine; all three were already in the graph.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Security: refuse document addresses the URL parser rejects

Follow-up to #104. egui opens links through the webbrowser crate, which
parses the address with url::Url and, when that fails, treats it as a
local file path and opens file:///<cwd>/<address> in the browser.
document_url accepted addresses that don't parse, such as
mailto://a^b/x or https://a^b/ (a forbidden host code point), so a
document could still get the browser to open a local path.

document_url now also requires url::Url to accept the address with the
same scheme, which is exactly the parse webbrowser does first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
jcl-bit 2026-10-08 13:07:25 -07:00 committed by GitHub
parent 130b7ea06c
commit bb8fcd006e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 439 additions and 14 deletions

View File

@ -19,7 +19,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Simplified Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "cac5e47a10ff49a6d41d87869fda1760778b533319f8ccec2b4e8e67b8a8e0dd"
sha256 = "d55df478f60a8a8185cdbc428d7f1c276b7ab21a8a4024faf0e978387fd3189e"
[[asset]]
path = "crates/ui-egui/src/i18n/zh-hant.tsv"
@ -63,7 +63,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Spanish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "6d2da5a55e6eb10e26efbdd4d34e48be30c2cad8e3f43e1fe811d8ce95dd0a19"
sha256 = "51982e7f71c598aa1fd23f1883cc84ee69ca15b4cd31671015328fb18e187e0b"
[[asset]]
path = "crates/ui-egui/src/i18n/fr.tsv"
@ -74,7 +74,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "French UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "2be55ac9d1303fea2d61f3f6c594cf5003e2b2aa405eefddffed2ea158a94b5e"
sha256 = "41b46b0540aead4c11b4a30b72758f6fa90888a544c18108a8c55d3682e1da2a"
[[asset]]
path = "crates/ui-egui/src/i18n/ja.tsv"

3
Cargo.lock generated
View File

@ -3573,6 +3573,8 @@ name = "pdfcraft-engine"
version = "0.4.0"
dependencies = [
"chrono",
"icu_properties",
"idna",
"image",
"pdfcraft-a11y",
"pdfcraft-annot",
@ -3598,6 +3600,7 @@ dependencies = [
"pdfcraft-xfdf",
"png 0.18.1",
"thiserror 2.0.21",
"url",
]
[[package]]

View File

@ -34,6 +34,11 @@ pdfcraft-create = { workspace = true }
pdfcraft-model = { workspace = true }
thiserror = { workspace = true }
png = "0.18"
# Already in the graph (url and idna through webbrowser, icu_properties through boa): they show
# the host of a document's link the way the browser reads it (links::display_host).
url = "2.5"
idna = "1.1"
icu_properties = "2.3"
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
# Already in the graph through lopdf; used only for the local UTC offset ("today").

View File

@ -1,6 +1,8 @@
//! Where PdfCraft and the ArtCraft community live on the web. One table, so the Help menu, the
//! About dialog, the home screen, the CLI and the README agree.
use icu_properties::props::Script;
/// The app's name in ArtCraft URLs (`getartcraft.com/apps/{APP}`, `github.com/storytold/{APP}`).
pub const APP: &str = "pdfcraft";
@ -53,6 +55,13 @@ pub enum BlockedLink {
Malformed,
/// A scheme other than [`DOCUMENT_SCHEMES`] (lowercased).
Scheme(String),
/// An email address asking the email app to attach a local file (`attach=`, `attachment=`)
/// or read one into the message (`insert=`).
MailFile,
/// An email address containing text that some email tools decode before reading the address,
/// so it could spell `attach`: an RFC 2047 encoded word (`=?utf-8?q?…?=`) or a backslash
/// escape (`\0141`, `\x61`).
MailEncodedWord,
}
impl std::fmt::Display for BlockedLink {
@ -63,6 +72,8 @@ impl std::fmt::Display for BlockedLink {
Self::Hidden => write!(f, "the address contains hidden or invisible characters"),
Self::Malformed => write!(f, "the address isn't a complete web or email address"),
Self::Scheme(s) => write!(f, "it is a “{s}:” address"),
Self::MailFile => write!(f, "it asks your email app to attach or insert a file from your computer"),
Self::MailEncodedWord => write!(f, "it contains encoded text that some email apps turn into other instructions"),
}
}
}
@ -103,14 +114,106 @@ pub fn document_url(raw: &str) -> Result<String, BlockedLink> {
}
let complete = match scheme.as_str() {
"mailto" => !rest.is_empty(),
_ => authority(url).is_some_and(|a| !a.contains(' ')) && host(url).is_some(),
_ => authority(url).is_some_and(|a| !a.contains(' ')) && display_host(url).is_some(),
};
if !complete {
// The desktop app opens addresses through the webbrowser crate, which treats one the WHATWG URL
// parser rejects as a local file path and opens that instead (`mailto://a^b/` would become
// `file:///…/mailto:/a^b/`). So only an address the parser accepts, as the same scheme, goes on.
if !complete || !url::Url::parse(url).is_ok_and(|u| u.scheme() == scheme) {
return Err(BlockedLink::Malformed);
}
if scheme == "mailto" {
check_mail_parameters(rest)?;
}
Ok(url.to_string())
}
/// Email-address parameters that make an email app read a file from the user's computer: Evolution,
/// KMail and Claws Mail attach the file named by `attach` (Evolution and KMail also by
/// `attachment`), and Claws Mail reads an `insert` file into the message body. Names are matched as
/// prefixes, so `attachments` is refused too.
const MAIL_FILE_PARAMETERS: &[&str] = &["attach", "insert"];
/// How many layers of percent-encoding are looked through. Email apps decode once at most.
const MAIL_DECODE_LAYERS: usize = 8;
/// Refuse an email address (what follows `mailto:`) that asks for a local file. A document can't
/// need this, and a drafted message carrying the user's files is one click from being sent.
///
/// Email apps disagree on how they read the address, so this doesn't try to parse it the way any
/// one of them does. Every email app reads a parameter as `name=value`, so it refuses any word
/// that starts with one of [`MAIL_FILE_PARAMETERS`] (in any case) and is followed by `=`, wherever
/// it stands: after `?`, `&`, `#` or `;` (KMail looks for the first `?` anywhere and reads `&#38;`
/// as `&`), with no `?` at all (xdg-email reads `mailto:a@example.org&attach=…` as a parameter) or
/// after a comma (xdg-email copies the subject into Thunderbird's comma-separated `-compose`
/// argument). The check is repeated after each layer of percent-decoding (KMail decodes names, so
/// `%61ttach` is `attach`). A word without `=` (`Please attach the invoice`) is fine.
///
/// The address is refused, not stripped of the parameter: PdfCraft never rewrites what a document
/// asks to open, and a stripped address could still be read differently by some email app.
fn check_mail_parameters(rest: &str) -> Result<(), BlockedLink> {
let mut text = rest.to_string();
for _ in 0..MAIL_DECODE_LAYERS {
check_mail_layer(&text)?;
let decoded = String::from_utf8_lossy(&percent_decode(text.as_bytes())).into_owned();
if decoded == text {
return Ok(());
}
text = decoded;
}
check_mail_layer(&text)
}
/// One layer of [`check_mail_parameters`].
fn check_mail_layer(text: &str) -> Result<(), BlockedLink> {
// KMail decodes RFC 2047 encoded words (`=?utf-8?q?attach?=`) across the whole address before
// reading it, and its decoder is lenient, so any `=?` followed later by `?=` is refused.
if text.find("=?").is_some_and(|i| text.get(i + 2..).is_some_and(|after| after.contains("?="))) {
return Err(BlockedLink::MailEncodedWord);
}
// xdg-email passes the address through `echo`, which turns `\0141` (octal) or `\x61` into `a`.
if text.split('\\').skip(1).any(|after| after.starts_with(|c: char| c.is_ascii_digit() || matches!(c, 'x' | 'X'))) {
return Err(BlockedLink::MailEncodedWord);
}
let lower = text.to_ascii_lowercase();
let is_word = |c: char| c.is_ascii_alphanumeric() || matches!(c, '_' | '-');
let mut previous = None;
for (i, c) in lower.char_indices() {
let starts_word = is_word(c) && !previous.is_some_and(is_word);
previous = Some(c);
if !starts_word {
continue;
}
let Some(word) = lower.get(i..) else { continue };
let name_end = word.find(|c: char| !is_word(c)).unwrap_or(word.len());
let (name, after) = word.split_at(name_end);
let asks = MAIL_FILE_PARAMETERS.iter().any(|p| name.starts_with(p));
if asks && after.trim_start_matches([' ', '\t', '+', '\'', '"']).starts_with('=') {
return Err(BlockedLink::MailFile);
}
}
Ok(())
}
/// Decode `%XX` escapes. A `%` not followed by two hex digits is kept as it is.
fn percent_decode(s: &[u8]) -> Vec<u8> {
let hex = |b: Option<&u8>| b.and_then(|b| char::from(*b).to_digit(16)).and_then(|d| u8::try_from(d).ok());
let mut out = Vec::with_capacity(s.len());
let mut i = 0;
while let Some(&b) = s.get(i) {
if b == b'%'
&& let (Some(high), Some(low)) = (hex(s.get(i + 1)), hex(s.get(i + 2)))
{
out.push((high << 4) | low);
i += 3;
} else {
out.push(b);
i += 1;
}
}
out
}
/// The authority of a web address: what follows `//`, up to the first `/`, `\`, `?` or `#`.
/// Browsers read `\` as `/` in web addresses (the WHATWG URL standard), so it ends the authority
/// too; otherwise `https://other.example\@trusted.example/` would seem to go to
@ -138,6 +241,102 @@ pub fn host(url: &str) -> Option<&str> {
(!host.is_empty()).then_some(host)
}
/// How to show a web address's host so that a lookalike can't pass for a familiar site.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct HostDisplay {
/// The host the browser connects to: lowercase ASCII, with international labels in punycode
/// (`xn--…`) and numeric addresses in their usual form (`192.168.1.1`, `[::1]`). Show this one,
/// not the document's spelling.
pub ascii: String,
/// The host has a label in another alphabet (`xn--…`), whose letters can look like familiar
/// ones.
pub international: bool,
/// A label mixes writing systems, such as the Cyrillic `а` in `pаypal.com`: a common way to
/// imitate another site's name.
pub mixed_scripts: bool,
}
/// The host of a web address as the browser will see it (see [`HostDisplay`]). On the desktop the
/// address is handed to the operating system as the `url` crate (the WHATWG URL standard)
/// serialises it, and in the browser build the browser parses it by the same standard, so this
/// reads the host with that crate: percent-decoded, IDNA-mapped (case, fullwidth letters, `。`) and
/// with numeric hosts such as `0x7f.1` resolved. `None` for a non-web address, one without a host
/// (see [`host`]), or one the browser would reject.
pub fn display_host(url: &str) -> Option<HostDisplay> {
host(url)?;
let parsed = url::Url::parse(url).ok()?;
let ascii = match parsed.host()? {
url::Host::Domain(d) => d.to_string(),
url::Host::Ipv4(a) => a.to_string(),
url::Host::Ipv6(a) => format!("[{a}]"),
};
if !ascii.split('.').any(|label| label.starts_with("xn--")) {
return Some(HostDisplay { ascii, international: false, mixed_scripts: false });
}
let (unicode, valid) = idna::domain_to_unicode(&ascii);
let mixed_scripts = valid.is_err() || unicode.split('.').any(mixes_scripts);
Some(HostDisplay { ascii, international: true, mixed_scripts })
}
/// A writing system for comparing the characters of a label. Han characters are written with
/// Japanese, Korean and Chinese ones, so those three combinations count as one system each
/// (Unicode TR 39's augmented script sets).
#[derive(Clone, Copy, PartialEq, Eq)]
enum Writing {
Script(Script),
Japanese,
Korean,
Chinese,
}
/// The writing systems a character belongs to, or `None` for one shared by all (digits, `-`,
/// combining marks).
fn writing_systems(c: char) -> Option<Vec<Writing>> {
let scripts = icu_properties::script::ScriptWithExtensions::new().get_script_extensions_val(c);
let mut out = Vec::new();
for s in scripts.iter() {
if s == Script::Common || s == Script::Inherited {
return None;
}
out.push(Writing::Script(s));
match s {
Script::Han => out.extend([Writing::Japanese, Writing::Korean, Writing::Chinese]),
Script::Hiragana | Script::Katakana => out.push(Writing::Japanese),
Script::Hangul => out.push(Writing::Korean),
Script::Bopomofo => out.push(Writing::Chinese),
_ => {}
}
}
Some(out)
}
/// The writing systems every character of `label` belongs to (Unicode TR 39's resolved script
/// set), skipping characters that `skip` picks. `None` when no character narrows it down.
fn shared_writing(label: &str, skip: impl Fn(&[Writing]) -> bool) -> Option<Vec<Writing>> {
let mut shared: Option<Vec<Writing>> = None;
for systems in label.chars().filter_map(writing_systems) {
if skip(&systems) {
continue;
}
shared = Some(match shared {
None => systems,
Some(s) => s.into_iter().filter(|w| systems.contains(w)).collect(),
});
}
shared
}
/// Whether a label mixes writing systems. Latin alongside Japanese, Korean or Chinese is allowed, as
/// in Unicode TR 39's "highly restrictive" level (`ソニーstore`).
fn mixes_scripts(label: &str) -> bool {
if shared_writing(label, |_| false).is_none_or(|s| !s.is_empty()) {
return false;
}
let latin = Writing::Script(Script::Latin);
let rest = shared_writing(label, |s| s.contains(&latin)).unwrap_or_default();
!rest.iter().any(|w| matches!(w, Writing::Japanese | Writing::Korean | Writing::Chinese))
}
#[cfg(test)]
mod tests {
#[test]
@ -223,4 +422,136 @@ mod tests {
assert_eq!(host("mailto:a@example.org"), None);
assert_eq!(host("https:///nohost"), None);
}
#[test]
fn addresses_the_url_parser_rejects_are_refused() {
use super::{BlockedLink as B, document_url};
// The webbrowser crate would open each of these as a local file path.
for bad in ["mailto://a^b/x", "mailto://a:99999/", "mailto://[x]/", "mailto://a\\b/", "mailto://a b/", "https://a^b/", "http://a<b/"] {
assert_eq!(document_url(bad), Err(B::Malformed), "{bad}");
}
for ok in ["mailto://example.org/x", "mailto:a@example.org?subject=a b"] {
assert_eq!(document_url(ok).as_deref(), Ok(ok), "{ok}");
}
}
#[test]
fn email_links_may_not_ask_for_a_local_file() {
use super::{BlockedLink as B, document_url};
for bad in [
// Evolution, KMail and Claws Mail attach the named file.
"mailto:a@example.org?attach=/home/me/.ssh/id_ed25519",
"mailto:a@example.org?subject=Hi&attachment=file:///etc/passwd",
// Claws Mail reads an `insert` file into the message body.
"mailto:a@example.org?insert=/home/me/notes.txt",
// Names are matched case-insensitively (Evolution, KMail) and after percent-decoding (KMail).
"mailto:a@example.org?ATTACH=x",
"mailto:a@example.org?Attachment=x",
"mailto:a@example.org?%61ttach=x",
"mailto:a@example.org?%2561ttach=x",
"mailto:a@example.org?%49NSERT=x",
"mailto:a@example.org?attach%3D/etc/passwd",
// An empty value, a repeated name, spaces or quotes before `=`.
"mailto:a@example.org?attach=",
"mailto:a@example.org?subject=a&attach=x&attach=y",
"mailto:a@example.org?subject=a& attach =x",
"mailto:a@example.org?subject=a&'attachment'='x'",
// No recipient, no `?` at all (xdg-email), a second `?`, and a `?` that only exists once decoded.
"mailto:?attach=x",
"mailto:a@example.org&attach=/etc/passwd",
"mailto:a@example.org?subject=a?attach=x",
"mailto:a@example.org%3Fattach=x",
// KMail reads the query from the first `?` even inside a fragment, and reads `&#38;` as `&`.
"mailto:a@example.org#?attach=x",
"mailto:a@example.org?subject=a#&attach=x",
"mailto:a@example.org?subject=a&#38;attach=x",
"mailto:a@example.org?subject=a;attach=x",
// An encoded `&`, and a comma (xdg-email copies the subject into a comma-separated list).
"mailto:a@example.org?subject=a%26attach=x",
"mailto:a@example.org?subject=a,attachment='/etc/passwd'",
// A `+`, an encoded tab or an encoded space between the name and `=`.
"mailto:a@example.org?attach+=x",
"mailto:a@example.org?attach%09=x",
"mailto:a@example.org?attach%20=x",
] {
assert_eq!(document_url(bad), Err(B::MailFile), "{bad}");
}
// KMail decodes RFC 2047 encoded words across the whole address before reading it, so
// `=?utf-8?q?attach?=` could become `attach`. Such words never belong in a link.
for bad in [
"mailto:a@example.org?=?utf-8?q?attach?==/etc/passwd",
"mailto:a@example.org?subject==?UTF-8?B?YXR0YWNo?=",
"mailto:a@example.org?subject=%3D%3Futf-8%3Fq%3Fattach%3F%3D",
"mailto:a@example.org?subject==??q?attach?=",
"mailto:a@example.org?subject==?utf-8?base64?YXR0YWNo?=",
// Backslash escapes that `echo` in xdg-email decodes, raw and percent-encoded.
"mailto:a@example.org?\\0141ttach=x",
"mailto:x\\x61ttachment=y",
"mailto:a@example.org?%5C0141ttach=x",
] {
assert_eq!(document_url(bad), Err(B::MailEncodedWord), "{bad}");
}
for ok in [
"mailto:orders@example.org?subject=Order form&body=Please attach the invoice",
"mailto:a@example.org?subject=Attachments%20to%20follow",
"mailto:a@example.org?body=Questions%3F%20Attach%20your%20CV",
"mailto:a@example.org?body=Hi%3B%20insert%20your%20name",
"mailto:a@example.org?cc=b@example.org&bcc=c@example.org&in-reply-to=%3Cid@example.org%3E",
"mailto:attach@example.org",
"mailto:a@example.org?attach",
"mailto:a@example.org?x-attach=1&reattach=2",
"mailto:a@example.org?body=Saved in C:\\Users\\me\\Documents",
] {
assert_eq!(document_url(ok).as_deref(), Ok(ok), "{ok}");
}
}
#[test]
fn hosts_are_shown_as_the_browser_receives_them() {
use super::{BlockedLink as B, HostDisplay, display_host, document_url};
let plain = |a: &str| Some(HostDisplay { ascii: a.into(), international: false, mixed_scripts: false });
assert_eq!(display_host("https://example.org/path"), plain("example.org"));
assert_eq!(display_host("HTTPS://EXAMPLE.ORG/"), plain("example.org"), "lowercased, as the browser does");
assert_eq!(display_host("https://example.org./"), plain("example.org."));
assert_eq!(display_host("https://ex%61mple.org/"), plain("example.org"), "percent-decoded, as the browser does");
assert_eq!(display_host("https://example。org/"), plain("example.org"), "fullwidth letters and `。` are mapped");
assert_eq!(display_host("https://trusted.example@other.example/"), plain("other.example"));
assert_eq!(display_host("https://other.example\\@trusted.example/"), plain("other.example"));
assert_eq!(display_host("mailto:a@example.org"), None);
// Numeric hosts in the form the browser connects to, so a local address can't hide.
assert_eq!(display_host("https://[::1]:8443/"), plain("[::1]"));
assert_eq!(display_host("https://[0:0:0:0:0:0:0:1]/"), plain("[::1]"));
assert_eq!(display_host("http://3232235777/"), plain("192.168.1.1"));
assert_eq!(display_host("http://0x7f.1/"), plain("127.0.0.1"));
assert_eq!(display_host("http://0300.0250.01.01/"), plain("192.168.1.1"));
// International hosts are shown in punycode and marked.
let bucher = display_host("https://bücher.example/").unwrap();
assert_eq!(bucher, HostDisplay { ascii: "xn--bcher-kva.example".into(), international: true, mixed_scripts: false });
assert!(!display_host("https://ソニーstore.example/").unwrap().mixed_scripts, "Latin with Japanese is normal (UTS #39)");
assert!(!display_host("https://example.ελ/").unwrap().mixed_scripts, "scripts are compared within a label, not across labels");
// Cyrillic `а` (U+0430) in a Latin name: punycode, and flagged as mixed.
let lookalike = display_host("https://p\u{0430}ypal.com/").unwrap();
assert_eq!(lookalike, HostDisplay { ascii: "xn--pypal-4ve.com".into(), international: true, mixed_scripts: true });
// The same lookalike written in punycode in the document, and upper-case.
assert_eq!(display_host("https://XN--PYPAL-4VE.com/"), Some(lookalike));
// A whole-script lookalike (all Cyrillic) isn't mixed, but is still shown in punycode, marked.
let cyrillic = display_host("https://\u{0430}\u{0440}\u{0440}\u{04CF}\u{0435}.com/").unwrap();
assert!(cyrillic.ascii.starts_with("xn--") && cyrillic.international && !cyrillic.mixed_scripts, "{cyrillic:?}");
// A host the browser can't turn into an address is refused rather than shown.
for bad in [
"https://xn--a.example/",
"https://exa%FFmple.org/",
"https://\u{0300}a.example/",
"https://%E2%80%AEexample.org/",
"https://trusted.example%40other.example/",
"https://[evil.example]/",
"https://example.org:port/",
"https://example.org:99999/",
] {
assert_eq!(display_host(bad), None, "{bad}");
assert_eq!(document_url(bad), Err(B::Malformed), "{bad}");
}
}
}

View File

@ -1423,8 +1423,18 @@ fn link_prompt(app: &mut PdfCraftApp, ctx: &egui::Context) {
};
ui.label(crate::i18n::fmt(template, &[("who", tl!(pending.origin.noun()))]));
ui.add_space(6.0);
if let Some(host) = pdfcraft_engine::links::host(&pending.url) {
ui.label(egui::RichText::new(host).font(theme::semibold(14.0)));
// The host as the browser will connect to it, in punycode when it is international, so a
// lookalike such as `pаypal.com` (Cyrillic `а`) reads as `xn--pypal-4ve.com`. Its Unicode
// form isn't repeated here: a whole-script lookalike would read as the real site.
if let Some(host) = pdfcraft_engine::links::display_host(&pending.url) {
ui.label(egui::RichText::new(&host.ascii).font(theme::semibold(14.0)));
if host.mixed_scripts {
let warning = tl!("This web address mixes letters from different alphabets, a common way to imitate another site's name.");
ui.add(egui::Label::new(egui::RichText::new(warning).color(egui::Color32::from_rgb(0xD1, 0x3B, 0x3B))).wrap());
} else if host.international {
let note = tl!("This web address uses letters from another alphabet, which can look like familiar ones.");
ui.add(egui::Label::new(egui::RichText::new(note).color(t.text)).wrap());
}
}
egui::ScrollArea::vertical().max_height(120.0).show(ui, |ui| {
ui.add(egui::Label::new(egui::RichText::new(&pending.url).monospace().small()).wrap().selectable(true));

View File

@ -1934,6 +1934,8 @@
Write this email? ¿Escribir este correo?
{who} in this document wants to open this address in your browser: {who} de este documento quiere abrir esta dirección en tu navegador:
{who} in this document wants to open your email app with this address: {who} de este documento quiere abrir tu aplicación de correo con esta dirección:
This web address mixes letters from different alphabets, a common way to imitate another site's name. Esta dirección web mezcla letras de distintos alfabetos, una forma habitual de imitar el nombre de otro sitio.
This web address uses letters from another alphabet, which can look like familiar ones. Esta dirección web usa letras de otro alfabeto, que pueden parecerse a letras conocidas.
Create PDF from images Crear PDF a partir de imágenes
Selected image files: {count} Archivos de imagen seleccionados: {count}
Use image resolution Usar la resolución de la imagen

Can't render this file because it contains an unexpected character in line 6 and column 60.

View File

@ -1877,6 +1877,8 @@
Write this email? Écrire ce courriel ?
{who} in this document wants to open this address in your browser: {who} dans ce document veut ouvrir cette adresse dans votre navigateur :
{who} in this document wants to open your email app with this address: {who} dans ce document veut ouvrir votre application de courrier avec cette adresse :
This web address mixes letters from different alphabets, a common way to imitate another site's name. Cette adresse web mélange des lettres d'alphabets différents, un moyen courant d'imiter le nom d'un autre site.
This web address uses letters from another alphabet, which can look like familiar ones. Cette adresse web utilise des lettres d'un autre alphabet, qui peuvent ressembler à des lettres familières.
Create PDF from images Créer un PDF à partir d'images
Selected image files: {count} Fichiers d'image sélectionnés : {count}
Use image resolution Utiliser la résolution de l'image

Can't render this file because it contains an unexpected character in line 6 and column 39.

View File

@ -1977,6 +1977,8 @@
Write this email? 是否撰写此邮件?
{who} in this document wants to open this address in your browser: 此文档中的{who}请求在浏览器中打开以下地址:
{who} in this document wants to open your email app with this address: 此文档中的{who}请求打开邮件应用并使用以下地址:
This web address mixes letters from different alphabets, a common way to imitate another site's name. 此网址混用了不同字母表的字母,这是仿冒其他网站名称的常见手法。
This web address uses letters from another alphabet, which can look like familiar ones. 此网址使用了其他字母表的字母,它们可能看起来像熟悉的字母。
CheckBox 复选框
Create link 创建链接
Duplicate field 复制字段

Can't render this file because it contains an unexpected character in line 9 and column 16.

View File

@ -971,10 +971,15 @@ impl PdfCraftApp {
self.pending_link = Some(PendingLink { url, origin });
}
}
Err(e) => self.notify_fmt(
"{who} in this document tried to open an address PdfCraft won't open: {e}. Only web (http, https) and email (mailto) links open from documents.",
&[("who", tl!(origin.noun())), ("e", &e.to_string())],
),
Err(e) => {
use pdfcraft_engine::links::BlockedLink;
let template = if matches!(e, BlockedLink::MailFile | BlockedLink::MailEncodedWord) {
"{who} in this document tried to open an address PdfCraft won't open: {e}."
} else {
"{who} in this document tried to open an address PdfCraft won't open: {e}. Only web (http, https) and email (mailto) links open from documents."
};
self.notify_fmt(template, &[("who", tl!(origin.noun())), ("e", &e.to_string())]);
}
}
}

View File

@ -156,7 +156,17 @@ fn a_script_asks_first_and_only_once() {
#[test]
fn a_script_cannot_open_other_kinds_of_address() {
let _gpu = gpu();
for url in ["file:///etc/passwd", "javascript:alert(1)", "ms-settings:privacy", "smb://server/share", "https://exa\u{202E}gro.elpmaxe"] {
// `mailto://a^b/x` and `https://a^b/` don't parse as URLs, so the browser would be handed them
// as local file paths.
for url in [
"file:///etc/passwd",
"javascript:alert(1)",
"ms-settings:privacy",
"smb://server/share",
"https://exa\u{202E}gro.elpmaxe",
"mailto://a^b/x",
"https://a^b/",
] {
let mut h = harness();
let id = h.state().views[0].id;
let script = format!("app.launchURL({});", serde_json::to_string(url).unwrap());
@ -167,3 +177,58 @@ fn a_script_cannot_open_other_kinds_of_address() {
assert!(h.state().toast.clone().is_some_and(|t| t.0.contains("won't open")), "{url}: {:?}", h.state().toast);
}
}
#[test]
fn an_email_link_cannot_attach_a_local_file() {
let _gpu = gpu();
for url in ["mailto:a@example.org?attach=/home/me/.ssh/id_ed25519", "mailto:a@example.org?subject=Hi&%61ttachment=C:/Users/me/x.txt"] {
let mut h = harness();
let id = h.state().views[0].id;
let script = format!("app.launchURL({});", serde_json::to_string(url).unwrap());
h.state_mut().run_button_script(id, "web", &script);
h.run_steps(3);
assert_eq!(h.state().pending_link, None, "{url}");
assert_eq!(h.state().last_opened_url, None, "{url}");
let toast = h.state().toast.clone().expect("the user is told").0;
assert!(toast.contains("attach or insert a file"), "{url}: {toast}");
}
}
/// Ask to open `url` from a script and return the harness with the prompt showing.
fn prompt_for(url: &str) -> Harness<'static, PdfCraftApp> {
let mut h = harness();
let id = h.state().views[0].id;
let script = format!("app.launchURL({});", serde_json::to_string(url).unwrap());
h.state_mut().run_button_script(id, "web", &script);
h.run_steps(3);
assert_eq!(h.state().pending_link, pending(url, LinkOrigin::Script));
h
}
#[test]
fn a_lookalike_host_is_shown_in_punycode_and_flagged() {
let _gpu = gpu();
// `pаypal.com` with a Cyrillic `а` (U+0430).
let h = prompt_for("https://p\u{0430}ypal.com/login");
h.get_by_label("xn--pypal-4ve.com");
assert_eq!(h.query_all_by_label_contains("p\u{0430}ypal.com").count(), 1, "only the full address shows the lookalike form");
h.get_by_label_contains("mixes letters from different alphabets");
}
#[test]
fn an_international_host_is_shown_in_punycode_and_marked() {
let _gpu = gpu();
let h = prompt_for("https://bücher.example/");
h.get_by_label("xn--bcher-kva.example");
assert_eq!(h.query_all_by_label_contains("bücher.example").count(), 1, "only the full address shows the Unicode form");
h.get_by_label_contains("letters from another alphabet");
assert!(h.query_by_label_contains("mixes letters").is_none());
}
#[test]
fn a_numeric_host_is_shown_as_the_address_it_goes_to() {
let _gpu = gpu();
let h = prompt_for("http://3232235777/admin");
h.get_by_label("192.168.1.1");
assert!(h.query_by_label_contains("alphabet").is_none());
}

View File

@ -2202,8 +2202,8 @@ milestone = "M7"
status = "shipped"
commands = ["edit.link"]
tools = ["link_add"]
evidence = ["crates/annot/src/tests.rs::links_are_added_edited_listed_and_removed", "crates/automation/tests/automation.rs::links_through_tools", "crates/ui-egui/tests/document_links.rs::a_link_asks_first_and_opens_when_allowed", "crates/ui-egui/tests/document_links.rs::a_button_uri_action_asks_first"]
notes = "Opening a document's link or button URI asks first; only http, https and mailto addresses are offered (#90, #91)."
evidence = ["crates/annot/src/tests.rs::links_are_added_edited_listed_and_removed", "crates/automation/tests/automation.rs::links_through_tools", "crates/ui-egui/tests/document_links.rs::a_link_asks_first_and_opens_when_allowed", "crates/ui-egui/tests/document_links.rs::a_button_uri_action_asks_first", "crates/ui-egui/tests/document_links.rs::an_email_link_cannot_attach_a_local_file", "crates/ui-egui/tests/document_links.rs::a_lookalike_host_is_shown_in_punycode_and_flagged"]
notes = "Opening a document's link or button URI asks first; only http, https and mailto addresses are offered (#90, #91), and an email address that asks the email app to attach or insert a local file is refused. The prompt shows the host as the browser receives it: international hosts in punycode, lookalikes that mix alphabets flagged, numeric hosts in their usual form."
[[feature]]
id = "edit.action-launch"