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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
This commit is contained in:
@@ -5,6 +5,7 @@ import {
|
|||||||
DECORATION_CATEGORIES,
|
DECORATION_CATEGORIES,
|
||||||
ALL_DECORATIONS,
|
ALL_DECORATIONS,
|
||||||
decorationUrl,
|
decorationUrl,
|
||||||
|
isValidDecorationSlug,
|
||||||
} from './avatarDecorations';
|
} from './avatarDecorations';
|
||||||
|
|
||||||
test('decorationUrl builds a CDN png url from the slug', () => {
|
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}`);
|
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);
|
||||||
|
});
|
||||||
|
|||||||
@@ -188,6 +188,19 @@ export const ALL_DECORATIONS: AvatarDecoration[] = DECORATION_CATEGORIES.flatMap
|
|||||||
(c) => c.decorations,
|
(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 {
|
export function decorationUrl(slug: string): string {
|
||||||
return `${RESOLVED_DECORATION_CDN}/${slug}.png`;
|
return `${RESOLVED_DECORATION_CDN}/${slug}.png`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { MatrixError, Method } from 'matrix-js-sdk';
|
import { MatrixError, Method } from 'matrix-js-sdk';
|
||||||
import { useMatrixClient } from './useMatrixClient';
|
import { useMatrixClient } from './useMatrixClient';
|
||||||
|
import { isValidDecorationSlug } from '../features/lotus/avatarDecorations';
|
||||||
|
|
||||||
const PROFILE_FIELD = 'io.lotus.avatar_decoration';
|
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.
|
// all fields (incl. custom MSC4133 ones); read the decoration out of it.
|
||||||
return authedRequest(Method.Get, `/profile/${encodeURIComponent(userId)}`)
|
return authedRequest(Method.Get, `/profile/${encodeURIComponent(userId)}`)
|
||||||
.then((res) => {
|
.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);
|
cache.set(userId, val);
|
||||||
return val;
|
return val;
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user