From e96c65e6c6cfce4ff4f4c1fa00e731efdfcf4ec9 Mon Sep 17 00:00:00 2001 From: Vasili Pascal Date: Fri, 30 Oct 2026 14:46:54 +0200 Subject: [PATCH] Vendor avatar is derived via Gravatar; website_url must be http(s) The marketplace vendor profile accepted a free-form avatar_url and served it verbatim on the public author page, so a vendor could put an arbitrary external image URL on a page other users visit. avatar_url is now always derived from the account email via Gravatar (d=identicon), the client value is ignored, and a migration NULLs non-Gravatar values already stored (the next profile save fills in the derived URL). website_url, rendered into an , now rejects non-http(s) schemes. --- Cargo.lock | 1 + Cargo.toml | 2 + ...9120000_null_non_gravatar_avatars.down.sql | 1 + ...029120000_null_non_gravatar_avatars.up.sql | 9 ++ src/helpers/gravatar.rs | 78 ++++++++++++ src/helpers/mod.rs | 1 + src/routes/marketplace/creator.rs | 14 ++- ...marketplace_creator_self_vendor_profile.rs | 111 +++++++++++++++++- 8 files changed, 212 insertions(+), 5 deletions(-) create mode 100644 migrations/20261029120000_null_non_gravatar_avatars.down.sql create mode 100644 migrations/20261029120000_null_non_gravatar_avatars.up.sql create mode 100644 src/helpers/gravatar.rs diff --git a/Cargo.lock b/Cargo.lock index 245377ac..051370db 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7085,6 +7085,7 @@ dependencies = [ "indicatif", "lapin", "lazy_static", + "md-5", "mockito", "pipe-adapter-mail", "pipe-adapter-sdk", diff --git a/Cargo.toml b/Cargo.toml index c626b551..b4e72372 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -72,6 +72,8 @@ tokio-stream = "0.1.14" actix-http = "3.4.0" hmac = "0.12.1" sha2 = "0.10.8" +# Gravatar avatar URLs for marketplace vendor profiles (helpers::gravatar). +md-5 = "0.10" # Constant-time comparison for credential digests (helpers::agent_token). subtle = "2.6" sqlx-adapter = { version = "1.8.0", default-features = false, features = ["postgres", "runtime-tokio-native-tls"]} diff --git a/migrations/20261029120000_null_non_gravatar_avatars.down.sql b/migrations/20261029120000_null_non_gravatar_avatars.down.sql new file mode 100644 index 00000000..13e3c8e8 --- /dev/null +++ b/migrations/20261029120000_null_non_gravatar_avatars.down.sql @@ -0,0 +1 @@ +-- Data cleanup migration: cleared external avatar URLs cannot be restored. diff --git a/migrations/20261029120000_null_non_gravatar_avatars.up.sql b/migrations/20261029120000_null_non_gravatar_avatars.up.sql new file mode 100644 index 00000000..a92a5796 --- /dev/null +++ b/migrations/20261029120000_null_non_gravatar_avatars.up.sql @@ -0,0 +1,9 @@ +-- Vendor avatars are now derived from the account email via Gravatar +-- (see helpers::gravatar and PATCH /mine/vendor-profile). Stored values +-- from before that change may point at arbitrary external hosts, which +-- must not reach public pages: clear every non-Gravatar avatar_url. The +-- next profile save repopulates the column with the derived Gravatar URL. +UPDATE marketplace_vendor_profile +SET avatar_url = NULL +WHERE avatar_url IS NOT NULL + AND avatar_url !~* '^https://([a-z0-9-]+\.)?gravatar\.com/'; diff --git a/src/helpers/gravatar.rs b/src/helpers/gravatar.rs new file mode 100644 index 00000000..d3f7cdf6 --- /dev/null +++ b/src/helpers/gravatar.rs @@ -0,0 +1,78 @@ +use md5::{Digest, Md5}; + +/// Gravatar avatar URL for a user's email, matching the platform convention in +/// the user service (`app/oauth2_server/views.py`): MD5 of the trimmed, +/// lowercased email. `d=identicon` serves a generated image when the email has +/// no registered Gravatar, so the URL never 404s. +pub fn gravatar_url(email: &str) -> String { + let hash = Md5::digest(email.trim().to_lowercase()); + format!("https://www.gravatar.com/avatar/{hash:x}?s=230&d=identicon&r=pg") +} + +/// Whether a URL is an https Gravatar avatar URL. Vendor profile avatars are +/// restricted to this host so user-supplied values can never introduce +/// arbitrary external images on public pages. +pub fn is_gravatar_url(url: &str) -> bool { + match url.strip_prefix("https://") { + Some(rest) => { + let host = rest.split(['/', '?', '#']).next().unwrap_or(""); + host.eq_ignore_ascii_case("gravatar.com") + || (host.len() > "gravatar.com".len() + && host.to_ascii_lowercase().ends_with(".gravatar.com")) + } + None => false, + } +} + +/// Whether a URL is a plain http(s) URL, rejecting `javascript:` and other +/// schemes that must never reach an `href` attribute. +pub fn is_http_url(url: &str) -> bool { + let lower = url.trim().to_ascii_lowercase(); + lower.starts_with("https://") || lower.starts_with("http://") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn gravatar_url_hashes_trimmed_lowercase_email() { + // md5("test@example.com") + assert_eq!( + gravatar_url(" Test@Example.COM "), + "https://www.gravatar.com/avatar/55502f40dc8b7c769880b10874abc9d0?s=230&d=identicon&r=pg" + ); + } + + #[test] + fn gravatar_url_accepts_any_email_shape() { + assert!(gravatar_url("").starts_with("https://www.gravatar.com/avatar/")); + } + + #[test] + fn is_gravatar_url_accepts_gravatar_hosts_only() { + assert!(is_gravatar_url("https://www.gravatar.com/avatar/abc?s=230")); + assert!(is_gravatar_url("https://gravatar.com/avatar/abc")); + assert!(is_gravatar_url("https://secure.gravatar.com/avatar/abc")); + assert!(!is_gravatar_url("http://www.gravatar.com/avatar/abc")); + assert!(!is_gravatar_url( + "https://evil.example/gravatar.com/avatar/abc" + )); + assert!(!is_gravatar_url("https://gravatar.com.evil.example/abc")); + assert!(!is_gravatar_url("https://example.com/avatar.png")); + assert!(!is_gravatar_url("javascript:alert(1)")); + assert!(!is_gravatar_url("data:image/svg+xml,")); + assert!(!is_gravatar_url("")); + } + + #[test] + fn is_http_url_rejects_non_http_schemes() { + assert!(is_http_url("https://example.com")); + assert!(is_http_url("http://example.com")); + assert!(!is_http_url("javascript:alert(1)")); + assert!(!is_http_url("data:text/html,