From 61107848f8b2f2c6be683e038d972ad08e1626f0 Mon Sep 17 00:00:00 2001 From: openhands Date: Thu, 15 Oct 2026 18:00:05 +0200 Subject: [PATCH] fix(imaging): normalise avatar figures before building URLs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Legacy accounts can carry a malformed stored look — the old register default `hr-100-.hd-180-1...` has an empty colour slot — which the imager rejects with 400 "Invalid figure format", so the avatar renders broken everywhere the look appears. Normalise the figure in the shared URL builder (`getAvatarUrl`, used by every avatar on the site): an unrenderable look becomes `DEFAULT_AVATAR_FIGURE` while the proxy route keeps rejecting malformed input with 400. The rule set and default now live in one client-safe module (`src/lib/avatar-figure.ts`), shared by the URL builder and the server parser. --- README.md | 15 ++++++++++++-- src/actions/register.ts | 9 +++++---- src/lib/avatar-figure.test.ts | 34 +++++++++++++++++++++++++++++++ src/lib/avatar-figure.ts | 38 +++++++++++++++++++++++++++++++++++ src/lib/imager-upstream.ts | 15 +++++++------- src/lib/imager.test.ts | 6 ++++++ src/lib/imager.ts | 3 ++- 7 files changed, 106 insertions(+), 14 deletions(-) create mode 100644 src/lib/avatar-figure.test.ts create mode 100644 src/lib/avatar-figure.ts diff --git a/README.md b/README.md index 198459d3..547a88f2 100644 --- a/README.md +++ b/README.md @@ -639,9 +639,20 @@ A production issue that this section documents: **the `avatar-imaging-pixinode` Since then the CMS-side avatar proxies (`/api/imaging/avatar` and `/imaging`) are resilient: when the configured upstream (the Polaris container on `8082`) fails or times out, the request is retried against Habbo's public renderer (`https://www.habbo.com/habbo-imaging/avatarimage`), with the `effect` parameter stripped and `img_format` forced to `png`, since the public renderer supports neither. The response is then marked with `X-Imager-Source: primary|fallback` and a shorter `Cache-Control` TTL when served from fallback, so the configured imager is retried soon instead of being masked for hours. No fallback is attempted when the configured upstream already is the Habbo public renderer. As a last line of defence every avatar `` on the site falls back to a grayscale silhouette placeholder instead of a broken-image glyph. -### Figure string validation (`src/app/api/imaging/avatar/route.ts`) +### Figure string validation -The CMS-side route re-validates the incoming figure against `/^[a-z]{2}-\d+(-\d+)?(\.[a-z]{2}-\d+(-\d+)?)*$/i`. Real Habbo figure strings use `type-id-color` parts separated by dots (`hd-180-1.ch-210-66`), so each part allows an optional second number. Invalid input is rejected with `400` before any render happens. +The URL builder (`src/lib/imager.ts`) and the proxy routes share one rule set +(`src/lib/avatar-figure.ts`): a figure must look like `part-id` segments +separated by dots (`hd-180-1.ch-210-66`). Some clients append extra segments the +renderer still accepts (e.g. a trailing `.0`), so the pattern is deliberately +loose; over-strict validation once turned valid avatars into 400s. + +Before a URL is built, the stored look is normalised: anything the imager would +reject — an empty part slot such as the legacy `hr-100-.hd-180-1` default, a +path-traversal attempt, an over-long value — is replaced with +`DEFAULT_AVATAR_FIGURE`. That stops legacy accounts from rendering broken +avatars without weakening the route, which still answers `400` to a malformed +figure (`src/app/api/imaging/avatar/route.ts`). --- diff --git a/src/actions/register.ts b/src/actions/register.ts index 84268f19..366d6055 100644 --- a/src/actions/register.ts +++ b/src/actions/register.ts @@ -5,6 +5,7 @@ import { after } from "next/server"; import { z } from "zod"; import { sendVerification } from "@/lib/auth/email-verification"; import { hashPassword } from "@/lib/auth/password"; +import { DEFAULT_AVATAR_FIGURE } from "@/lib/avatar-figure"; import { invalidateKey } from "@/lib/cached-db"; import { db, User } from "@/lib/db"; import { logger } from "@/lib/logger"; @@ -169,10 +170,10 @@ const ZOD_MESSAGE_CODES: Record = { }; // A valid starter Habbo figure so the avatar renders in-client immediately. -// The hair part needs a colour slot (`hr-100-1`, not `hr-100-`) or the imager -// rejects the whole figure with 400 "Invalid figure format" and every avatar -// for the user renders broken. -const DEFAULT_LOOK = "hr-100-1.hd-180-1.ch-255-66.lg-280-110.sh-305-62"; +// Shared with the imager URL builder, which normalises malformed stored looks +// to the same figure (the hair part needs a colour slot: `hr-100-1`, not +// `hr-100-`). +const DEFAULT_LOOK = DEFAULT_AVATAR_FIGURE; export interface RegisterState { error: string | null; diff --git a/src/lib/avatar-figure.test.ts b/src/lib/avatar-figure.test.ts new file mode 100644 index 00000000..747f920c --- /dev/null +++ b/src/lib/avatar-figure.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { DEFAULT_AVATAR_FIGURE, normalizeAvatarFigure } from "./avatar-figure"; + +describe("normalizeAvatarFigure", () => { + it("keeps a figure the imager can draw", () => { + const figure = + "hr-11782-40-40.hd-180-7-14.ch-11592-66.lg-10726-79-1408.sh-11764-1408.ha-11958-70-1408.wa-2007-0.0"; + expect(normalizeAvatarFigure(figure)).toBe(figure); + }); + + it("trims surrounding whitespace", () => { + expect(normalizeAvatarFigure(" hd-180-1 ")).toBe("hd-180-1"); + }); + + it("replaces a stored look with an empty colour slot", () => { + // The legacy register default: `hr-100-` has no colour, so the imager + // rejects the whole figure with 400 and the avatar renders broken. + expect( + normalizeAvatarFigure("hr-100-.hd-180-1.ch-255-66.lg-280-110.sh-305-62"), + ).toBe(DEFAULT_AVATAR_FIGURE); + }); + + it.each([ + "", + " ", + "0", + "../bad", + "hd-180-1/../x", + `hd-180-${"x".repeat(600)}`, + `hd-180${".a-1".repeat(30)}`, + ])("replaces %j with the default", (value) => { + expect(normalizeAvatarFigure(value)).toBe(DEFAULT_AVATAR_FIGURE); + }); +}); diff --git a/src/lib/avatar-figure.ts b/src/lib/avatar-figure.ts new file mode 100644 index 00000000..d7883978 --- /dev/null +++ b/src/lib/avatar-figure.ts @@ -0,0 +1,38 @@ +/** + * Client-safe avatar figure rules, shared by the URL builder (`imager.ts`) and + * the server-side query parser (`imager-upstream.ts`). + * + * The imager is the authority on what it can draw: a figure with an empty part + * slot (e.g. `hr-100-.hd-180-1.ch-255-66`) is rejected with 400 "Invalid figure + * format". Legacy accounts can still carry such stored looks, so the URL + * builder normalises them to a valid default instead of emitting a request that + * renders as a broken image. + */ + +/** Valid starter figure — also the default look new accounts are given. */ +export const DEFAULT_AVATAR_FIGURE = + "hr-100-1.hd-180-1.ch-255-66.lg-280-110.sh-305-62"; + +/** + * The first segment must look like a real part (`hd-180`); the rest is + * deliberately loose because some clients append extra segments (e.g. a + * trailing `.0`) that the renderer still accepts. Over-strict validation here + * turned valid avatars into 400s. + */ +export const AVATAR_FIGURE_RE = /^[a-z]{2}-\d+(?:[.-][a-z0-9]+)*$/i; +export const AVATAR_FIGURE_MAX_LEN = 512; +export const AVATAR_FIGURE_MAX_PARTS = 24; + +/** + * Return `figure` unchanged when the imager can draw it, otherwise the default + * figure. Trims surrounding whitespace either way. + */ +export function normalizeAvatarFigure(figure: string): string { + const trimmed = figure.trim(); + if (!trimmed || trimmed.length > AVATAR_FIGURE_MAX_LEN) + return DEFAULT_AVATAR_FIGURE; + if (!AVATAR_FIGURE_RE.test(trimmed)) return DEFAULT_AVATAR_FIGURE; + if (trimmed.split(".").length > AVATAR_FIGURE_MAX_PARTS) + return DEFAULT_AVATAR_FIGURE; + return trimmed; +} diff --git a/src/lib/imager-upstream.ts b/src/lib/imager-upstream.ts index 2031b8a3..eba2f5d0 100644 --- a/src/lib/imager-upstream.ts +++ b/src/lib/imager-upstream.ts @@ -1,5 +1,10 @@ import "server-only"; +import { + AVATAR_FIGURE_MAX_LEN as FIGURE_MAX_LEN, + AVATAR_FIGURE_MAX_PARTS as FIGURE_MAX_PARTS, + AVATAR_FIGURE_RE as FIGURE_RE, +} from "@/lib/avatar-figure"; import { avatarCacheDir, imagingCacheKey, @@ -9,13 +14,9 @@ import { import { resolveImagerBase } from "@/lib/runtime-asset-config"; // The client's figure strings are passed straight through to the imager, which -// is the authority on what it can draw. Only the first segment is required to -// look like a real part (`hd-180`); the rest is deliberately loose because some -// clients append extra segments (e.g. a trailing `.0`) that the renderer still -// accepts. Over-strict validation here turned valid avatars into 400s. -export const FIGURE_RE = /^[a-z]{2}-\d+(?:[.-][a-z0-9]+)*$/i; -export const FIGURE_MAX_LEN = 512; -export const FIGURE_MAX_PARTS = 24; +// is the authority on what it can draw. The rules live in `avatar-figure.ts` so +// the URL builder applies the exact same ones; the parser only enforces them. +export { FIGURE_MAX_LEN, FIGURE_MAX_PARTS, FIGURE_RE }; const HABBO_PUBLIC_UPSTREAM = "https://www.habbo.com/habbo-imaging/avatarimage"; diff --git a/src/lib/imager.test.ts b/src/lib/imager.test.ts index e99ad5fd..e4b14c01 100644 --- a/src/lib/imager.test.ts +++ b/src/lib/imager.test.ts @@ -3,6 +3,7 @@ import { mkdtempSync, readdirSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { DEFAULT_AVATAR_FIGURE } from "./avatar-figure"; import { getAvatarUrl } from "./imager"; import { FALLBACK_TIMEOUT_MS, @@ -52,6 +53,11 @@ describe("getAvatarUrl", () => { expect(url).not.toContain("&x=1"); }); + it("falls back to the default when the stored look is malformed", () => { + const url = getAvatarUrl("hr-100-.hd-180-1.ch-255-66.lg-280-110.sh-305-62"); + expect(url).toContain(`figure=${DEFAULT_AVATAR_FIGURE}`); + }); + it("allows custom img_format", () => { const url = getAvatarUrl("hd-180-1", { img_format: "png" }); expect(url).toContain("img_format=png"); diff --git a/src/lib/imager.ts b/src/lib/imager.ts index 18ea68ce..beda8940 100644 --- a/src/lib/imager.ts +++ b/src/lib/imager.ts @@ -7,6 +7,7 @@ export type { AvatarOptions } from "@/types/admin"; import type { SyntheticEvent } from "react"; +import { normalizeAvatarFigure } from "@/lib/avatar-figure"; import type { AvatarOptions } from "@/types/admin"; /** @@ -46,7 +47,7 @@ export function getAvatarUrl( } = options; const params = new URLSearchParams({ - figure, + figure: normalizeAvatarFigure(figure), img_format, effect: String(effect), });