294 lines
12 KiB
TypeScript
294 lines
12 KiB
TypeScript
import type {
|
|
GameClientBridgeCancelResponse,
|
|
GameClientBridgeCommandCancellationResponse,
|
|
GameClientBridgeCommandListResponse,
|
|
GameClientBridgeCommandResponse,
|
|
GameClientBridgeCommandResultResponse,
|
|
GameClientBridgeCommandState,
|
|
GameClientBridgeJsonObject,
|
|
GameClientBridgeJsonValue,
|
|
GameClientBridgeProfileDeclarationResponse,
|
|
GameClientBridgeResultStatus,
|
|
GameClientBridgeSnapshotListResponse,
|
|
GameClientBridgeSnapshotResponse,
|
|
GameClientBridgeStatusResponse
|
|
} from "../api/types";
|
|
|
|
const commandStates = new Set<GameClientBridgeCommandState>(["pending", "claimed", "succeeded", "failed", "cancelled", "expired", "unknown"]);
|
|
const resultStatuses = new Set<GameClientBridgeResultStatus>(["succeeded", "failed", "cancelled", "unknown"]);
|
|
const forbiddenKeys = new Set([
|
|
"apikey",
|
|
"accesskey",
|
|
"accesskeyid",
|
|
"claimlease",
|
|
"componentkey",
|
|
"componentsession",
|
|
"componentsessionid",
|
|
"credential",
|
|
"credentials",
|
|
"deploymentgeneration",
|
|
"dsn",
|
|
"fencingtoken",
|
|
"hostpath",
|
|
"installationid",
|
|
"keygeneration",
|
|
"leaseexpiresat",
|
|
"password",
|
|
"privatekey",
|
|
"rawcredential",
|
|
"runendpoint",
|
|
"runendpointurl",
|
|
"secret",
|
|
"secretref",
|
|
"secretvalue",
|
|
"sessiontoken",
|
|
"socket",
|
|
"sourcesessionid",
|
|
"storagecredential",
|
|
"token"
|
|
]);
|
|
const forbiddenFragments = [
|
|
"bearer ",
|
|
"password=",
|
|
"secret://",
|
|
"unix://",
|
|
"tcp://",
|
|
"mysql://",
|
|
"postgres://",
|
|
"sqlite://",
|
|
"rcon://"
|
|
];
|
|
const forbiddenHostPath = /(?:^|[\s"'])(?:\/[Uu]sers\/|\/home\/|\/root\/|\/var\/|\/etc\/|\/opt\/|[a-z]:[\\/]|\\\\[^\\]+\\)/;
|
|
|
|
export function parseSafeGameClientBridgeStatus(value: unknown): GameClientBridgeStatusResponse {
|
|
const record = safeObject(value, "Game Client Bridge status");
|
|
return {
|
|
serverInstanceId: string(record.serverInstanceId, "serverInstanceId"),
|
|
pluginId: string(record.pluginId, "pluginId"),
|
|
available: boolean(record.available, "available"),
|
|
reason: optionalString(record.reason, "reason"),
|
|
profiles: array(record.profiles, "profiles").map(parseProfile),
|
|
features: array(record.features ?? [], "features").map(parseFeature)
|
|
};
|
|
}
|
|
|
|
export function parseSafeGameClientBridgeCommand(value: unknown): GameClientBridgeCommandResponse {
|
|
const record = safeObject(value, "Game Client Bridge command");
|
|
const result: GameClientBridgeCommandResponse = {
|
|
id: string(record.id, "id"),
|
|
serverInstanceId: string(record.serverInstanceId, "serverInstanceId"),
|
|
pluginId: string(record.pluginId, "pluginId"),
|
|
profileKey: string(record.profileKey, "profileKey"),
|
|
commandType: string(record.commandType, "commandType"),
|
|
priority: number(record.priority, "priority"),
|
|
state: commandState(record.state),
|
|
expiresAt: string(record.expiresAt, "expiresAt"),
|
|
createdAt: string(record.createdAt, "createdAt"),
|
|
updatedAt: string(record.updatedAt, "updatedAt")
|
|
};
|
|
copyOptionalString(record, result, "requesterId");
|
|
copyOptionalString(record, result, "resultSummary");
|
|
copyOptionalString(record, result, "completedAt");
|
|
if (record.result !== undefined) result.result = parseResult(record.result);
|
|
if (record.cancellation !== undefined) result.cancellation = parseCancellation(record.cancellation);
|
|
return result;
|
|
}
|
|
|
|
export function parseSafeGameClientBridgeCommandList(value: unknown): GameClientBridgeCommandListResponse {
|
|
const record = safeObject(value, "Game Client Bridge command list");
|
|
return {
|
|
items: array(record.items, "items").map(parseSafeGameClientBridgeCommand),
|
|
count: nonNegativeInteger(record.count, "count")
|
|
};
|
|
}
|
|
|
|
export function parseSafeGameClientBridgeCancellation(value: unknown): GameClientBridgeCancelResponse {
|
|
const record = safeObject(value, "Game Client Bridge cancellation");
|
|
const result: GameClientBridgeCancelResponse = {
|
|
commandId: string(record.commandId, "commandId"),
|
|
state: commandState(record.state),
|
|
cancellation: parseCancellation(record.cancellation),
|
|
updatedAt: string(record.updatedAt, "updatedAt")
|
|
};
|
|
return result;
|
|
}
|
|
|
|
export function parseSafeGameClientBridgeSnapshotList(value: unknown): GameClientBridgeSnapshotListResponse {
|
|
const record = safeObject(value, "Game Client Bridge snapshot list");
|
|
return {
|
|
items: array(record.items, "items").map(parseSnapshot),
|
|
count: nonNegativeInteger(record.count, "count")
|
|
};
|
|
}
|
|
|
|
function parseProfile(value: unknown): GameClientBridgeProfileDeclarationResponse {
|
|
const record = safeObject(value, "Game Client Bridge profile");
|
|
return {
|
|
pluginId: string(record.pluginId, "profile.pluginId"),
|
|
profileKey: string(record.profileKey, "profile.profileKey"),
|
|
available: boolean(record.available, "profile.available"),
|
|
reason: optionalString(record.reason, "profile.reason"),
|
|
commandTypes: stringArray(record.commandTypes, "profile.commandTypes"),
|
|
snapshotTypes: stringArray(record.snapshotTypes, "profile.snapshotTypes"),
|
|
queryTemplateKeys: stringArray(record.queryTemplateKeys, "profile.queryTemplateKeys"),
|
|
handlerTypes: stringArray(record.handlerTypes ?? [], "profile.handlerTypes"),
|
|
eventProducerTypes: stringArray(record.eventProducerTypes ?? [], "profile.eventProducerTypes")
|
|
};
|
|
}
|
|
|
|
function parseFeature(value: unknown): { key: string; available: boolean; reason?: string } {
|
|
const record = safeObject(value, "Game Client Bridge feature");
|
|
return { key: string(record.key, "feature.key"), available: boolean(record.available, "feature.available"), reason: optionalString(record.reason, "feature.reason") };
|
|
}
|
|
|
|
function parseResult(value: unknown): GameClientBridgeCommandResultResponse {
|
|
const record = safeObject(value, "Game Client Bridge command result");
|
|
const result: GameClientBridgeCommandResultResponse = {
|
|
status: resultStatus(record.status),
|
|
completedAt: string(record.completedAt, "result.completedAt")
|
|
};
|
|
const summary = optionalString(record.summary, "result.summary");
|
|
if (summary) result.summary = summary;
|
|
if (record.payload !== undefined) result.payload = jsonObject(record.payload, "result.payload");
|
|
return result;
|
|
}
|
|
|
|
function parseCancellation(value: unknown): GameClientBridgeCommandCancellationResponse {
|
|
const record = safeObject(value, "Game Client Bridge command cancellation");
|
|
const result: GameClientBridgeCommandCancellationResponse = {
|
|
cancelledAt: string(record.cancelledAt, "cancellation.cancelledAt")
|
|
};
|
|
const requestedBy = optionalString(record.requestedBy, "cancellation.requestedBy");
|
|
const reason = optionalString(record.reason, "cancellation.reason");
|
|
if (requestedBy) result.requestedBy = requestedBy;
|
|
if (reason) result.reason = reason;
|
|
return result;
|
|
}
|
|
|
|
function parseSnapshot(value: unknown): GameClientBridgeSnapshotResponse {
|
|
const record = safeObject(value, "Game Client Bridge snapshot");
|
|
const retention = safeObject(record.retention, "snapshot.retention");
|
|
const result: GameClientBridgeSnapshotResponse = {
|
|
id: string(record.id, "snapshot.id"),
|
|
serverInstanceId: string(record.serverInstanceId, "snapshot.serverInstanceId"),
|
|
pluginId: string(record.pluginId, "snapshot.pluginId"),
|
|
profileKey: string(record.profileKey, "snapshot.profileKey"),
|
|
type: string(record.type, "snapshot.type"),
|
|
schemaVersion: string(record.schemaVersion, "snapshot.schemaVersion"),
|
|
streamKey: string(record.streamKey, "snapshot.streamKey"),
|
|
sequence: nonNegativeInteger(record.sequence, "snapshot.sequence"),
|
|
observedAt: string(record.observedAt, "snapshot.observedAt"),
|
|
payload: jsonObject(record.payload, "snapshot.payload"),
|
|
retention: {
|
|
keepForSeconds: nonNegativeInteger(retention.keepForSeconds, "snapshot.retention.keepForSeconds")
|
|
},
|
|
createdAt: string(record.createdAt, "snapshot.createdAt"),
|
|
expiresAt: string(record.expiresAt, "snapshot.expiresAt")
|
|
};
|
|
if (retention.maxRecords !== undefined) result.retention.maxRecords = nonNegativeInteger(retention.maxRecords, "snapshot.retention.maxRecords");
|
|
return result;
|
|
}
|
|
|
|
function safeObject(value: unknown, label: string): Record<string, unknown> {
|
|
const record = object(value, label);
|
|
rejectSensitiveProjection(record);
|
|
return record;
|
|
}
|
|
|
|
function rejectSensitiveProjection(value: unknown): void {
|
|
if (typeof value === "string") {
|
|
const normalized = value.toLowerCase();
|
|
if (forbiddenFragments.some((fragment) => normalized.includes(fragment)) || forbiddenHostPath.test(value)) {
|
|
throw new Error("Game Client Bridge response contains sensitive connection or host data");
|
|
}
|
|
return;
|
|
}
|
|
if (Array.isArray(value)) {
|
|
value.forEach(rejectSensitiveProjection);
|
|
return;
|
|
}
|
|
if (value && typeof value === "object") {
|
|
for (const [key, child] of Object.entries(value)) {
|
|
if (forbiddenKeys.has(key.toLowerCase().replace(/[^a-z0-9]/g, ""))) {
|
|
throw new Error("Game Client Bridge response contains a forbidden field");
|
|
}
|
|
rejectSensitiveProjection(child);
|
|
}
|
|
}
|
|
}
|
|
|
|
function jsonObject(value: unknown, label: string): GameClientBridgeJsonObject {
|
|
const record = object(value, label);
|
|
return Object.fromEntries(Object.entries(record).map(([key, child]) => [key, jsonValue(child, `${label}.${key}`)]));
|
|
}
|
|
|
|
function jsonValue(value: unknown, label: string): GameClientBridgeJsonValue {
|
|
if (value === null || typeof value === "string" || typeof value === "boolean") return value;
|
|
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
if (Array.isArray(value)) return value.map((child, index) => jsonValue(child, `${label}[${index}]`));
|
|
if (value && typeof value === "object") return jsonObject(value, label);
|
|
throw new Error(`${label} must be JSON-compatible`);
|
|
}
|
|
|
|
function object(value: unknown, label: string): Record<string, unknown> {
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${label} must be an object`);
|
|
return value as Record<string, unknown>;
|
|
}
|
|
|
|
function array(value: unknown, label: string): unknown[] {
|
|
if (!Array.isArray(value)) throw new Error(`${label} must be an array`);
|
|
return value;
|
|
}
|
|
|
|
function string(value: unknown, label: string): string {
|
|
if (typeof value !== "string" || value === "") throw new Error(`${label} must be a string`);
|
|
return value;
|
|
}
|
|
|
|
function optionalString(value: unknown, label: string): string | undefined {
|
|
if (value === undefined || value === "") return undefined;
|
|
return string(value, label);
|
|
}
|
|
|
|
function number(value: unknown, label: string): number {
|
|
if (typeof value !== "number" || !Number.isFinite(value)) throw new Error(`${label} must be a number`);
|
|
return value;
|
|
}
|
|
|
|
function nonNegativeInteger(value: unknown, label: string): number {
|
|
const parsed = number(value, label);
|
|
if (!Number.isSafeInteger(parsed) || parsed < 0) throw new Error(`${label} must be a non-negative safe integer`);
|
|
return parsed;
|
|
}
|
|
|
|
function boolean(value: unknown, label: string): boolean {
|
|
if (typeof value !== "boolean") throw new Error(`${label} must be a boolean`);
|
|
return value;
|
|
}
|
|
|
|
function stringArray(value: unknown, label: string): string[] {
|
|
return array(value, label).map((item, index) => string(item, `${label}[${index}]`));
|
|
}
|
|
|
|
function optionalStringArray(value: unknown, label: string): string[] | undefined {
|
|
if (value === undefined) return undefined;
|
|
return stringArray(value, label);
|
|
}
|
|
|
|
function commandState(value: unknown): GameClientBridgeCommandState {
|
|
const parsed = string(value, "state") as GameClientBridgeCommandState;
|
|
if (!commandStates.has(parsed)) throw new Error("Game Client Bridge command state is invalid");
|
|
return parsed;
|
|
}
|
|
|
|
function resultStatus(value: unknown): GameClientBridgeResultStatus {
|
|
const parsed = string(value, "result.status") as GameClientBridgeResultStatus;
|
|
if (!resultStatuses.has(parsed)) throw new Error("Game Client Bridge result status is invalid");
|
|
return parsed;
|
|
}
|
|
|
|
function copyOptionalString<T extends object>(source: Record<string, unknown>, target: T, key: keyof T): void {
|
|
const parsed = optionalString(source[key as string], String(key));
|
|
if (parsed) (target as Record<keyof T, unknown>)[key] = parsed;
|
|
}
|