fix: align password policy to 8-72 printable ASCII on register (WealthLedger/backend#60) #109

Merged
codex-bot merged 1 commits from fix/60-ascii-password-policy into develop 2026-10-16 10:00:48 +03:00
4 changed files with 127 additions and 15 deletions
+55 -2
View File
@@ -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 = {
@@ -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(
+28 -10
View File
@@ -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;
}
+41
View File
@@ -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')