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,