fix(security): add server-side URL scheme validation for roaster homepage

Reject non-http(s) URL schemes (javascript:, data:, etc.) in both
NewRoaster::normalize() and new UpdateRoaster::normalize(). The HTML
input type="url" provides browser-side validation, but the API path
had no server-side check.
This commit is contained in:
Jon Seager 2026-02-06 17:52:15 +00:00
parent 1f2ec0fc14
commit 830d424297
No known key found for this signature in database
2 changed files with 27 additions and 1 deletions

View file

@ -96,6 +96,7 @@ pub(crate) async fn update_roaster(
Path(id): Path<RoasterId>, Path(id): Path<RoasterId>,
Json(payload): Json<UpdateRoaster>, Json(payload): Json<UpdateRoaster>,
) -> Result<Json<Roaster>, ApiError> { ) -> Result<Json<Roaster>, ApiError> {
let payload = payload.normalize();
let has_changes = payload.name.is_some() let has_changes = payload.name.is_some()
|| payload.country.is_some() || payload.country.is_some()
|| payload.city.is_some() || payload.city.is_some()

View file

@ -29,7 +29,8 @@ impl NewRoaster {
self.name = self.name.trim().to_string(); self.name = self.name.trim().to_string();
self.country = self.country.trim().to_string(); self.country = self.country.trim().to_string();
self.city = normalize_optional_field(self.city); self.city = normalize_optional_field(self.city);
self.homepage = normalize_optional_field(self.homepage); self.homepage =
normalize_optional_field(self.homepage).filter(|url| is_valid_url_scheme(url));
self self
} }
@ -53,6 +54,13 @@ fn normalize_optional_field(value: Option<String>) -> Option<String> {
}) })
} }
/// Returns `true` if the URL starts with `http://` or `https://`.
/// Rejects `javascript:`, `data:`, and other potentially dangerous schemes.
fn is_valid_url_scheme(url: &str) -> bool {
let lower = url.trim().to_lowercase();
lower.starts_with("http://") || lower.starts_with("https://")
}
impl Roaster { impl Roaster {
pub fn to_timeline_event(&self) -> NewTimelineEvent { pub fn to_timeline_event(&self) -> NewTimelineEvent {
let mut details = vec![TimelineEventDetail { let mut details = vec![TimelineEventDetail {
@ -88,6 +96,23 @@ pub struct UpdateRoaster {
pub homepage: Option<String>, pub homepage: Option<String>,
} }
impl UpdateRoaster {
pub fn normalize(mut self) -> Self {
self.homepage = self
.homepage
.and_then(|h| {
let trimmed = h.trim().to_string();
if trimmed.is_empty() {
None
} else {
Some(trimmed)
}
})
.filter(|url| is_valid_url_scheme(url));
self
}
}
#[derive(Debug, Clone, Copy, Eq, PartialEq)] #[derive(Debug, Clone, Copy, Eq, PartialEq)]
pub enum RoasterSortKey { pub enum RoasterSortKey {
CreatedAt, CreatedAt,