security: validate avatar URLs on server and client

- Add validateAvatarURL helper enforcing https:// scheme, non-empty host, and 512-char max length
- Add rate limiting (10/min) to PATCH /api/v1/users/me profile update endpoint
- Guard avatar rendering in DmSidebar, DmProfileSidebar, and UserProfilePopup with isSafeUrl check to prevent unsafe URL injection in the UI
This commit is contained in:
J3vb
2026-04-03 08:10:26 +02:00
parent 5dbb89f237
commit 485040be32
5 changed files with 42 additions and 5 deletions
@@ -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,
@@ -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,
@@ -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,
+7
View File
@@ -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
)
+29 -2
View File
@@ -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
}