mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
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:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user