fix(housekeeping): harden people read boundaries
This commit is contained in:
1 parent
e3f8b51d31
commit
d1382c839e
12 files changed
+1635
-446
No files matched your search
@@ -49,7 +49,7 @@ export interface PeopleSanctionSummary {
|
||||
readonly kind: string;
|
||||
readonly reason: string;
|
||||
readonly createdAt: string | null;
|
||||
readonly expiresAt: string | null;
|
||||
readonly expiresAt: string | 0 | null;
|
||||
readonly active: boolean;
|
||||
}
|
||||
|
||||
@@ -59,6 +59,16 @@ export interface PeopleUserDetail extends PeopleUserSummary {
|
||||
readonly accountCreated: string | null;
|
||||
readonly lastLogin: string | null;
|
||||
readonly sanctions: readonly PeopleSanctionSummary[];
|
||||
readonly watched: boolean;
|
||||
readonly permission: {
|
||||
readonly rankName: string;
|
||||
readonly ranks: readonly PeoplePermissionRank[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface PeoplePermissionRank {
|
||||
readonly id: number;
|
||||
readonly name: string;
|
||||
}
|
||||
|
||||
export interface PeopleMultiAccountCluster {
|
||||
@@ -135,15 +145,25 @@ export interface PeopleQueueSnapshot {
|
||||
readonly activeBans: number;
|
||||
}
|
||||
|
||||
export interface PeopleSupportStaff {
|
||||
readonly id: number;
|
||||
readonly username: string;
|
||||
readonly rank: number;
|
||||
readonly href: `/ase/people/users/${number}`;
|
||||
}
|
||||
|
||||
export interface PeopleTicketSummary {
|
||||
readonly source: "cms" | "help";
|
||||
readonly id: number;
|
||||
readonly subject: string;
|
||||
readonly status: string;
|
||||
readonly priority: string;
|
||||
readonly creatorId: number;
|
||||
readonly creatorId: number | null;
|
||||
readonly creatorUsername: string | null;
|
||||
readonly updatedAt: string | null;
|
||||
readonly href: `/ase/people/support/tickets/${number}`;
|
||||
readonly href:
|
||||
| `/ase/people/support/tickets/${number}`
|
||||
| `/ase/people/support/help-tickets/${number}`;
|
||||
}
|
||||
|
||||
export interface PeopleTicketDetail extends PeopleTicketSummary {
|
||||
@@ -157,6 +177,8 @@ export interface PeopleTicketDetail extends PeopleTicketSummary {
|
||||
readonly isStaff: boolean;
|
||||
readonly createdAt: string | null;
|
||||
}[];
|
||||
readonly queue: PeopleQueueSnapshot;
|
||||
readonly staff: readonly PeopleSupportStaff[];
|
||||
}
|
||||
|
||||
export interface PeopleHelpTicketSummary {
|
||||
@@ -180,6 +202,9 @@ export interface PeopleHelpTicketDetail extends PeopleHelpTicketSummary {
|
||||
readonly content: string;
|
||||
readonly createdAt: string | null;
|
||||
}[];
|
||||
readonly queue: PeopleQueueSnapshot;
|
||||
readonly staff: readonly PeopleSupportStaff[];
|
||||
readonly activeBan: PeopleBanSummary | null;
|
||||
}
|
||||
|
||||
export interface PeopleTicketTemplate {
|
||||
@@ -217,7 +242,7 @@ export interface PeopleBanSummary {
|
||||
readonly type: string;
|
||||
readonly reason: string;
|
||||
readonly createdAt: string | null;
|
||||
readonly expiresAt: string | null;
|
||||
readonly expiresAt: string | 0;
|
||||
readonly active: boolean;
|
||||
}
|
||||
|
||||
@@ -252,7 +277,7 @@ export interface PeopleQueries {
|
||||
|
||||
const DEFAULT_PAGE_SIZE = 20;
|
||||
const MAX_PAGE_SIZE = 100;
|
||||
const MAX_OFFSET = 1_000_000;
|
||||
const MAX_OFFSET = 10_000;
|
||||
const MAX_SEARCH_LENGTH = 128;
|
||||
|
||||
function boundedInteger(
|
||||
@@ -297,34 +322,22 @@ export function normalizePeopleListInput(
|
||||
};
|
||||
}
|
||||
|
||||
function compareValues(left: string | number, right: string | number): number {
|
||||
if (typeof left === "number" && typeof right === "number") {
|
||||
return left - right;
|
||||
}
|
||||
return String(left).localeCompare(String(right), undefined, {
|
||||
numeric: true,
|
||||
sensitivity: "base",
|
||||
});
|
||||
}
|
||||
|
||||
export function createPeoplePage<T extends { readonly id: number }>(
|
||||
export function createPeoplePage<T>(
|
||||
rows: readonly T[],
|
||||
total: number,
|
||||
input: NormalizedListInput,
|
||||
sortValue: (row: T) => string | number,
|
||||
): Page<T> {
|
||||
const items = [...rows]
|
||||
.sort((left, right) => {
|
||||
const primary = compareValues(sortValue(left), sortValue(right));
|
||||
return (
|
||||
(input.order === "desc" ? -primary : primary) || left.id - right.id
|
||||
);
|
||||
})
|
||||
.slice(input.offset, input.offset + input.pageSize);
|
||||
|
||||
if (
|
||||
!Number.isSafeInteger(total) ||
|
||||
total < 0 ||
|
||||
rows.length > input.pageSize
|
||||
) {
|
||||
throw new Error("invalid People page");
|
||||
}
|
||||
assertPeopleSerializable(rows);
|
||||
return {
|
||||
items,
|
||||
total: boundedInteger(total, rows.length, 0, Number.MAX_SAFE_INTEGER),
|
||||
items: [...rows],
|
||||
total,
|
||||
pageSize: input.pageSize,
|
||||
offset: input.offset,
|
||||
};
|
||||
@@ -335,6 +348,22 @@ export function toPeopleNumber(value: unknown, fallback = 0): number {
|
||||
return Number.isSafeInteger(parsed) ? parsed : fallback;
|
||||
}
|
||||
|
||||
function positiveSafeInteger(value: unknown): number {
|
||||
const parsed = typeof value === "bigint" ? Number(value) : Number(value);
|
||||
if (!Number.isSafeInteger(parsed) || parsed <= 0) {
|
||||
throw new Error("invalid People identifier");
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function nonNegativeSafeInteger(value: unknown): number {
|
||||
const parsed = typeof value === "bigint" ? Number(value) : Number(value);
|
||||
if (!Number.isSafeInteger(parsed) || parsed < 0) {
|
||||
throw new Error("invalid People number");
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export function toPeopleIsoDate(value: unknown): string | null {
|
||||
if (value === null || value === undefined || value === "") return null;
|
||||
let date: Date;
|
||||
@@ -348,40 +377,47 @@ export function toPeopleIsoDate(value: unknown): string | null {
|
||||
)
|
||||
: new Date(String(value));
|
||||
}
|
||||
return Number.isFinite(date.getTime()) ? date.toISOString() : null;
|
||||
if (!Number.isFinite(date.getTime())) throw new Error("invalid People date");
|
||||
return date.toISOString();
|
||||
}
|
||||
|
||||
function nullableString(value: unknown): string | null {
|
||||
if (typeof value !== "string") return null;
|
||||
if (value === null || value === undefined) return null;
|
||||
if (typeof value !== "string") throw new Error("invalid People string");
|
||||
const normalized = value.trim();
|
||||
return normalized.length > 0 ? normalized : null;
|
||||
}
|
||||
|
||||
export function peopleUserHref(id: number): `/ase/people/users/${number}` {
|
||||
positiveSafeInteger(id);
|
||||
return `/ase/people/users/${id}`;
|
||||
}
|
||||
|
||||
export function peopleGuildHref(
|
||||
id: number,
|
||||
): `/ase/people/community/guilds/${number}` {
|
||||
positiveSafeInteger(id);
|
||||
return `/ase/people/community/guilds/${id}`;
|
||||
}
|
||||
|
||||
export function peopleTicketHref(
|
||||
id: number,
|
||||
): `/ase/people/support/tickets/${number}` {
|
||||
positiveSafeInteger(id);
|
||||
return `/ase/people/support/tickets/${id}`;
|
||||
}
|
||||
|
||||
export function peopleHelpTicketHref(
|
||||
id: number,
|
||||
): `/ase/people/support/help-tickets/${number}` {
|
||||
positiveSafeInteger(id);
|
||||
return `/ase/people/support/help-tickets/${id}`;
|
||||
}
|
||||
|
||||
export function peopleCfhHref(
|
||||
id: number,
|
||||
): `/ase/people/moderation/cfh/${number}` {
|
||||
positiveSafeInteger(id);
|
||||
return `/ase/people/moderation/cfh/${id}`;
|
||||
}
|
||||
|
||||
@@ -389,12 +425,22 @@ export function normalizePeopleUser(
|
||||
row: PeopleUserRecord,
|
||||
projection: { readonly includeMail: boolean; readonly includeIp: boolean },
|
||||
): PeopleUserSummary {
|
||||
const id = toPeopleNumber(row.id);
|
||||
const bannedUntil = toPeopleNumber(row.bannedUntil, 0);
|
||||
const id = positiveSafeInteger(row.id);
|
||||
if (typeof row.username !== "string" || row.username.trim().length === 0) {
|
||||
throw new Error("invalid People username");
|
||||
}
|
||||
const rank = nonNegativeSafeInteger(row.rank);
|
||||
const bannedUntil =
|
||||
row.bannedUntil === null || row.bannedUntil === undefined
|
||||
? null
|
||||
: nonNegativeSafeInteger(row.bannedUntil);
|
||||
const onlineValues = new Set([true, false, 0, 1, "0", "1", "true", "false"]);
|
||||
if (!onlineValues.has(row.online as never))
|
||||
throw new Error("invalid People online value");
|
||||
return {
|
||||
id,
|
||||
username: String(row.username ?? ""),
|
||||
rank: toPeopleNumber(row.rank),
|
||||
username: row.username,
|
||||
rank,
|
||||
online:
|
||||
row.online === true ||
|
||||
row.online === 1 ||
|
||||
@@ -402,7 +448,54 @@ export function normalizePeopleUser(
|
||||
row.online === "true",
|
||||
mail: projection.includeMail ? nullableString(row.mail) : null,
|
||||
ipCurrent: projection.includeIp ? nullableString(row.ipCurrent) : null,
|
||||
bannedUntil: bannedUntil > 0 ? bannedUntil : null,
|
||||
bannedUntil,
|
||||
href: peopleUserHref(id),
|
||||
};
|
||||
}
|
||||
|
||||
const DATE_KEYS = new Set([
|
||||
"accountCreated",
|
||||
"createdAt",
|
||||
"expiresAt",
|
||||
"lastLogin",
|
||||
"updatedAt",
|
||||
]);
|
||||
|
||||
export function assertPeopleSerializable(value: unknown): void {
|
||||
function visit(current: unknown, key = ""): void {
|
||||
if (
|
||||
current === null ||
|
||||
typeof current === "string" ||
|
||||
typeof current === "boolean"
|
||||
) {
|
||||
if (
|
||||
typeof current === "string" &&
|
||||
DATE_KEYS.has(key) &&
|
||||
!Number.isFinite(Date.parse(current))
|
||||
) {
|
||||
throw new Error("invalid People date string");
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (typeof current === "number") {
|
||||
if (!Number.isSafeInteger(current))
|
||||
throw new Error("invalid People number");
|
||||
if ((key === "id" || key.endsWith("Id")) && current <= 0) {
|
||||
throw new Error("invalid People identifier");
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(current)) {
|
||||
for (const item of current) visit(item);
|
||||
return;
|
||||
}
|
||||
if (typeof current !== "object" || current instanceof Date) {
|
||||
throw new Error("non-serializable People value");
|
||||
}
|
||||
for (const [childKey, child] of Object.entries(current)) {
|
||||
if (child === undefined) throw new Error("undefined People value");
|
||||
visit(child, childKey);
|
||||
}
|
||||
}
|
||||
visit(value);
|
||||
}
|
||||
Reference in new issue
Block a user