From e03fad622bfec0fa7e42d536f65e361d319a1ac9 Mon Sep 17 00:00:00 2001 From: codex-bot Date: Fri, 16 Oct 2026 10:00:47 +0300 Subject: [PATCH] fix: align password policy to 8-72 printable ASCII on register (WealthLedger/backend#60) (#109) --- src/common/dto-validation.spec.ts | 57 ++++++++++++++++++- .../auth/presentation/auth.controller.ts | 6 +- src/modules/auth/presentation/auth.dto.ts | 38 +++++++++---- test/app.e2e-spec.ts | 41 +++++++++++++ 4 files changed, 127 insertions(+), 15 deletions(-) diff --git a/src/common/dto-validation.spec.ts b/src/common/dto-validation.spec.ts index 942fd3d..a15f985 100644 --- a/src/common/dto-validation.spec.ts +++ b/src/common/dto-validation.spec.ts @@ -1,6 +1,6 @@ import { BadRequestException, ValidationPipe } from '@nestjs/common'; -import { CredentialsDto } from '../modules/auth/presentation/auth.dto'; +import { LoginDto, RegisterDto } from '../modules/auth/presentation/auth.dto'; import { CreateBankCardDto, UpdateBankCardDto, @@ -11,7 +11,7 @@ describe('DTO validation', () => { forbidUnknownValues: true, transform: true, whitelist: true }); it('requires credentials and normalizes a valid email', async () => { - const metadata = { type: 'body' as const, metatype: CredentialsDto }; + const metadata = { type: 'body' as const, metatype: RegisterDto }; await expect(pipe.transform({}, metadata)).rejects.toBeInstanceOf( BadRequestException, ); @@ -29,6 +29,59 @@ describe('DTO validation', () => { }); }); + // Test intent: register accepts 8..72 printable ASCII characters only (bcrypt + // reads 72 bytes, so non-ASCII could be silently truncated); login only caps + // bytes so accounts created under the older policy can still sign in. + describe('password policy', () => { + const register = { type: 'body' as const, metatype: RegisterDto }; + const login = { type: 'body' as const, metatype: LoginDto }; + const body = (password: string) => ({ email: 'user@example.com', password }); + const accepts = (value: unknown, metadata: typeof login | typeof register) => + pipe.transform(value, metadata).then( + () => true, + (error: unknown) => { + if (!(error instanceof BadRequestException)) throw error; + return false; + }, + ); + + it.each([ + ['7 chars', 'a'.repeat(7), false], + ['8 chars', 'a'.repeat(8), true], + ['72 chars', 'a'.repeat(72), true], + ['73 chars', 'a'.repeat(73), false], + ['all printable symbols and space', 'Aa1 !~"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}', true], + ['Cyrillic, 8+ chars', 'пароль-пароль', false], + ['emoji', 'passw😀rd', false], + ['tab', 'pass\tword1', false], + ['newline', 'password1\n', false], + ['DEL', 'password\x7f1', false], + ['non-string', 12345678, false], + ])('register: %s', async (_, password, accepted) => { + await expect(accepts(body(password as string), register)).resolves.toBe(accepted); + }); + + it('register keeps raw whitespace untouched', async () => { + const password = ' spaced pass '; + await expect(pipe.transform(body(password), register)).resolves.toEqual( + body(password), + ); + }); + + it.each([ + ['legacy Cyrillic password', 'пароль-пароль', true], + ['short legacy password', 'short', true], + ['72 bytes', 'a'.repeat(72), true], + ['73 bytes', 'a'.repeat(73), false], + ['36 Cyrillic letters = 72 bytes', 'я'.repeat(36), true], + ['37 Cyrillic letters = 74 bytes', 'я'.repeat(37), false], + ['empty', '', false], + ['non-string', 12345678, false], + ])('login: %s', async (_, password, accepted) => { + await expect(accepts(body(password as string), login)).resolves.toBe(accepted); + }); + }); + it('requires and validates nested money in a new card', async () => { const metadata = { type: 'body' as const, metatype: CreateBankCardDto }; const card = { diff --git a/src/modules/auth/presentation/auth.controller.ts b/src/modules/auth/presentation/auth.controller.ts index cf50136..1fdbed1 100644 --- a/src/modules/auth/presentation/auth.controller.ts +++ b/src/modules/auth/presentation/auth.controller.ts @@ -24,7 +24,7 @@ import { ApiProblemResponses } from '../../../common/decorators/api-safe-error-r import { HttpErrorCode } from '../../../common/error-code'; import { AuthService } from '../application/auth.service'; import { AccessPayload, AuthenticationResult } from '../application/auth.types'; -import { CredentialsDto } from './auth.dto'; +import { LoginDto, RegisterDto } from './auth.dto'; import { JwtAuthGuard } from './jwt-auth.guard'; const REFRESH_COOKIE = 'refresh_token'; @@ -56,7 +56,7 @@ export class AuthController { @HttpCode(HttpStatus.OK) @ApiOkResponse({ type: AuthenticationDto }) async register( - @Body() credentials: CredentialsDto, + @Body() credentials: RegisterDto, @Res({ passthrough: true }) response: Response, ) { return this.respond( @@ -69,7 +69,7 @@ export class AuthController { @HttpCode(HttpStatus.OK) @ApiOkResponse({ type: AuthenticationDto }) async login( - @Body() credentials: CredentialsDto, + @Body() credentials: LoginDto, @Res({ passthrough: true }) response: Response, ) { return this.respond( diff --git a/src/modules/auth/presentation/auth.dto.ts b/src/modules/auth/presentation/auth.dto.ts index c04bee2..f42aa15 100644 --- a/src/modules/auth/presentation/auth.dto.ts +++ b/src/modules/auth/presentation/auth.dto.ts @@ -1,8 +1,9 @@ import { ApiProperty } from '@nestjs/swagger'; import { Transform } from 'class-transformer'; import { + IsNotEmpty, IsString, - MinLength, + Matches, Validate, ValidatorConstraint, } from 'class-validator'; @@ -13,8 +14,10 @@ const FIRST_CHARACTER_INDEX = 0; const MAX_EMAIL_LENGTH = 254; const ASCII_SPACE = 32; const ASCII_DELETE = 127; -const MIN_PASSWORD_LENGTH = 12; +// bcrypt reads at most 72 bytes; printable ASCII keeps characters == bytes. +const MIN_PASSWORD_LENGTH = 8; const MAX_PASSWORD_BYTES = 72; +const PASSWORD_PATTERN = `^[\\x20-\\x7E]{${MIN_PASSWORD_LENGTH},${MAX_PASSWORD_BYTES}}$`; @ValidatorConstraint({ name: 'email' }) class EmailConstraint { @@ -40,8 +43,9 @@ class EmailConstraint { } } -@ValidatorConstraint({ name: 'passwordBytes' }) -class PasswordBytesConstraint { +// Login stays lenient: accounts created under an older policy must still sign in. +@ValidatorConstraint({ name: 'passwordMaxBytes' }) +class PasswordMaxBytesConstraint { validate(value: unknown): boolean { return ( typeof value === 'string' && @@ -50,11 +54,11 @@ class PasswordBytesConstraint { } defaultMessage(): string { - return 'must not exceed 72 UTF-8 bytes'; + return `must not exceed ${MAX_PASSWORD_BYTES} UTF-8 bytes`; } } -export class CredentialsDto { +class EmailDto { @ApiProperty({ example: 'user@example.com' }) @IsString() @Transform(({ value }: { value: unknown }) => @@ -62,14 +66,28 @@ export class CredentialsDto { ) @Validate(EmailConstraint) declare email: string; +} +export class RegisterDto extends EmailDto { @ApiProperty({ - minLength: MIN_PASSWORD_LENGTH, - maxLength: MAX_PASSWORD_BYTES, + description: `Raw password: ${MIN_PASSWORD_LENGTH}-${MAX_PASSWORD_BYTES} printable ASCII characters (letters, digits, symbols, space). Not trimmed or normalized.`, + pattern: PASSWORD_PATTERN, example: 'a secure password', }) @IsString() - @MinLength(MIN_PASSWORD_LENGTH) - @Validate(PasswordBytesConstraint) + @Matches(new RegExp(PASSWORD_PATTERN), { + message: `must be ${MIN_PASSWORD_LENGTH}-${MAX_PASSWORD_BYTES} printable ASCII characters`, + }) + declare password: string; +} + +export class LoginDto extends EmailDto { + @ApiProperty({ + description: `Raw password, at most ${MAX_PASSWORD_BYTES} UTF-8 bytes. Not trimmed or normalized.`, + example: 'a secure password', + }) + @IsString() + @IsNotEmpty() + @Validate(PasswordMaxBytesConstraint) declare password: string; } diff --git a/test/app.e2e-spec.ts b/test/app.e2e-spec.ts index 9083f05..8eb0745 100644 --- a/test/app.e2e-spec.ts +++ b/test/app.e2e-spec.ts @@ -2,7 +2,9 @@ import { INestApplication } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { JwtService } from '@nestjs/jwt'; import { Test, TestingModule } from '@nestjs/testing'; +import * as bcrypt from 'bcrypt'; import { PARAMS_PROVIDER_TOKEN } from 'nestjs-pino'; +import { randomUUID } from 'node:crypto'; import request from 'supertest'; import { App } from 'supertest/types'; @@ -265,6 +267,45 @@ describe('Authentication (e2e)', () => { expect(await app.get(PrismaService).user.count()).toBe(0); }); + it('applies the ASCII 8..72 password policy on register and keeps login lenient', async () => { + const validation = (detail: string) => ({ + status: 400, + code: 'VALIDATION_ERROR', + errors: [{ pointer: '#/password', detail }], + }); + for (const [index, password] of ['a'.repeat(8), ' '.repeat(2) + 'b'.repeat(70)].entries()) { + const credentials = { email: `user${index}@example.com`, password }; + await request(app.getHttpServer()).post('/api/v1/auth/register').send(credentials).expect(200); + await request(app.getHttpServer()).post('/api/v1/auth/login').send(credentials).expect(200); + } + for (const password of ['a'.repeat(7), 'a'.repeat(73), 'пароль-пароль', 'passw😀rd', 'pass\tword1']) { + await request(app.getHttpServer()) + .post('/api/v1/auth/register') + .send({ email: 'rejected@example.com', password }) + .expect(400) + .expect('Content-Type', /application\/problem\+json/) + .expect(({ body }: { body: unknown }) => + expect(body).toEqual(validation('must be 8-72 printable ASCII characters')), + ); + } + expect(await app.get(PrismaService).user.count()).toBe(2); + + // An account created under the previous policy (Cyrillic, 12+ chars) must still log in. + const legacy = { email: 'legacy@example.com', password: 'пароль-пароль' }; + const prisma = app.get(PrismaService); + await prisma.user.create({ + data: { id: randomUUID(), email: legacy.email, passwordHash: await bcrypt.hash(legacy.password, 4) }, + }); + await request(app.getHttpServer()).post('/api/v1/auth/login').send(legacy).expect(200); + await request(app.getHttpServer()) + .post('/api/v1/auth/login') + .send({ email: legacy.email, password: 'a'.repeat(73) }) + .expect(400) + .expect(({ body }: { body: unknown }) => + expect(body).toEqual(validation('must not exceed 72 UTF-8 bytes')), + ); + }); + it('guards T-Bank connection and never returns a token', async () => { const registration = await request(app.getHttpServer()) .post('/api/v1/auth/register')