From f3119e3dc2b987c2546084390fd5e4a8d7bc79a5 Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Sat, 12 Sep 2026 14:48:35 -0400 Subject: [PATCH] fix(security): validate avatar-decoration slugs from remote profiles The MSC4133 io.lotus.avatar_decoration value was interpolated into the CDN URL verbatim, letting a room member steer the path/query of a request every viewer's browser makes. Accept only slugs present in the catalog; anything else is treated as no decoration. Unit-tested. Fixes #64 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA --- .../features/lotus/avatarDecorations.test.ts | 18 ++++++++++++++++++ src/app/features/lotus/avatarDecorations.ts | 13 +++++++++++++ src/app/hooks/useAvatarDecoration.ts | 6 +++++- 3 files changed, 36 insertions(+), 1 deletion(-) diff --git a/src/app/features/lotus/avatarDecorations.test.ts b/src/app/features/lotus/avatarDecorations.test.ts index 9dadb0dce..1d606ce33 100644 --- a/src/app/features/lotus/avatarDecorations.test.ts +++ b/src/app/features/lotus/avatarDecorations.test.ts @@ -5,6 +5,7 @@ import { DECORATION_CATEGORIES, ALL_DECORATIONS, decorationUrl, + isValidDecorationSlug, } from './avatarDecorations'; test('decorationUrl builds a CDN png url from the slug', () => { @@ -66,3 +67,20 @@ test('slugs use the snake_case charset (lowercase, digits, underscore)', () => { assert.match(decoration.slug, /^[a-z0-9_]+$/, `bad slug: ${decoration.slug}`); }); }); + +test('isValidDecorationSlug: accepts a real catalog slug', () => { + assert.equal(isValidDecorationSlug('joystick'), true); + assert.equal(isValidDecorationSlug('lotus_flower'), true); +}); + +test('isValidDecorationSlug: rejects a path-traversal string', () => { + assert.equal(isValidDecorationSlug('../../anything'), false); +}); + +test('isValidDecorationSlug: rejects a slug carrying a query string', () => { + assert.equal(isValidDecorationSlug('joystick?u=probe'), false); +}); + +test('isValidDecorationSlug: rejects an empty string', () => { + assert.equal(isValidDecorationSlug(''), false); +}); diff --git a/src/app/features/lotus/avatarDecorations.ts b/src/app/features/lotus/avatarDecorations.ts index c5f1bf153..34b23cc2d 100644 --- a/src/app/features/lotus/avatarDecorations.ts +++ b/src/app/features/lotus/avatarDecorations.ts @@ -188,6 +188,19 @@ export const ALL_DECORATIONS: AvatarDecoration[] = DECORATION_CATEGORIES.flatMap (c) => c.decorations, ); +const DECORATION_SLUGS = new Set(ALL_DECORATIONS.map((d) => d.slug)); + +/** + * Whether `slug` is a known catalog decoration. `io.lotus.avatar_decoration` + * is a free-form MSC4133 profile field set by a remote user (and their + * homeserver), and its value is interpolated verbatim into `decorationUrl` + * — so anything not in the catalog (path traversal, a query string, an + * oversized value) must be rejected before it reaches a URL. + */ +export function isValidDecorationSlug(slug: string): boolean { + return DECORATION_SLUGS.has(slug); +} + export function decorationUrl(slug: string): string { return `${RESOLVED_DECORATION_CDN}/${slug}.png`; } diff --git a/src/app/hooks/useAvatarDecoration.ts b/src/app/hooks/useAvatarDecoration.ts index 1c7c993bf..ed610cf76 100644 --- a/src/app/hooks/useAvatarDecoration.ts +++ b/src/app/hooks/useAvatarDecoration.ts @@ -1,6 +1,7 @@ import { useEffect, useState } from 'react'; import { MatrixError, Method } from 'matrix-js-sdk'; import { useMatrixClient } from './useMatrixClient'; +import { isValidDecorationSlug } from '../features/lotus/avatarDecorations'; const PROFILE_FIELD = 'io.lotus.avatar_decoration'; @@ -51,7 +52,10 @@ function fetchDecoration( // all fields (incl. custom MSC4133 ones); read the decoration out of it. return authedRequest(Method.Get, `/profile/${encodeURIComponent(userId)}`) .then((res) => { - const val = (res[PROFILE_FIELD] as string | undefined) ?? null; + const rawVal = (res[PROFILE_FIELD] as string | undefined) ?? null; + // The remote profile field is free-form and attacker-controlled; only + // accept it when it names a real catalog decoration (see decorationUrl). + const val = rawVal && isValidDecorationSlug(rawVal) ? rawVal : null; cache.set(userId, val); return val; })