diff --git a/Client/tauri-client/src/components/DmProfileSidebar.ts b/Client/tauri-client/src/components/DmProfileSidebar.ts index a8f5e435..8db9fd18 100644 --- a/Client/tauri-client/src/components/DmProfileSidebar.ts +++ b/Client/tauri-client/src/components/DmProfileSidebar.ts @@ -13,6 +13,7 @@ import { createElement, appendChildren, setText } from "@lib/dom"; import type { MountableComponent } from "@lib/safe-render"; import type { UserStatus } from "@lib/types"; +import { isSafeUrl } from "./message-list/attachments"; // --------------------------------------------------------------------------- // Types @@ -114,7 +115,7 @@ export function createDmProfileSidebar( wrapper.style.position = "relative"; wrapper.style.flexShrink = "0"; - if (user.avatar !== null && user.avatar.length > 0) { + if (user.avatar !== null && user.avatar.length > 0 && isSafeUrl(user.avatar)) { wrapper.style.background = "transparent"; const img = createElement("img", { src: user.avatar, diff --git a/Client/tauri-client/src/components/DmSidebar.ts b/Client/tauri-client/src/components/DmSidebar.ts index 5beedaf5..2c25d267 100644 --- a/Client/tauri-client/src/components/DmSidebar.ts +++ b/Client/tauri-client/src/components/DmSidebar.ts @@ -11,6 +11,7 @@ import { createElement, setText, appendChildren } from "@lib/dom"; import { createIcon } from "@lib/icons"; import type { MountableComponent } from "@lib/safe-render"; +import { isSafeUrl } from "./message-list/attachments"; export interface DmConversation { readonly userId: number; @@ -59,7 +60,7 @@ function renderDmItem( const avatar = createElement("div", { class: "dm-avatar" }); avatar.style.background = avatarBg; - if (convo.avatar !== null) { + if (convo.avatar !== null && isSafeUrl(convo.avatar)) { const img = createElement("img", { src: convo.avatar, alt: convo.username, diff --git a/Client/tauri-client/src/components/UserProfilePopup.ts b/Client/tauri-client/src/components/UserProfilePopup.ts index 7c9944d0..6e635f2b 100644 --- a/Client/tauri-client/src/components/UserProfilePopup.ts +++ b/Client/tauri-client/src/components/UserProfilePopup.ts @@ -13,6 +13,7 @@ import { createElement, appendChildren } from "@lib/dom"; import { createIcon } from "@lib/icons"; import type { MountableComponent } from "@lib/safe-render"; import type { UserStatus } from "@lib/types"; +import { isSafeUrl } from "./message-list/attachments"; // --------------------------------------------------------------------------- // Types @@ -137,7 +138,7 @@ export function createUserProfilePopup( wrapper.style.background = "#4e5058"; const text = createElement("span", {}, "?"); wrapper.appendChild(text); - } else if (user.avatar !== null && user.avatar.length > 0) { + } else if (user.avatar !== null && user.avatar.length > 0 && isSafeUrl(user.avatar)) { const img = createElement("img", { src: user.avatar, alt: user.username, diff --git a/Server/api/constants.go b/Server/api/constants.go index e3d9be43..830fc1c3 100644 --- a/Server/api/constants.go +++ b/Server/api/constants.go @@ -65,6 +65,10 @@ const ( // per IP per minute. profilePasswordRateLimitPerMinute = 5 + // profileUpdateRateLimitPerMinute is the maximum profile update attempts + // per user per minute. + profileUpdateRateLimitPerMinute = 10 + // loginUserFailureThreshold is the number of failed login attempts for a // specific username (regardless of source IP) before the account is locked. loginUserFailureThreshold = 9 @@ -131,4 +135,7 @@ const ( // maxUploadFilenameLength is the maximum length of an upload filename // (filesystem-safe limit). maxUploadFilenameLength = 255 + + // maxAvatarURLLen is the maximum length of a user avatar URL. + maxAvatarURLLen = 512 ) diff --git a/Server/api/profile_handler.go b/Server/api/profile_handler.go index c810dd6e..13d0f260 100644 --- a/Server/api/profile_handler.go +++ b/Server/api/profile_handler.go @@ -6,6 +6,7 @@ import ( "fmt" "log/slog" "net/http" + "net/url" "strings" "time" @@ -57,7 +58,8 @@ func MountProfileRoutes(r chi.Router, database *db.DB, limiter *auth.RateLimiter r.Route("/api/v1/users/me", func(r chi.Router) { r.Use(AuthMiddleware(database)) - r.Patch("/", handleUpdateProfile(database, broadcaster)) + r.With(RateLimitMiddleware(limiter, profileUpdateRateLimitPerMinute, time.Minute, trustedProxies)). + Patch("/", handleUpdateProfile(database, broadcaster)) r.With(RateLimitMiddleware(limiter, profilePasswordRateLimitPerMinute, time.Minute, trustedProxies)). Put("/password", handleChangePassword(database, limiter)) @@ -67,6 +69,24 @@ func MountProfileRoutes(r chi.Router, database *db.DB, limiter *auth.RateLimiter }) } +// ─── Helpers ───────────────────────────────────────────────────────────────── + +// validateAvatarURL checks that avatar is either empty or a valid https:// URL +// no longer than maxAvatarURLLen characters. +func validateAvatarURL(avatar string) error { + if avatar == "" { + return nil + } + if len(avatar) > maxAvatarURLLen { + return fmt.Errorf("avatar URL too long (max %d characters)", maxAvatarURLLen) + } + parsed, err := url.Parse(avatar) + if err != nil || parsed.Scheme != "https" || parsed.Host == "" { + return fmt.Errorf("avatar URL must use https://") + } + return nil +} + // ─── Handlers ──────────────────────────────────────────────────────────────── // handleUpdateProfile processes PATCH /api/v1/users/me. @@ -108,9 +128,16 @@ func handleUpdateProfile(database *db.DB, broadcaster ProfileBroadcaster) http.H return } - // Sanitize avatar if provided. + // Sanitize and validate avatar if provided. if req.Avatar != nil { trimmed := strings.TrimSpace(sanitizer.Sanitize(*req.Avatar)) + if err := validateAvatarURL(trimmed); err != nil { + writeJSON(w, http.StatusBadRequest, errorResponse{ + Error: "INVALID_INPUT", + Message: err.Error(), + }) + return + } req.Avatar = &trimmed }