refactor: strengthen client boundaries and quality gates
This commit is contained in:
@@ -0,0 +1,55 @@
|
||||
import { registerDecorator, type ValidationArguments, type ValidationOptions } from 'class-validator';
|
||||
|
||||
type BoundedJsonOptions = {
|
||||
maxDepth?: number;
|
||||
maxKeys?: number;
|
||||
maxStringLength?: number;
|
||||
};
|
||||
|
||||
const FORBIDDEN_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
|
||||
|
||||
export function IsBoundedJsonObject(options: BoundedJsonOptions = {}, validationOptions?: ValidationOptions) {
|
||||
return (target: object, propertyName: string) =>
|
||||
registerDecorator({
|
||||
name: 'isBoundedJsonObject',
|
||||
target: target.constructor,
|
||||
propertyName,
|
||||
constraints: [options],
|
||||
options: validationOptions,
|
||||
validator: {
|
||||
validate(value: unknown, args: ValidationArguments) {
|
||||
if (value === undefined || value === null) return true;
|
||||
const [constraints] = args.constraints as [BoundedJsonOptions];
|
||||
return isBoundedJsonValue(value, {
|
||||
maxDepth: constraints.maxDepth ?? 4,
|
||||
maxKeys: constraints.maxKeys ?? 100,
|
||||
maxStringLength: constraints.maxStringLength ?? 2_000,
|
||||
});
|
||||
},
|
||||
defaultMessage(args: ValidationArguments) {
|
||||
return `${args.property} contains too many, too deeply nested, or unsafe values`;
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function isBoundedJsonValue(value: unknown, limits: Required<BoundedJsonOptions>) {
|
||||
let keyCount = 0;
|
||||
const visit = (current: unknown, depth: number): boolean => {
|
||||
if (depth > limits.maxDepth) return false;
|
||||
if (current == null || typeof current === 'boolean' || typeof current === 'number') return true;
|
||||
if (typeof current === 'string') return current.length <= limits.maxStringLength;
|
||||
if (Array.isArray(current)) {
|
||||
keyCount += current.length;
|
||||
return keyCount <= limits.maxKeys && current.every((item) => visit(item, depth + 1));
|
||||
}
|
||||
if (typeof current !== 'object') return false;
|
||||
const entries = Object.entries(current as Record<string, unknown>);
|
||||
keyCount += entries.length;
|
||||
return (
|
||||
keyCount <= limits.maxKeys &&
|
||||
entries.every(([key, item]) => key.length <= 128 && !FORBIDDEN_KEYS.has(key) && visit(item, depth + 1))
|
||||
);
|
||||
};
|
||||
return visit(value, 0);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { ClientBatchTaskDto, ClientImportConfirmDto, ClientStatusChangeDto } from './client-write.dto';
|
||||
import { ClientBatchTaskDto, ClientDeleteResourceDto, ClientImportConfirmDto } from './client-write.dto';
|
||||
import { strictValidationPipe } from './strict-validation.pipe';
|
||||
|
||||
function validate<T>(metatype: new () => T, value: unknown) {
|
||||
@@ -8,10 +8,12 @@ function validate<T>(metatype: new () => T, value: unknown) {
|
||||
|
||||
describe('strict client write DTOs', () => {
|
||||
it('accepts an import confirmation without a client-supplied phones array', async () => {
|
||||
await expect(validate(ClientImportConfirmDto, {
|
||||
content: '【测试】验证码 ${code}',
|
||||
importContent: 'phone,code\n13800000001,1234',
|
||||
})).resolves.toEqual(expect.objectContaining({ importContent: expect.any(String) }));
|
||||
await expect(
|
||||
validate(ClientImportConfirmDto, {
|
||||
content: '【测试】验证码 ${code}',
|
||||
importContent: 'phone,code\n13800000001,1234',
|
||||
}),
|
||||
).resolves.toEqual(expect.objectContaining({ importContent: expect.any(String) }));
|
||||
});
|
||||
|
||||
it('rejects a direct batch task without validated phone numbers', async () => {
|
||||
@@ -19,7 +21,28 @@ describe('strict client write DTOs', () => {
|
||||
});
|
||||
|
||||
it('rejects a client-supplied operator identity', async () => {
|
||||
await expect(validate(ClientStatusChangeDto, { status: 'disabled', operatorId: 'another-user' }))
|
||||
.rejects.toBeInstanceOf(BadRequestException);
|
||||
await expect(
|
||||
validate(ClientDeleteResourceDto, { status: 'deleted', operatorId: 'another-user' }),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('rejects a client-supplied tenant identity', async () => {
|
||||
await expect(
|
||||
validate(ClientBatchTaskDto, {
|
||||
tenantId: 'other-tenant',
|
||||
content: '【测试】通知',
|
||||
phones: ['13800000001'],
|
||||
}),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('rejects deeply nested or prototype-like dynamic values', async () => {
|
||||
await expect(
|
||||
validate(ClientBatchTaskDto, {
|
||||
content: '【测试】通知',
|
||||
phones: ['13800000001'],
|
||||
variables: { safe: { nested: { too: { deep: { value: 'x' } } } } },
|
||||
}),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUrl,
|
||||
IsDateString,
|
||||
Matches,
|
||||
Max,
|
||||
MaxLength,
|
||||
@@ -17,26 +18,25 @@ import {
|
||||
MinLength,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
import { IsBoundedJsonObject } from './bounded-json-object.validator';
|
||||
|
||||
export class ClientCertificationSubmissionDto {
|
||||
@IsOptional() @IsString() @MaxLength(64) tenantId?: string;
|
||||
@IsString() @MinLength(1) @MaxLength(200) companyName!: string;
|
||||
@IsOptional() @IsString() @MaxLength(100) licenseNo?: string;
|
||||
@IsOptional() @IsString() @MaxLength(100) contactName?: string;
|
||||
@IsOptional() @Matches(/^\+?[0-9-]{6,24}$/) contactPhone?: string;
|
||||
@IsOptional() @IsObject() materials?: Record<string, unknown>;
|
||||
@IsOptional() @IsObject() @IsBoundedJsonObject({ maxKeys: 100, maxDepth: 4 }) materials?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export class ClientTaskBaseDto {
|
||||
@IsOptional() @IsString() @MaxLength(64) tenantId?: string;
|
||||
@IsOptional() @IsString() @MaxLength(64) applicationId?: string;
|
||||
@IsOptional() @IsString() @MaxLength(64) templateId?: string;
|
||||
@IsString() @MinLength(1) @MaxLength(5000) content!: string;
|
||||
@IsOptional() @IsString() @MaxLength(64) category?: string;
|
||||
@IsOptional() @IsIn(['immediate', 'scheduled']) sendMode?: 'immediate' | 'scheduled';
|
||||
@IsOptional() @IsString() @MaxLength(64) scheduledAt?: string;
|
||||
@IsOptional() @IsObject() variables?: Record<string, unknown>;
|
||||
@IsOptional() @IsString() @MaxLength(64) requestedAt?: string;
|
||||
@IsOptional() @IsDateString({ strict: true }) scheduledAt?: string;
|
||||
@IsOptional() @IsObject() @IsBoundedJsonObject({ maxKeys: 100, maxDepth: 4 }) variables?: Record<string, unknown>;
|
||||
@IsOptional() @IsDateString({ strict: true }) requestedAt?: string;
|
||||
@IsOptional() @IsString() @MaxLength(128) clientMessageId?: string;
|
||||
}
|
||||
|
||||
@@ -45,7 +45,6 @@ export class ClientBatchTaskDto extends ClientTaskBaseDto {
|
||||
}
|
||||
|
||||
export class ClientImportPreviewDto {
|
||||
@IsOptional() @IsString() @MaxLength(64) tenantId?: string;
|
||||
@IsOptional() @IsString() @MaxLength(64) applicationId?: string;
|
||||
@IsString() @MinLength(1) @MaxLength(5_000_000) content!: string;
|
||||
@IsOptional() @IsString() @MaxLength(255) fileName?: string;
|
||||
@@ -60,7 +59,6 @@ export class ClientImportConfirmDto extends ClientTaskBaseDto {
|
||||
}
|
||||
|
||||
export class ClientBillingEstimateDto {
|
||||
@IsOptional() @IsString() @MaxLength(64) tenantId?: string;
|
||||
@IsOptional() @IsString() @MaxLength(64) applicationId?: string;
|
||||
@IsString() @MinLength(1) @MaxLength(5000) content!: string;
|
||||
@Type(() => Number) @IsInt() @Min(1) @Max(100000) phoneCount!: number;
|
||||
@@ -69,7 +67,6 @@ export class ClientBillingEstimateDto {
|
||||
}
|
||||
|
||||
export class ClientSmsApplicationDto {
|
||||
@IsOptional() @IsString() @MaxLength(64) tenantId?: string;
|
||||
@IsString() @MinLength(1) @MaxLength(100) name!: string;
|
||||
@IsOptional() @IsString() @MaxLength(500) scene?: string;
|
||||
@IsOptional() @IsUrl({ require_tld: false }) @MaxLength(2048) callbackUrl?: string;
|
||||
@@ -93,11 +90,10 @@ export class ClientSmsApplicationDto {
|
||||
}
|
||||
|
||||
export class ClientSmsSignatureDto {
|
||||
@IsOptional() @IsString() @MaxLength(64) tenantId?: string;
|
||||
@IsOptional() @IsString() @MaxLength(64) applicationId?: string;
|
||||
@IsString() @MinLength(1) @MaxLength(100) name!: string;
|
||||
@IsOptional() @IsString() @MaxLength(500) purpose?: string;
|
||||
@IsOptional() @IsObject() drainageInfo?: Record<string, unknown>;
|
||||
@IsOptional() @IsObject() @IsBoundedJsonObject({ maxKeys: 100, maxDepth: 4 }) drainageInfo?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export class ClientSmsSignatureUpdateDto extends PartialType(ClientSmsSignatureDto) {
|
||||
@@ -108,7 +104,7 @@ export class ClientDrainageInfoDto {
|
||||
@IsString() @MinLength(1) @MaxLength(200) siteName!: string;
|
||||
@IsUrl({ require_tld: false }) @MaxLength(2048) url!: string;
|
||||
@IsOptional() @IsString() @MaxLength(1000) remark?: string;
|
||||
@IsOptional() @IsObject() reportValues?: Record<string, unknown>;
|
||||
@IsOptional() @IsObject() @IsBoundedJsonObject({ maxKeys: 200, maxDepth: 4 }) reportValues?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export class ClientDrainageInfoUpdateDto extends PartialType(ClientDrainageInfoDto) {}
|
||||
@@ -127,13 +123,17 @@ class TemplateVariableDto {
|
||||
}
|
||||
|
||||
export class ClientSmsTemplateDto {
|
||||
@IsOptional() @IsString() @MaxLength(64) tenantId?: string;
|
||||
@IsString() @MaxLength(64) applicationId!: string;
|
||||
@IsOptional() @IsString() @MaxLength(64) signatureId?: string;
|
||||
@IsString() @MinLength(1) @MaxLength(200) name!: string;
|
||||
@IsString() @MinLength(1) @MaxLength(5000) content!: string;
|
||||
@IsOptional() @IsString() @MaxLength(64) category?: string;
|
||||
@IsOptional() @IsArray() @ArrayMaxSize(100) @ValidateNested({ each: true }) @Type(() => TemplateVariableDto) variables?: TemplateVariableDto[];
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayMaxSize(100)
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => TemplateVariableDto)
|
||||
variables?: TemplateVariableDto[];
|
||||
}
|
||||
|
||||
export class ClientSmsTemplateUpdateDto extends PartialType(ClientSmsTemplateDto) {
|
||||
@@ -152,3 +152,32 @@ export class ClientStatusChangeDto {
|
||||
@IsOptional() @IsBoolean() deleteAssociatedDrainage?: boolean;
|
||||
@IsOptional() @IsBoolean() abandonAssociatedReportTasks?: boolean;
|
||||
}
|
||||
|
||||
export class ClientSecretResetDto {
|
||||
@IsOptional() @IsString() @MaxLength(1000) reason?: string;
|
||||
@IsOptional() @IsString() @MaxLength(64) expectedUpdatedAt?: string;
|
||||
@IsOptional() @IsString() @MaxLength(128) idempotencyKey?: string;
|
||||
}
|
||||
|
||||
export class ClientApplicationStatusDto {
|
||||
@IsOptional() @IsIn(['active', 'disabled', 'disabling', 'deleted']) status?: string;
|
||||
@IsOptional() @IsString() @MaxLength(1000) reason?: string;
|
||||
@IsOptional() @IsBoolean() force?: boolean;
|
||||
@IsOptional() @IsString() @MaxLength(200) confirmName?: string;
|
||||
@IsOptional() @IsString() @MaxLength(200) confirmText?: string;
|
||||
@IsOptional() @IsString() @MaxLength(64) expectedUpdatedAt?: string;
|
||||
@IsOptional() @IsString() @MaxLength(128) idempotencyKey?: string;
|
||||
}
|
||||
|
||||
export class ClientDeleteResourceDto {
|
||||
@IsIn(['deleted']) status!: 'deleted';
|
||||
@IsOptional() @IsString() @MaxLength(1000) reason?: string;
|
||||
@IsOptional() @IsBoolean() force?: boolean;
|
||||
@IsOptional() @IsString() @MaxLength(200) confirmName?: string;
|
||||
@IsOptional() @IsString() @MaxLength(200) confirmText?: string;
|
||||
@IsOptional() @IsString() @MaxLength(64) expectedUpdatedAt?: string;
|
||||
@IsOptional() @IsString() @MaxLength(128) idempotencyKey?: string;
|
||||
@IsOptional() @IsBoolean() deleteAssociatedTemplates?: boolean;
|
||||
@IsOptional() @IsBoolean() deleteAssociatedDrainage?: boolean;
|
||||
@IsOptional() @IsBoolean() abandonAssociatedReportTasks?: boolean;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user