fix: align password policy to 8-72 printable ASCII on register (WealthLedger/backend#60)
Backend verify / commits (pull_request) Successful in 32s
Backend verify / security (pull_request) Successful in 13s
Backend verify / verify (pull_request) Successful in 4m59s

Register accepts only printable ASCII (letters, digits, symbols, space),
8-72 characters, so characters equal bytes and the bcrypt 72-byte limit
holds without silent truncation. Login only caps the password at 72 bytes
so accounts created under the previous policy can still sign in.

CredentialsDto is split into RegisterDto and LoginDto; OpenAPI publishes
the register pattern and length bounds.
This commit is contained in:
2026-10-16 09:41:58 +03:00
parent 774e58ef80
commit ec95d2756d
4 changed files with 127 additions and 15 deletions
+55 -2
View File
@@ -1,6 +1,6 @@
import { BadRequestException, ValidationPipe } from '@nestjs/common'; import { BadRequestException, ValidationPipe } from '@nestjs/common';
import { CredentialsDto } from '../modules/auth/presentation/auth.dto'; import { LoginDto, RegisterDto } from '../modules/auth/presentation/auth.dto';
import { import {
CreateBankCardDto, CreateBankCardDto,
UpdateBankCardDto, UpdateBankCardDto,
@@ -11,7 +11,7 @@ describe('DTO validation', () => {
forbidUnknownValues: true, transform: true, whitelist: true }); forbidUnknownValues: true, transform: true, whitelist: true });
it('requires credentials and normalizes a valid email', async () => { 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( await expect(pipe.transform({}, metadata)).rejects.toBeInstanceOf(
BadRequestException, 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 () => { it('requires and validates nested money in a new card', async () => {
const metadata = { type: 'body' as const, metatype: CreateBankCardDto }; const metadata = { type: 'body' as const, metatype: CreateBankCardDto };
const card = { const card = {
@@ -24,7 +24,7 @@ import { ApiProblemResponses } from '../../../common/decorators/api-safe-error-r
import { HttpErrorCode } from '../../../common/error-code'; import { HttpErrorCode } from '../../../common/error-code';
import { AuthService } from '../application/auth.service'; import { AuthService } from '../application/auth.service';
import { AccessPayload, AuthenticationResult } from '../application/auth.types'; import { AccessPayload, AuthenticationResult } from '../application/auth.types';
import { CredentialsDto } from './auth.dto'; import { LoginDto, RegisterDto } from './auth.dto';
import { JwtAuthGuard } from './jwt-auth.guard'; import { JwtAuthGuard } from './jwt-auth.guard';
const REFRESH_COOKIE = 'refresh_token'; const REFRESH_COOKIE = 'refresh_token';
@@ -56,7 +56,7 @@ export class AuthController {
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@ApiOkResponse({ type: AuthenticationDto }) @ApiOkResponse({ type: AuthenticationDto })
async register( async register(
@Body() credentials: CredentialsDto, @Body() credentials: RegisterDto,
@Res({ passthrough: true }) response: Response, @Res({ passthrough: true }) response: Response,
) { ) {
return this.respond( return this.respond(
@@ -69,7 +69,7 @@ export class AuthController {
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@ApiOkResponse({ type: AuthenticationDto }) @ApiOkResponse({ type: AuthenticationDto })
async login( async login(
@Body() credentials: CredentialsDto, @Body() credentials: LoginDto,
@Res({ passthrough: true }) response: Response, @Res({ passthrough: true }) response: Response,
) { ) {
return this.respond( return this.respond(
+28 -10
View File
@@ -1,8 +1,9 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { Transform } from 'class-transformer'; import { Transform } from 'class-transformer';
import { import {
IsNotEmpty,
IsString, IsString,
MinLength, Matches,
Validate, Validate,
ValidatorConstraint, ValidatorConstraint,
} from 'class-validator'; } from 'class-validator';
@@ -13,8 +14,10 @@ const FIRST_CHARACTER_INDEX = 0;
const MAX_EMAIL_LENGTH = 254; const MAX_EMAIL_LENGTH = 254;
const ASCII_SPACE = 32; const ASCII_SPACE = 32;
const ASCII_DELETE = 127; 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 MAX_PASSWORD_BYTES = 72;
const PASSWORD_PATTERN = `^[\\x20-\\x7E]{${MIN_PASSWORD_LENGTH},${MAX_PASSWORD_BYTES}}$`;
@ValidatorConstraint({ name: 'email' }) @ValidatorConstraint({ name: 'email' })
class EmailConstraint { class EmailConstraint {
@@ -40,8 +43,9 @@ class EmailConstraint {
} }
} }
@ValidatorConstraint({ name: 'passwordBytes' }) // Login stays lenient: accounts created under an older policy must still sign in.
class PasswordBytesConstraint { @ValidatorConstraint({ name: 'passwordMaxBytes' })
class PasswordMaxBytesConstraint {
validate(value: unknown): boolean { validate(value: unknown): boolean {
return ( return (
typeof value === 'string' && typeof value === 'string' &&
@@ -50,11 +54,11 @@ class PasswordBytesConstraint {
} }
defaultMessage(): string { 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' }) @ApiProperty({ example: 'user@example.com' })
@IsString() @IsString()
@Transform(({ value }: { value: unknown }) => @Transform(({ value }: { value: unknown }) =>
@@ -62,14 +66,28 @@ export class CredentialsDto {
) )
@Validate(EmailConstraint) @Validate(EmailConstraint)
declare email: string; declare email: string;
}
export class RegisterDto extends EmailDto {
@ApiProperty({ @ApiProperty({
minLength: MIN_PASSWORD_LENGTH, description: `Raw password: ${MIN_PASSWORD_LENGTH}-${MAX_PASSWORD_BYTES} printable ASCII characters (letters, digits, symbols, space). Not trimmed or normalized.`,
maxLength: MAX_PASSWORD_BYTES, pattern: PASSWORD_PATTERN,
example: 'a secure password', example: 'a secure password',
}) })
@IsString() @IsString()
@MinLength(MIN_PASSWORD_LENGTH) @Matches(new RegExp(PASSWORD_PATTERN), {
@Validate(PasswordBytesConstraint) 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; declare password: string;
} }
+41
View File
@@ -2,7 +2,9 @@ import { INestApplication } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
import { Test, TestingModule } from '@nestjs/testing'; import { Test, TestingModule } from '@nestjs/testing';
import * as bcrypt from 'bcrypt';
import { PARAMS_PROVIDER_TOKEN } from 'nestjs-pino'; import { PARAMS_PROVIDER_TOKEN } from 'nestjs-pino';
import { randomUUID } from 'node:crypto';
import request from 'supertest'; import request from 'supertest';
import { App } from 'supertest/types'; import { App } from 'supertest/types';
@@ -265,6 +267,45 @@ describe('Authentication (e2e)', () => {
expect(await app.get(PrismaService).user.count()).toBe(0); 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 () => { it('guards T-Bank connection and never returns a token', async () => {
const registration = await request(app.getHttpServer()) const registration = await request(app.getHttpServer())
.post('/api/v1/auth/register') .post('/api/v1/auth/register')