fix(housekeeping): address people moderation review
CI / check (pull_request) Failing after 9s
CI / release (pull_request) Skipped
CI / deploy (pull_request) Skipped

This commit is contained in:
Simo committed 2026-08-29 23:53:38 +02:00
1 parent 29fe22297b
commit 3fa1119f5a
20 files changed
+2000 -777

No files matched your search

+12 -1
View File
@@ -10,10 +10,21 @@ import { createCorrelationId } from "@/features/housekeeping/foundation/contract
import { PERMS } from "@/lib/permissions"; import { PERMS } from "@/lib/permissions";
import { adminAction } from "@/lib/safe-action"; import { adminAction } from "@/lib/safe-action";
import { ActionError, actionOk } from "@/lib/safe-action-shared"; import { ActionError, actionOk } from "@/lib/safe-action-shared";
import { canonicalTicketId } from "@/lib/services/ticket-replies";
const ticketIdField = z const ticketIdField = z
.union([z.string(), z.number(), z.bigint()]) .union([z.string(), z.number(), z.bigint()])
.transform((v) => BigInt(String(v))); .transform((value, context) => {
try {
return canonicalTicketId(value);
} catch {
context.addIssue({
code: "custom",
message: "Invalid ticket identifier",
});
return z.NEVER;
}
});
const replyHelpCenterTicketSchema = z.object({ const replyHelpCenterTicketSchema = z.object({
ticketId: ticketIdField, ticketId: ticketIdField,
File diff suppressed because it is too large. Load diff
@@ -8,11 +8,34 @@ const { execute, registrations, staff } = vi.hoisted(() => ({
})); }));
function wrapper( function wrapper(
options: { permission: string | readonly string[] }, options: {
permission: string | readonly string[];
schema?: {
safeParse(value: unknown):
| { success: true; data: unknown }
| { success: false; error: unknown };
};
},
handler: (context: { data: unknown; session: { user: typeof staff } }) => unknown, handler: (context: { data: unknown; session: { user: typeof staff } }) => unknown,
) { ) {
registrations.push(options); registrations.push(options);
return (data: unknown) => handler({ data, session: { user: staff } }); return async (data: unknown) => {
const parsed = options.schema?.safeParse(data);
if (parsed && !parsed.success) {
return { ok: false, error: "Validation failed" };
}
try {
return await handler({
data: parsed?.data ?? data,
session: { user: staff },
});
} catch (error) {
return {
ok: false,
error: error instanceof Error ? error.message : "Internal server error",
};
}
};
} }
vi.mock("@/features/housekeeping/domains/people/services/mutations", () => ({ vi.mock("@/features/housekeeping/domains/people/services/mutations", () => ({
@@ -202,6 +225,64 @@ describe("legacy People support and moderation wrappers", () => {
); );
}); });
it.each([
["kick", quickKick, { userId: 7 }],
["mute", quickMute, { userId: 7, duration: 60 }],
["unmute", quickUnmute, { userId: 7 }],
["alert", quickAlert, { userId: 7, message: "Stop" }],
["room kick", quickRoomKick, { roomId: 12 }],
["broadcast", broadcastAlert, { message: "Notice", type: "staff" }],
] as const)(
"maps a non-ok %s service result to the historical legacy failure boundary",
async (_label, action, input) => {
execute.mockResolvedValueOnce({
ok: false,
error: {
code: "DEPENDENCY_UNAVAILABLE",
messageKey: "errors.housekeeping.dependencyUnavailable",
},
correlationId: "quick-action-failure",
});
await expect(call(action, input)).resolves.toEqual({
ok: false,
error: "Could not execute moderation action",
});
},
);
it("maps a thrown moderation service failure instead of reporting success", async () => {
execute.mockRejectedValueOnce(new Error("RCON unavailable"));
await expect(call(quickKick, { userId: 7 })).resolves.toEqual({
ok: false,
error: "RCON unavailable",
});
});
it.each([
9_007_199_254_740_992,
"01",
"0",
0,
-1,
"18446744073709551616",
] as const)(
"rejects noncanonical help-ticket identifier %s at every legacy action schema",
async (ticketId) => {
for (const [action, input] of [
[replyHelpCenterTicket, { ticketId, content: "Handled" }],
[closeHelpCenterTicket, { ticketId }],
[reopenHelpCenterTicket, { ticketId }],
[liftBanFromHelpTicket, { ticketId }],
] as const) {
await expect(call(action, input)).resolves.toEqual({
ok: false,
error: "Validation failed",
});
}
expect(execute).not.toHaveBeenCalled();
},
);
it("keeps close-CFH missing rows as a successful legacy no-op", async () => { it("keeps close-CFH missing rows as a successful legacy no-op", async () => {
execute.mockResolvedValueOnce({ execute.mockResolvedValueOnce({
ok: false, ok: false,
@@ -2,6 +2,7 @@ import "server-only";
import { z } from "zod"; import { z } from "zod";
import { PERMS } from "@/lib/permission-slugs"; import { PERMS } from "@/lib/permission-slugs";
import { CANONICAL_TICKET_ID_PATTERN } from "@/lib/services/ticket-replies";
import type { HousekeepingCommand } from "../../../foundation/commands/registry"; import type { HousekeepingCommand } from "../../../foundation/commands/registry";
import { anyCapability } from "../../../foundation/contracts"; import { anyCapability } from "../../../foundation/contracts";
import { import {
@@ -24,26 +25,7 @@ export const SUPPORT_COMMAND_IDS = [
type SupportCommandId = (typeof SUPPORT_COMMAND_IDS)[number]; type SupportCommandId = (typeof SUPPORT_COMMAND_IDS)[number];
const positiveId = z.number().int().positive(); const positiveId = z.number().int().positive();
const text = (max: number) => z.string().min(1).max(max).regex(/\S/u); const text = (max: number) => z.string().min(1).max(max).regex(/\S/u);
const MAX_UNSIGNED_BIGINT = "18446744073709551615"; const bigintId = z.string().regex(CANONICAL_TICKET_ID_PATTERN);
function boundedDecimalPattern(maximum: string): RegExp {
const alternatives = [`[1-9]\\d{0,${maximum.length - 2}}`];
for (let index = 0; index < maximum.length; index += 1) {
const maximumDigit = Number(maximum[index]);
const minimumDigit = index === 0 ? 1 : 0;
const upperDigit = maximumDigit - 1;
if (upperDigit < minimumDigit) continue;
const digit =
upperDigit === minimumDigit
? String(minimumDigit)
: `[${minimumDigit}-${upperDigit}]`;
alternatives.push(
`${maximum.slice(0, index)}${digit}\\d{${maximum.length - index - 1}}`,
);
}
alternatives.push(maximum);
return new RegExp(`^(?:${alternatives.join("|")})$`, "u");
}
const bigintId = z.string().regex(boundedDecimalPattern(MAX_UNSIGNED_BIGINT));
function command<I>( function command<I>(
service: Pick<PeopleMutationService, "execute">, service: Pick<PeopleMutationService, "execute">,
@@ -12,6 +12,7 @@ import {
type HousekeepingWorkItem, type HousekeepingWorkItem,
ok, ok,
} from "../../foundation/contracts"; } from "../../foundation/contracts";
import { isSafeHousekeepingHref } from "../../foundation/housekeeping-href";
import { peopleModerationQuery } from "./queries/moderation"; import { peopleModerationQuery } from "./queries/moderation";
import { peopleSupportQuery } from "./queries/support"; import { peopleSupportQuery } from "./queries/support";
@@ -41,17 +42,6 @@ export interface PeopleInboxAdapters {
): Promise<readonly HousekeepingWorkItem[]>; ): Promise<readonly HousekeepingWorkItem[]>;
} }
function safeHref(href: string): boolean {
return (
href.startsWith("/ase/") &&
!href.includes("\\") &&
!Array.from(href).some((character) => {
const code = character.codePointAt(0) ?? 0;
return code < 32 || code === 127;
})
);
}
function createSource( function createSource(
id: (typeof PEOPLE_INBOX_SOURCE_IDS)[number], id: (typeof PEOPLE_INBOX_SOURCE_IDS)[number],
capability: CapabilityRequirement, capability: CapabilityRequirement,
@@ -72,7 +62,7 @@ function createSource(
items: items items: items
.filter( .filter(
(item) => (item) =>
safeHref(item.href) && isSafeHousekeepingHref(item.href) &&
satisfiesCapability(context, item.capability), satisfiesCapability(context, item.capability),
) )
.slice(0, 25), .slice(0, 25),
File diff suppressed because it is too large. Load diff
@@ -11,7 +11,10 @@ import {
peopleSupportQuery, peopleSupportQuery,
} from "../queries/support"; } from "../queries/support";
import { PeoplePageFrame, parsePeopleListInput } from "./page-state"; import { PeoplePageFrame, parsePeopleListInput } from "./page-state";
import { PeopleCommandForm } from "./people-command-form"; import {
PeopleCommandForm,
type PeopleCommandSubmission,
} from "./people-command-form";
interface Props { interface Props {
readonly context: HousekeepingCapabilityContext; readonly context: HousekeepingCapabilityContext;
@@ -34,6 +37,52 @@ function Queue({ queue }: { readonly queue: { tickets: number; helpTickets: numb
); );
} }
type PeopleTicketTemplate = Extract<
PeopleSupportQueryData,
{ readonly kind: "ticket-templates" }
>["page"]["items"][number];
export function ticketTemplateUpdateSubmission(
template: PeopleTicketTemplate,
): PeopleCommandSubmission {
return {
commandId: "people.ticket-template.change",
input: { action: "update", id: template.id },
fields: [
{
name: "title",
label: "Title",
type: "text",
required: true,
maxLength: 255,
defaultValue: template.title,
},
{
name: "content",
label: "Content",
type: "text",
required: true,
maxLength: 5_000,
defaultValue: template.content,
},
{
name: "category",
label: "Category",
type: "text",
maxLength: 50,
defaultValue: template.category,
},
{
name: "sortOrder",
label: "Sort order",
type: "number",
min: 0,
defaultValue: template.sortOrder,
},
],
};
}
export function PeopleSupportPage({ context, result }: Props) { export function PeopleSupportPage({ context, result }: Props) {
return ( return (
<PeoplePageFrame <PeoplePageFrame
@@ -66,11 +115,17 @@ export function PeopleSupportPage({ context, result }: Props) {
<h2>{template.title}</h2> <h2>{template.title}</h2>
<p>{template.content}</p> <p>{template.content}</p>
{context.has(PERMS.TICKETS_EDIT) ? ( {context.has(PERMS.TICKETS_EDIT) ? (
<PeopleCommandForm <div className="space-y-3">
commandId="people.ticket-template.change" <PeopleCommandForm
buttonLabel="Delete template" {...ticketTemplateUpdateSubmission(template)}
input={{ action: "delete", id: template.id }} buttonLabel="Update template"
/> />
<PeopleCommandForm
commandId="people.ticket-template.change"
buttonLabel="Delete template"
input={{ action: "delete", id: template.id }}
/>
</div>
) : null} ) : null}
</li> </li>
))} ))}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,100 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { PERMS } from "@/lib/permission-slugs";
import type { HousekeepingCapabilityContext } from "../../foundation/contracts";
const mocks = vi.hoisted(() => ({
execute: vi.fn(),
fetchTicketQueueOpenCounts: vi.fn(),
}));
vi.mock("@/lib/admin/ticket-queue-counts", () => ({
fetchTicketQueueOpenCounts: mocks.fetchTicketQueueOpenCounts,
}));
vi.mock("@/lib/db", () => {
return { db: { execute: mocks.execute } };
});
import { PEOPLE_WIDGETS } from "./widgets";
function context(granted: readonly string[]): HousekeepingCapabilityContext {
const permissions = new Set(granted);
return {
actor: { id: 42, username: "operator", rank: 4 },
isSuperAdmin: false,
has: (slug) => permissions.has(slug),
hasAny: (...slugs) => slugs.some((slug) => permissions.has(slug)),
hasAll: (...slugs) => slugs.every((slug) => permissions.has(slug)),
};
}
async function load(granted: readonly string[]) {
return PEOPLE_WIDGETS[0].load(
context(granted),
new AbortController().signal,
);
}
beforeEach(() => {
vi.clearAllMocks();
mocks.fetchTicketQueueOpenCounts.mockResolvedValue({
cmsOpen: 1,
helpOpen: 2,
});
});
describe("People production queue widget", () => {
it("wires ticket-only, CFH-only, ban-only, mixed, and denied contexts without unauthorized reads", async () => {
await expect(load([PERMS.MOD_TICKETS_VIEW])).resolves.toMatchObject({
ok: true,
data: { tickets: 1, helpTickets: 2, cfh: 0, activeBans: 0 },
});
expect(mocks.fetchTicketQueueOpenCounts).toHaveBeenCalledTimes(1);
expect(mocks.execute).not.toHaveBeenCalled();
vi.clearAllMocks();
mocks.execute.mockResolvedValueOnce([[{ total: 3 }], []]);
await expect(load([PERMS.MOD_CFH_VIEW])).resolves.toMatchObject({
ok: true,
data: { tickets: 0, helpTickets: 0, cfh: 3, activeBans: 0 },
});
expect(mocks.fetchTicketQueueOpenCounts).not.toHaveBeenCalled();
expect(mocks.execute).toHaveBeenCalledTimes(1);
vi.clearAllMocks();
mocks.execute.mockResolvedValueOnce([[{ total: 4 }], []]);
await expect(load([PERMS.MOD_BANS_VIEW])).resolves.toMatchObject({
ok: true,
data: { tickets: 0, helpTickets: 0, cfh: 0, activeBans: 4 },
});
expect(mocks.fetchTicketQueueOpenCounts).not.toHaveBeenCalled();
expect(mocks.execute).toHaveBeenCalledTimes(1);
vi.clearAllMocks();
mocks.fetchTicketQueueOpenCounts.mockResolvedValue({
cmsOpen: 1,
helpOpen: 2,
});
mocks.execute
.mockResolvedValueOnce([[{ total: 3 }], []])
.mockResolvedValueOnce([[{ total: 4 }], []]);
const mixed = await load([
PERMS.MOD_TICKETS_VIEW,
PERMS.MOD_CFH_VIEW,
PERMS.MOD_BANS_VIEW,
]);
expect(mocks.fetchTicketQueueOpenCounts).toHaveBeenCalledTimes(1);
expect(mocks.execute).toHaveBeenCalledTimes(2);
expect(mixed).toMatchObject({
ok: true,
data: { tickets: 1, helpTickets: 2, cfh: 3, activeBans: 4 },
});
vi.clearAllMocks();
await expect(load([])).resolves.toMatchObject({
ok: false,
error: { code: "FORBIDDEN" },
});
expect(mocks.fetchTicketQueueOpenCounts).not.toHaveBeenCalled();
expect(mocks.execute).not.toHaveBeenCalled();
});
});
@@ -11,6 +11,7 @@ import {
type HousekeepingSearchProvider, type HousekeepingSearchProvider,
ok, ok,
} from "../../foundation/contracts"; } from "../../foundation/contracts";
import { isSafeHousekeepingHref } from "../../foundation/housekeeping-href";
import { peopleCommunityQuery } from "./queries/community"; import { peopleCommunityQuery } from "./queries/community";
import { peopleSupportQuery } from "./queries/support"; import { peopleSupportQuery } from "./queries/support";
import { peopleUsersQuery } from "./queries/users"; import { peopleUsersQuery } from "./queries/users";
@@ -47,17 +48,6 @@ export interface PeopleSearchAdapters {
): Promise<readonly PeopleSearchCandidate[]>; ): Promise<readonly PeopleSearchCandidate[]>;
} }
function safeHref(href: string): href is `/ase/${string}` {
return (
href.startsWith("/ase/") &&
!href.includes("\\") &&
!Array.from(href).some((character) => {
const code = character.codePointAt(0) ?? 0;
return code < 32 || code === 127;
})
);
}
function boundedLimit(limit: number): number { function boundedLimit(limit: number): number {
return Number.isFinite(limit) ? Math.min(25, Math.max(1, Math.trunc(limit))) : 25; return Number.isFinite(limit) ? Math.min(25, Math.max(1, Math.trunc(limit))) : 25;
} }
@@ -82,7 +72,7 @@ function createProvider(
candidates candidates
.filter( .filter(
(item) => (item) =>
safeHref(item.href) && isSafeHousekeepingHref(item.href) &&
satisfiesCapability(context, item.capability), satisfiesCapability(context, item.capability),
) )
.slice(0, limit) .slice(0, limit)
File diff suppressed because it is too large. Load diff
@@ -93,4 +93,24 @@ describe("People production server authority", () => {
expect(alertUser).not.toHaveBeenCalled(); expect(alertUser).not.toHaveBeenCalled();
expect(audit).not.toHaveBeenCalled(); expect(audit).not.toHaveBeenCalled();
}); });
it("blocks Task 13 moderation work before intent audit or transport on actor mismatch", async () => {
resolveServerContext.mockResolvedValue(context([PERMS.MOD_ACTIONS]));
const result = await peopleMutationService.execute(
{
correlationId: "task13-server-authority",
expectedActorId: 99,
legacy: true,
},
"moderation.action",
{ action: "alert", userId: 7, message: "Stop" },
);
expect(result).toMatchObject({
ok: false,
error: { code: "FORBIDDEN" },
correlationId: "task13-server-authority",
});
expect(alertUser).not.toHaveBeenCalled();
expect(audit).not.toHaveBeenCalled();
});
}); });
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,43 @@
import "server-only";
import { sql } from "drizzle-orm";
import { fetchTicketQueueOpenCounts } from "@/lib/admin/ticket-queue-counts";
import { db } from "@/lib/db";
import { createPeopleQueueAggregateLoader } from "./widgets";
function count(value: unknown): number {
const parsed = Number(value);
if (!Number.isSafeInteger(parsed) || parsed < 0) {
throw new Error("invalid queue count");
}
return parsed;
}
export const loadPeopleQueueAggregate = createPeopleQueueAggregateLoader({
async support(signal) {
if (signal.aborted) throw new Error("aborted queue aggregate");
const counts = await fetchTicketQueueOpenCounts({ strict: true });
return { tickets: counts.cmsOpen, helpTickets: counts.helpOpen };
},
async cfh(signal) {
if (signal.aborted) throw new Error("aborted queue aggregate");
const result = await db.execute(
sql`SELECT COUNT(*) AS total FROM support_tickets WHERE state <> 2`,
);
if (!Array.isArray(result) || !Array.isArray(result[0])) {
throw new Error("invalid CFH queue count");
}
return count((result[0] as Array<{ total: unknown }>)[0]?.total);
},
async activeBans(signal) {
if (signal.aborted) throw new Error("aborted queue aggregate");
const result = await db.execute(sql`
SELECT COUNT(*) AS total FROM bans
WHERE ban_expire = 0 OR ban_expire > UNIX_TIMESTAMP()
`);
if (!Array.isArray(result) || !Array.isArray(result[0])) {
throw new Error("invalid ban queue count");
}
return count((result[0] as Array<{ total: unknown }>)[0]?.total);
},
});
@@ -11,7 +11,6 @@ import {
ok, ok,
} from "../../foundation/contracts"; } from "../../foundation/contracts";
import type { PeopleQueueSnapshot } from "./models"; import type { PeopleQueueSnapshot } from "./models";
import { peopleSupportQuery } from "./queries/support";
export interface PeopleWidgetAdapters { export interface PeopleWidgetAdapters {
queue( queue(
@@ -20,6 +19,14 @@ export interface PeopleWidgetAdapters {
): Promise<PeopleQueueSnapshot>; ): Promise<PeopleQueueSnapshot>;
} }
export interface PeopleQueueAggregateAdapters {
support(
signal: AbortSignal,
): Promise<Readonly<{ tickets: number; helpTickets: number }>>;
cfh(signal: AbortSignal): Promise<number>;
activeBans(signal: AbortSignal): Promise<number>;
}
const supportQueueCapability = anyCapability( const supportQueueCapability = anyCapability(
PERMS.TICKETS_VIEW, PERMS.TICKETS_VIEW,
PERMS.MOD_TICKETS_VIEW, PERMS.MOD_TICKETS_VIEW,
@@ -38,6 +45,40 @@ const queueCapability = anyCapability(
...banQueueCapability.slugs, ...banQueueCapability.slugs,
); );
function count(value: unknown): number {
const parsed = Number(value);
if (!Number.isSafeInteger(parsed) || parsed < 0) {
throw new Error("invalid queue count");
}
return parsed;
}
export function createPeopleQueueAggregateLoader(
adapters: PeopleQueueAggregateAdapters,
): PeopleWidgetAdapters["queue"] {
return async (context, signal) => {
const authorization = authorizeHousekeeping(context, queueCapability);
if (!authorization.ok) throw new Error("forbidden queue aggregate");
if (signal.aborted) throw new Error("aborted queue aggregate");
const supportAllowed = satisfiesCapability(context, supportQueueCapability);
const cfhAllowed = satisfiesCapability(context, cfhQueueCapability);
const bansAllowed = satisfiesCapability(context, banQueueCapability);
const [support, cfh, activeBans] = await Promise.all([
supportAllowed
? adapters.support(signal)
: Promise.resolve({ tickets: 0, helpTickets: 0 }),
cfhAllowed ? adapters.cfh(signal) : Promise.resolve(0),
bansAllowed ? adapters.activeBans(signal) : Promise.resolve(0),
]);
return {
tickets: count(support.tickets),
helpTickets: count(support.helpTickets),
cfh: count(cfh),
activeBans: count(activeBans),
};
};
}
export function createPeopleWidgets( export function createPeopleWidgets(
adapters: PeopleWidgetAdapters, adapters: PeopleWidgetAdapters,
): readonly HousekeepingWidgetDefinition[] { ): readonly HousekeepingWidgetDefinition[] {
@@ -80,11 +121,8 @@ export function createPeopleWidgets(
} }
export const PEOPLE_WIDGETS = createPeopleWidgets({ export const PEOPLE_WIDGETS = createPeopleWidgets({
async queue(context) { async queue(context, signal) {
const result = await peopleSupportQuery.run(context, { const { loadPeopleQueueAggregate } = await import("./widgets-production");
routeId: "people.support.queue", return loadPeopleQueueAggregate(context, signal);
});
if (!result.ok || result.data.kind !== "queue") throw new Error("queue");
return result.data.queue;
}, },
}); });
@@ -138,10 +138,20 @@ const approvedRuntimeImports = new Map<string, ReadonlySet<string>>([
"src/lib/auth", "src/lib/auth",
"src/lib/auth/password", "src/lib/auth/password",
"src/lib/db", "src/lib/db",
"src/features/housekeeping/domains/people/services/moderation-mutations",
"src/features/housekeeping/domains/people/services/support-mutations",
"drizzle-orm", "drizzle-orm",
"mysql2", "mysql2",
]), ]),
], ],
[
"src/features/housekeeping/domains/people/services/moderation-mutations.ts",
new Set(["src/lib/db", "drizzle-orm"]),
],
[
"src/features/housekeeping/domains/people/services/support-mutations.ts",
new Set(["src/lib/db", "drizzle-orm"]),
],
[ [
"src/features/housekeeping/domains/people/manifest.ts", "src/features/housekeeping/domains/people/manifest.ts",
new Set([ new Set([
@@ -170,7 +180,15 @@ const approvedRuntimeImports = new Map<string, ReadonlySet<string>>([
"src/features/housekeeping/domains/people/widgets.ts", "src/features/housekeeping/domains/people/widgets.ts",
new Set([ new Set([
"src/features/housekeeping/domains/people/models", "src/features/housekeeping/domains/people/models",
"src/features/housekeeping/domains/people/queries/support", "src/features/housekeeping/domains/people/widgets-production",
]),
],
[
"src/features/housekeeping/domains/people/widgets-production.ts",
new Set([
"src/features/housekeeping/domains/people/widgets",
"src/lib/db",
"drizzle-orm",
]), ]),
], ],
[ [
@@ -0,0 +1,70 @@
const HOUSEKEEPING_ORIGIN = "https://housekeeping.invalid";
function containsControlCharacter(value: string): boolean {
return Array.from(value).some((character) => {
const code = character.codePointAt(0) ?? 0;
return code < 32 || code === 127;
});
}
function decodedVariants(value: string): readonly string[] | null {
const variants = [value];
for (let pass = 0; pass < 3; pass += 1) {
let decoded: string;
try {
decoded = decodeURIComponent(variants.at(-1) ?? "");
} catch {
return null;
}
if (decoded === variants.at(-1)) break;
variants.push(decoded);
}
return variants;
}
function containsDotSegment(path: string): boolean {
return path.split("/").some((segment) => segment === "." || segment === "..");
}
export function isSafeHousekeepingHref(href: string): boolean {
if (
href.length === 0 ||
!href.startsWith("/") ||
href.startsWith("//") ||
containsControlCharacter(href) ||
href.includes("\\")
) {
return false;
}
const variants = decodedVariants(href);
if (
variants === null ||
variants.some(
(value) => containsControlCharacter(value) || value.includes("\\"),
)
) {
return false;
}
const rawPath = href.split(/[?#]/u, 1)[0] ?? "";
const pathVariants = decodedVariants(rawPath);
if (
pathVariants === null ||
pathVariants.some((path) => containsDotSegment(path))
) {
return false;
}
let normalized: URL;
try {
normalized = new URL(href, HOUSEKEEPING_ORIGIN);
} catch {
return false;
}
return (
normalized.origin === HOUSEKEEPING_ORIGIN &&
(normalized.pathname === "/ase" ||
normalized.pathname.startsWith("/ase/"))
);
}
+35 -7
View File
@@ -19,23 +19,51 @@ export interface TicketReplyDb {
touchTicket(ticketId: bigint, updatedAt: Date): Promise<unknown>; touchTicket(ticketId: bigint, updatedAt: Date): Promise<unknown>;
} }
const MAX_UNSIGNED_BIGINT = 18_446_744_073_709_551_615n; export const MAX_UNSIGNED_TICKET_ID = 18_446_744_073_709_551_615n;
function boundedDecimalPattern(maximum: string): RegExp {
const alternatives = [`[1-9]\\d{0,${maximum.length - 2}}`];
for (let index = 0; index < maximum.length; index += 1) {
const maximumDigit = Number(maximum[index]);
const minimumDigit = index === 0 ? 1 : 0;
const upperDigit = maximumDigit - 1;
if (upperDigit < minimumDigit) continue;
const digit =
upperDigit === minimumDigit
? String(minimumDigit)
: `[${minimumDigit}-${upperDigit}]`;
alternatives.push(
`${maximum.slice(0, index)}${digit}\\d{${maximum.length - index - 1}}`,
);
}
alternatives.push(maximum);
return new RegExp(`^(?:${alternatives.join("|")})$`, "u");
}
export const CANONICAL_TICKET_ID_PATTERN = boundedDecimalPattern(
MAX_UNSIGNED_TICKET_ID.toString(),
);
// Canonicalize a SQL BIGINT identifier without passing through Number. // Canonicalize a SQL BIGINT identifier without passing through Number.
export function canonicalTicketId(value: string | number | bigint): bigint { export function canonicalTicketId(value: string | number | bigint): bigint {
let parsed: bigint; let parsed: bigint;
try { try {
if ( if (typeof value === "bigint") {
typeof value === "number" && parsed = value;
(!Number.isSafeInteger(value) || value <= 0) } else if (typeof value === "string") {
) { if (!CANONICAL_TICKET_ID_PATTERN.test(value)) {
throw new Error("noncanonical identifier");
}
parsed = BigInt(value);
} else if (Number.isSafeInteger(value) && value > 0) {
parsed = BigInt(value);
} else {
throw new Error("unsafe identifier"); throw new Error("unsafe identifier");
} }
parsed = BigInt(String(value));
} catch { } catch {
throw new Error("invalid ticket identifier"); throw new Error("invalid ticket identifier");
} }
if (parsed <= 0n || parsed > MAX_UNSIGNED_BIGINT) { if (parsed <= 0n || parsed > MAX_UNSIGNED_TICKET_ID) {
throw new Error("invalid ticket identifier"); throw new Error("invalid ticket identifier");
} }
return parsed; return parsed;