From 71920fc688b56f2fe73af3072543c3c9b2f09eb9 Mon Sep 17 00:00:00 2001 From: luizhf42 Date: Fri, 29 May 2026 11:40:40 -0300 Subject: [PATCH] fix(ui-react): encode list filters as UTF-8 base64 `btoa(JSON.stringify(...))` rejects characters above U+00FF, so any filter value containing non-Latin-1 text (tag names, hostnames, usernames, emails) throws `InvalidCharacterError` and the request never goes out. Route filter encoding through a shared `toBase64Json` helper that serializes via `Buffer.from(..., "utf-8")`. ASCII payloads encode to the same bytes as before, so the backend decode path (`base64.StdEncoding.DecodeString` then `json.Unmarshal`) needs no change. --- .../apps/console/src/hooks/useAdminDevices.ts | 9 ++-- .../console/src/hooks/useAdminNamespaces.ts | 3 +- .../apps/console/src/hooks/useAdminUsers.ts | 3 +- .../apps/console/src/hooks/useContainers.ts | 3 +- ui-react/apps/console/src/hooks/useDevices.ts | 3 +- .../apps/console/src/hooks/usePublicKeys.ts | 3 +- .../apps/console/src/hooks/useWebEndpoints.ts | 3 +- .../src/utils/__tests__/encoding.test.ts | 46 +++++++++++++++++++ ui-react/apps/console/src/utils/encoding.ts | 8 ++++ .../apps/console/src/utils/invitations.ts | 3 +- 10 files changed, 73 insertions(+), 11 deletions(-) create mode 100644 ui-react/apps/console/src/utils/__tests__/encoding.test.ts create mode 100644 ui-react/apps/console/src/utils/encoding.ts diff --git a/ui-react/apps/console/src/hooks/useAdminDevices.ts b/ui-react/apps/console/src/hooks/useAdminDevices.ts index 5e4a503dcf5..59145c4a492 100644 --- a/ui-react/apps/console/src/hooks/useAdminDevices.ts +++ b/ui-react/apps/console/src/hooks/useAdminDevices.ts @@ -13,6 +13,7 @@ import { import { paginatedQueryFn, type PaginatedResult } from "../api/pagination"; import { useAuthStore } from "../stores/authStore"; import { isSdkError } from "../api/errors"; +import { toBase64Json } from "@/utils/encoding"; export type NormalizedDevice = Omit & { tags: string[] }; @@ -21,9 +22,9 @@ function normalizeDevice(device: Device): NormalizedDevice { ...device, tags: Array.isArray(device.tags) ? device.tags.map((t) => { - if (typeof t === "object" && t !== null && "name" in t) return t.name; - return String(t); - }) + if (typeof t === "object" && t !== null && "name" in t) return t.name; + return String(t); + }) : [], }; } @@ -35,7 +36,7 @@ function buildNameFilter(search: string): string { params: { name: "name", operator: "contains", value: search }, }, ]; - return btoa(JSON.stringify(filter)); + return toBase64Json(filter); } interface UseAdminDevicesParams { diff --git a/ui-react/apps/console/src/hooks/useAdminNamespaces.ts b/ui-react/apps/console/src/hooks/useAdminNamespaces.ts index 909d778c8d2..a2473de37cc 100644 --- a/ui-react/apps/console/src/hooks/useAdminNamespaces.ts +++ b/ui-react/apps/console/src/hooks/useAdminNamespaces.ts @@ -11,6 +11,7 @@ import { import { paginatedQueryFn, type PaginatedResult } from "../api/pagination"; import { useAuthStore } from "../stores/authStore"; import { isSdkError } from "../api/errors"; +import { toBase64Json } from "@/utils/encoding"; function buildNameFilter(search: string): string { const filter = [ @@ -19,7 +20,7 @@ function buildNameFilter(search: string): string { params: { name: "name", operator: "contains", value: search }, }, ]; - return btoa(JSON.stringify(filter)); + return toBase64Json(filter); } interface UseAdminNamespacesParams { diff --git a/ui-react/apps/console/src/hooks/useAdminUsers.ts b/ui-react/apps/console/src/hooks/useAdminUsers.ts index bbe281dd814..1c094537718 100644 --- a/ui-react/apps/console/src/hooks/useAdminUsers.ts +++ b/ui-react/apps/console/src/hooks/useAdminUsers.ts @@ -11,6 +11,7 @@ import { import { paginatedQueryFn, type PaginatedResult } from "../api/pagination"; import { useAuthStore } from "../stores/authStore"; import { isSdkError } from "../api/errors"; +import { toBase64Json } from "@/utils/encoding"; function buildUsernameFilter(search: string): string { const filter = [ @@ -19,7 +20,7 @@ function buildUsernameFilter(search: string): string { params: { name: "username", operator: "contains", value: search }, }, ]; - return btoa(JSON.stringify(filter)); + return toBase64Json(filter); } interface UseAdminUsersParams { diff --git a/ui-react/apps/console/src/hooks/useContainers.ts b/ui-react/apps/console/src/hooks/useContainers.ts index 4249236fa13..da97042f66f 100644 --- a/ui-react/apps/console/src/hooks/useContainers.ts +++ b/ui-react/apps/console/src/hooks/useContainers.ts @@ -7,6 +7,7 @@ import { import type { Device, DeviceStatus } from "../client"; import { getContainersQueryKey } from "../client/@tanstack/react-query.gen"; import { paginatedQueryFn, type PaginatedResult } from "../api/pagination"; +import { toBase64Json } from "@/utils/encoding"; export type NormalizedContainer = Omit & { tags: string[] }; @@ -24,7 +25,7 @@ function buildFilter(search: string, tags: string[]): string { params: { name: "tags.name", operator: "contains", value: tags }, }); } - return btoa(JSON.stringify(filters)); + return toBase64Json(filters); } export function normalizeContainer(container: Device): NormalizedContainer { diff --git a/ui-react/apps/console/src/hooks/useDevices.ts b/ui-react/apps/console/src/hooks/useDevices.ts index 1e63db96bba..9bfd51c0166 100644 --- a/ui-react/apps/console/src/hooks/useDevices.ts +++ b/ui-react/apps/console/src/hooks/useDevices.ts @@ -8,6 +8,7 @@ import { import { getDevicesQueryKey } from "../client/@tanstack/react-query.gen"; import { paginatedQueryFn, type PaginatedResult } from "../api/pagination"; import type { Device as GeneratedDevice } from "../client"; +import { toBase64Json } from "@/utils/encoding"; export type NormalizedDevice = Omit & { tags: string[]; @@ -35,7 +36,7 @@ export function buildFilter(search: string, tags: string[]): string { params: { name: "tags.name", operator: "contains", value: tags }, }); } - return btoa(JSON.stringify(filters)); + return toBase64Json(filters); } export function normalizeDevice(device: GeneratedDevice): NormalizedDevice { diff --git a/ui-react/apps/console/src/hooks/usePublicKeys.ts b/ui-react/apps/console/src/hooks/usePublicKeys.ts index 0aa7d19abc0..627aad41301 100644 --- a/ui-react/apps/console/src/hooks/usePublicKeys.ts +++ b/ui-react/apps/console/src/hooks/usePublicKeys.ts @@ -7,6 +7,7 @@ import { } from "../client"; import { getPublicKeysQueryKey } from "../client/@tanstack/react-query.gen"; import { paginatedQueryFn, type PaginatedResult } from "../api/pagination"; +import { toBase64Json } from "@/utils/encoding"; export function buildPublicKeyFilter(search: string): string { const filters = [ @@ -21,7 +22,7 @@ export function buildPublicKeyFilter(search: string): string { params: { name: "fingerprint", operator: "contains", value: search }, }, ]; - return btoa(JSON.stringify(filters)); + return toBase64Json(filters); } interface UsePublicKeysParams { diff --git a/ui-react/apps/console/src/hooks/useWebEndpoints.ts b/ui-react/apps/console/src/hooks/useWebEndpoints.ts index 9616e7b2638..ea5976c9624 100644 --- a/ui-react/apps/console/src/hooks/useWebEndpoints.ts +++ b/ui-react/apps/console/src/hooks/useWebEndpoints.ts @@ -6,6 +6,7 @@ import { } from "../client"; import { listWebEndpointsQueryKey } from "../client/@tanstack/react-query.gen"; import { paginatedQueryFn, type PaginatedResult } from "../api/pagination"; +import { toBase64Json } from "@/utils/encoding"; interface UseWebEndpointsParams { page?: number; @@ -24,7 +25,7 @@ function encodeAddressFilter(value: string): string { params: { name: "address", operator: "contains", value }, }, ]; - return btoa(JSON.stringify(clauses)); + return toBase64Json(clauses); } export function useWebEndpoints({ diff --git a/ui-react/apps/console/src/utils/__tests__/encoding.test.ts b/ui-react/apps/console/src/utils/__tests__/encoding.test.ts new file mode 100644 index 00000000000..7bf6add7d95 --- /dev/null +++ b/ui-react/apps/console/src/utils/__tests__/encoding.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect } from "vitest"; +import { toBase64Json } from "@/utils/encoding"; + +describe("toBase64Json", () => { + it("matches btoa(JSON.stringify(...)) for ASCII payloads", () => { + // Backward-compat guard: the BE decoder only sees the bytes, so an ASCII + // payload must produce the exact same string the old `btoa` path produced. + const value = [ + { + type: "property", + params: { name: "name", operator: "contains", value: "qa-edge" }, + }, + ]; + expect(toBase64Json(value)).toBe(btoa(JSON.stringify(value))); + }); + + it("does not throw on non-Latin-1 characters", () => { + const value = [ + { + type: "property", + params: { + name: "tags.name", + operator: "contains", + value: "日本語タグ", + }, + }, + ]; + expect(() => toBase64Json(value)).not.toThrow(); + }); + + it("round-trips Unicode through base64 → UTF-8 → JSON", () => { + const value = { tag: "日本語タグ", emoji: "🚀", arabic: "سلام" }; + const encoded = toBase64Json(value); + const decoded = JSON.parse( + Buffer.from(encoded, "base64").toString("utf-8"), + ) as typeof value; + expect(decoded).toEqual(value); + }); + + it("produces stable output for the same input (used as a cache key)", () => { + const value = [ + { type: "property", params: { name: "x", operator: "eq", value: 1 } }, + ]; + expect(toBase64Json(value)).toBe(toBase64Json(value)); + }); +}); diff --git a/ui-react/apps/console/src/utils/encoding.ts b/ui-react/apps/console/src/utils/encoding.ts new file mode 100644 index 00000000000..aa1039f6f17 --- /dev/null +++ b/ui-react/apps/console/src/utils/encoding.ts @@ -0,0 +1,8 @@ +import { Buffer } from "buffer"; + +// `btoa(JSON.stringify(value))` throws InvalidCharacterError for any character +// above U+00FF (e.g. tag names, hostnames, usernames in non-Latin scripts), +// so the request never leaves the browser. UTF-8-encode first, then base64. +export function toBase64Json(value: unknown): string { + return Buffer.from(JSON.stringify(value), "utf-8").toString("base64"); +} diff --git a/ui-react/apps/console/src/utils/invitations.ts b/ui-react/apps/console/src/utils/invitations.ts index 397fafca1e2..98fd0b320f9 100644 --- a/ui-react/apps/console/src/utils/invitations.ts +++ b/ui-react/apps/console/src/utils/invitations.ts @@ -1,4 +1,5 @@ import type { MembershipInvitation } from "@/client"; +import { toBase64Json } from "@/utils/encoding"; export type InvitationStatus = MembershipInvitation["status"]; @@ -13,7 +14,7 @@ export function invitationStatusFilter(status: InvitationStatus): string { params: { name: "status", operator: "eq", value: status }, }, ]; - return btoa(JSON.stringify(filter)); + return toBase64Json(filter); } export function isInvitationExpired(expiresAt: string | null): boolean {