Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
-- Data cleanup migration: cleared external avatar URLs cannot be restored.
9 changes: 9 additions & 0 deletions migrations/20261029120000_null_non_gravatar_avatars.up.sql
Original file line number Diff line number Diff line change
@@ -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/';
78 changes: 78 additions & 0 deletions src/helpers/gravatar.rs
Original file line number Diff line number Diff line change
@@ -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,<svg/>"));
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,<script>"));
assert!(!is_http_url("vbscript:msgbox(1)"));
assert!(!is_http_url("example.com"));
assert!(!is_http_url(""));
}
}
1 change: 1 addition & 0 deletions src/helpers/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ pub(crate) mod compressor;
pub mod dockerhub;
pub mod env_path;
pub mod fs;
pub mod gravatar;
pub(crate) mod ip;
pub mod stacker_labels;

Expand Down
14 changes: 12 additions & 2 deletions src/routes/marketplace/creator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1695,7 +1695,6 @@ pub struct UpdateVendorPublicProfileRequest {
pub public_slug: Option<String>,
pub display_name: Option<String>,
pub bio: Option<String>,
pub avatar_url: Option<String>,
pub website_url: Option<String>,
}

Expand All @@ -1722,13 +1721,24 @@ pub async fn update_vendor_public_profile_handler(
}
}

if let Some(ref website_url) = req.website_url {
if !website_url.is_empty() && !crate::helpers::gravatar::is_http_url(website_url) {
return Err(JsonResponse::<serde_json::Value>::build()
.bad_request("website_url must be an http(s) URL"));
}
}

// Avatar is always derived from the account email via Gravatar; the
// client cannot supply an arbitrary external image URL.
let avatar_url = crate::helpers::gravatar::gravatar_url(&user.email);

db::marketplace::update_vendor_public_profile(
pg_pool.get_ref(),
&user.id,
req.public_slug.as_deref(),
req.display_name.as_deref(),
req.bio.as_deref(),
req.avatar_url.as_deref(),
Some(&avatar_url),
req.website_url.as_deref(),
)
.await
Expand Down
111 changes: 108 additions & 3 deletions tests/marketplace_creator_self_vendor_profile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -198,8 +198,10 @@ async fn creator_patch_vendor_public_profile_creates_profile_when_missing() {
assert_eq!("acme-cloud", profile["public_slug"]);
assert_eq!("Acme Cloud", profile["display_name"]);
assert_eq!("We build stacks.", profile["bio"]);
// Avatar is derived from the account email via Gravatar; the
// client-supplied avatar_url must never be stored.
assert_eq!(
"https://acme-cloud.example.com/avatar.png",
"https://www.gravatar.com/avatar/55502f40dc8b7c769880b10874abc9d0?s=230&d=identicon&r=pg",
profile["avatar_url"]
);
assert_eq!("https://acme-cloud.example.com", profile["website_url"]);
Expand Down Expand Up @@ -228,7 +230,8 @@ async fn creator_patch_vendor_public_profile_updates_existing_profile() {
.await
.expect("Failed to seed vendor profile");

// Update only display_name and bio — slug and URLs should be preserved via COALESCE
// Update only display_name and bio — slug and website_url are preserved
// via COALESCE, while avatar_url is re-derived from the account email.
let response = reqwest::Client::new()
.patch(format!("{}/api/templates/mine/vendor-profile", app.address))
.bearer_auth(USER_TOKEN)
Expand Down Expand Up @@ -259,7 +262,11 @@ async fn creator_patch_vendor_public_profile_updates_existing_profile() {
assert_eq!("old-slug", profile["public_slug"]);
assert_eq!("New Name", profile["display_name"]);
assert_eq!("New bio", profile["bio"]);
assert_eq!("https://old.example.com/avatar.png", profile["avatar_url"]);
// The legacy external avatar is replaced with the derived Gravatar URL.
assert_eq!(
"https://www.gravatar.com/avatar/55502f40dc8b7c769880b10874abc9d0?s=230&d=identicon&r=pg",
profile["avatar_url"]
);
assert_eq!("https://old.example.com", profile["website_url"]);
}

Expand All @@ -283,6 +290,104 @@ async fn creator_patch_vendor_public_profile_rejects_invalid_slug() {
assert_eq!(StatusCode::BAD_REQUEST, response.status());
}

#[tokio::test]
async fn creator_patch_vendor_public_profile_ignores_client_supplied_avatar_url() {
let app = match common::spawn_app().await {
Some(app) => app,
None => return,
};

// Regression: the client-supplied avatar_url must never be stored or
// served — vendor avatars are always derived via Gravatar from the
// account email so public pages cannot load arbitrary external images.
let response = reqwest::Client::new()
.patch(format!("{}/api/templates/mine/vendor-profile", app.address))
.bearer_auth(USER_TOKEN)
.json(&json!({
"display_name": "Acme Cloud",
"avatar_url": "https://evil.example/track.png",
"public_slug": "acme-cloud"
}))
.send()
.await
.expect("Failed to PATCH vendor public profile");

assert_eq!(StatusCode::OK, response.status());

let get_response = reqwest::Client::new()
.get(format!("{}/api/templates/mine/vendor-profile", app.address))
.bearer_auth(USER_TOKEN)
.send()
.await
.expect("Failed to GET self vendor profile after PATCH");

let body: Value = get_response
.json()
.await
.expect("response should be valid JSON");

let profile = &body["item"]["vendor_profile"];
assert_eq!(
"https://www.gravatar.com/avatar/55502f40dc8b7c769880b10874abc9d0?s=230&d=identicon&r=pg",
profile["avatar_url"]
);

// The same must hold on the public vendor page.
let public_response = reqwest::Client::new()
.get(format!("{}/api/vendors/acme-cloud", app.address))
.send()
.await
.expect("Failed to GET public vendor profile");

assert_eq!(StatusCode::OK, public_response.status());

let public_body: Value = public_response
.json()
.await
.expect("public response should be valid JSON");

assert_eq!(
"https://www.gravatar.com/avatar/55502f40dc8b7c769880b10874abc9d0?s=230&d=identicon&r=pg",
public_body["item"]["vendor"]["avatar_url"]
);
}

#[tokio::test]
async fn creator_patch_vendor_public_profile_rejects_non_http_website_url() {
let app = match common::spawn_app().await {
Some(app) => app,
None => return,
};

// website_url is rendered into an <a href> on the public page, so only
// http(s) URLs may be stored.
for malicious in ["javascript:alert(1)", "data:text/html,<script>", "vbscript:msgbox(1)"] {
let response = reqwest::Client::new()
.patch(format!("{}/api/templates/mine/vendor-profile", app.address))
.bearer_auth(USER_TOKEN)
.json(&json!({
"website_url": malicious
}))
.send()
.await
.expect("Failed to PATCH vendor public profile");

assert_eq!(StatusCode::BAD_REQUEST, response.status(), "url: {malicious}");
}

let response = reqwest::Client::new()
.patch(format!("{}/api/templates/mine/vendor-profile", app.address))
.bearer_auth(USER_TOKEN)
.json(&json!({
"website_url": "https://acme-cloud.example.com"
}))
.send()
.await
.expect("Failed to PATCH vendor public profile");

assert_eq!(StatusCode::OK, response.status());
}

#[tokio::test]
async fn creator_patch_vendor_public_profile_requires_authentication() {
let app = match common::spawn_app().await {
Expand Down
Loading