Bỏ qua để đến nội dung
Knowledge Hub

Lỗi hay gặp khi viết API: injection, phân quyền, secret, retry Trung cấp

Thời gian đọc ước tính: 13 phút

Trang này gom các lỗi bảo mật nằm ngay trong code handler: không cần kiến trúc phức tạp để sửa, chỉ cần biết nhìn vào đâu khi review.

Mỗi cặp ngắn gọn. Giải thích sâu, demo và bảng OWASP nằm ở Secure coding cho backend và các bài trong nhóm Security.

Mỗi lỗi trong trang là một chốt bị bỏ qua hoặc làm sai. Số trên sơ đồ khớp với nhóm heading bên dưới.

Đang tải diagram…

Nối chuỗi vào SQL qua lối thoát raw của ORM

Hay gặp

// Knex
await knex.raw(`SELECT name FROM users WHERE tenant_id = 1 AND name = '${name}'`);
// Kysely
await sql`SELECT name FROM users WHERE tenant_id = 1 AND name = '${sql.raw(name)}'`.execute(db);
// TypeORM QueryBuilder
await repo.createQueryBuilder('u').where(`u.name = '${name}'`).getMany();

Nên viết

await knex.raw('SELECT name FROM users WHERE tenant_id = ? AND name = ?', [tenantId, name]);
await sql`SELECT name FROM users WHERE tenant_id = ${tenantId} AND name = ${name}`.execute(db);
await repo.createQueryBuilder('u').where('u.name = :name', { name }).getMany();

Vì sao

  • Chạy thử với name = x' OR '1'='1: bản Knex và Kysely bad trả cả 3 user, gồm user của tenant 2. Bản good trả rỗng.
  • ${} trong tagged template sql của Kysely luôn thành parameter; sql.raw thì không. Tài liệu TypeORM gọi việc nối giá trị vào where là “not safe”.
  • Tên cột, ASC/DESC không parameter được: dùng map allowlist. Xem SQL injection.

Mass assignment: đổ nguyên body vào update

Hay gặp

@Patch('me')
update(@CurrentUser() user: User, @Body() body: any) {
return this.users.update(user.id, { ...body }); // body có thể chứa role, isVerified
}

Nên viết

main.ts
app.useGlobalPipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true }));
export class UpdateMeDto {
@IsOptional() @IsString() @Length(1, 80)
displayName?: string;
}
@Patch('me')
update(@CurrentUser() user: User, @Body() dto: UpdateMeDto) {
return this.users.update(user.id, { displayName: dto.displayName });
}

Vì sao

  • Body {"displayName":"An","role":"admin"}: chỉ whitelist thì role bị bỏ thầm lặng; thêm forbidNonWhitelisted thì trả 400 property role should not exist. Đã chạy thử với ValidationPipe của @nestjs/common.
  • @Body() body: any hoặc DTO không decorator thì pipe không lọc gì cả.
  • OWASP Mass Assignment: dùng DTO chỉ chứa field người gọi được sửa. Xem Validate ở biên.

Tin giá tiền và userId do client gửi

Hay gặp

// body: { userId, productId, qty, price }
@Post('orders')
create(@Body() dto: CreateOrderDto) {
return this.orders.create({
userId: dto.userId,
productId: dto.productId,
qty: dto.qty,
total: dto.price * dto.qty,
});
}

Nên viết

// body: { productId, qty }
@Post('orders')
async create(@CurrentUser() user: User, @Body() dto: CreateOrderDto) {
const product = await this.products.findActiveOrFail(dto.productId);
return this.orders.create({
userId: user.id, // từ token đã verify
productId: product.id,
qty: dto.qty,
total: product.price * dto.qty, // giá từ database
});
}

Vì sao

  • Client là của người dùng: sửa request bằng DevTools hoặc curl mất vài giây. price: 1 là mua iPhone giá 1 đồng; userId khác là đặt đơn dưới tên người khác.
  • Quy tắc: thứ gì server tự biết (danh tính, giá, quyền, trạng thái) thì không nhận từ body. DTO không nên có field đó ngay từ đầu.
  • Validate qty có giới hạn (@IsInt() @Min(1) @Max(100)); số âm cũng là một cách “giảm giá”.

Lấy object theo id mà không kiểm tra chủ sở hữu (BOLA)

Hay gặp

@Get('orders/:id')
get(@Param('id', ParseIntPipe) id: number) {
return this.orderRepo.findOneByOrFail({ id }); // ai đăng nhập cũng đọc được mọi đơn
}

Nên viết

@Get('orders/:id')
async get(@CurrentUser() user: User, @Param('id', ParseIntPipe) id: number) {
const order = await this.orderRepo.findOneBy({ id, userId: user.id });
if (!order) throw new NotFoundException(); // 404, không 403: không xác nhận id tồn tại
return order;
}

Vì sao

  • Đổi /orders/1001 thành /orders/1002 là đọc đơn người khác. Đây là lỗi số 1 trong OWASP API Security Top 10.
  • Đã có guard xác thực không có nghĩa là đã phân quyền: guard biết bạn là ai, không biết object này của ai.
  • Đưa điều kiện sở hữu vào chính câu query thay vì đọc rồi so sánh, để không quên ở endpoint update/delete. Xem BOLA/IDOR.

jwt.decode thay vì verify

Hay gặp

const token = req.headers.authorization?.slice('Bearer '.length);
const payload = this.jwt.decode(token); // @nestjs/jwt, hoặc jsonwebtoken.decode
req.user = { id: payload.sub, role: payload.role };

Nên viết

const token = req.headers.authorization?.slice('Bearer '.length);
if (!token) throw new UnauthorizedException();
try {
const payload = await this.jwt.verifyAsync<JwtPayload>(token, { algorithms: ['HS256'] });
req.user = { id: payload.sub, role: payload.role };
} catch {
throw new UnauthorizedException();
}

Vì sao

  • Chạy thử: token ký bằng key của kẻ tấn công với role: 'admin'. decode trả nguyên payload; verifyAsync ném invalid signature.
  • README của jsonwebtoken: decode “will not verify whether the signature is valid. You should not use this for untrusted messages.”
  • Ghim algorithms để không nhận thuật toán ngoài dự kiến; kiểm tra thêm exp, iss, aud. Xem JWT: verifier phải kiểm tra gì.

CORS origin: true kèm credentials

Hay gặp

app.enableCors({ origin: true, credentials: true }); // "cho chạy được trên mọi môi trường"

Nên viết

app.enableCors({
origin: ['https://app.example.com', 'https://admin.example.com'],
credentials: true,
});

Vì sao

  • origin: true phản chiếu header Origin của request. Chạy thử với Origin: https://evil.example: response có Access-Control-Allow-Origin: https://evil.example và Allow-Credentials: true.
  • Nghĩa là trang bất kỳ có thể gọi API bằng cookie của nạn nhân và đọc được response. Bản allowlist không trả Allow-Origin cho origin lạ.
  • NestJS enableCors dùng package cors của Express nên option giống nhau. Xem Cấu hình CORS đúng.

Redirect tới URL lấy từ query string

Hay gặp

@Get('login/callback')
callback(@Query('next') next: string, @Res() res: Response) {
// "đã chặn": chỉ cho đường dẫn bắt đầu bằng /
if (!next.startsWith('/')) next = '/';
return res.redirect(next);
}

Nên viết

const APP_ORIGIN = 'https://app.example.com';
function safeNext(next: string | undefined): string {
if (!next) return '/';
const url = new URL(next, APP_ORIGIN);
if (url.origin !== APP_ORIGIN) return '/';
return url.pathname + url.search;
}
// res.redirect(safeNext(next))

Vì sao

  • //evil.example và /\evil.example đều bắt đầu bằng /. Bộ parse URL chuẩn WHATWG (Node URL, trình duyệt) hiểu cả hai thành https://evil.example/. Đã chạy thử.
  • Hậu quả: link đăng nhập thật của bạn đưa người dùng sang trang phishing ngay sau khi họ vừa nhập mật khẩu.
  • OWASP khuyến nghị ưu tiên map id phía server (?next=orders) hơn nhận URL; nếu nhận URL thì so sánh thành phần đã parse, không so chuỗi.

Math.random() cho token

Hay gặp

const resetToken = Math.random().toString(36).slice(2); // ~11 ký tự

Nên viết

import { randomBytes, randomUUID, randomInt } from 'node:crypto';
const resetToken = randomBytes(32).toString('base64url'); // 256 bit
const inviteId = randomUUID();
const otp = randomInt(0, 1_000_000).toString().padStart(6, '0');

Vì sao

  • MDN: Math.random() không cho số ngẫu nhiên an toàn mật mã. Token sinh ra còn ngắn (khoảng 11 ký tự).
  • Token reset mật khẩu đoán được là chiếm tài khoản. Lưu hash của token trong DB, không lưu nguyên văn.
  • Chi tiết và demo: Random và so sánh hằng thời gian.

So sánh secret bằng ===

Hay gặp

const expected = createHmac('sha256', SECRET).update(rawBody).digest('hex');
if (req.headers['x-signature'] !== expected) throw new UnauthorizedException();

Nên viết

import { createHash, createHmac, timingSafeEqual } from 'node:crypto';
const expected = createHmac('sha256', SECRET).update(rawBody).digest();
const given = Buffer.from(String(req.headers['x-signature'] ?? ''), 'hex');
// Băm cả hai về 32 byte: timingSafeEqual ném lỗi khi khác độ dài
const sha = (b: Buffer) => createHash('sha256').update(b).digest();
if (!timingSafeEqual(sha(given), sha(expected))) throw new UnauthorizedException();

Vì sao

  • === dừng ở byte khác đầu tiên, nên thời gian phản hồi rò rỉ số byte đúng. Kẻ tấn công đo đủ nhiều lần là dò dần được chữ ký.
  • timingSafeEqual ném ERR_CRYPTO_TIMING_SAFE_EQUAL_LENGTH khi hai Buffer khác độ dài (đã chạy thử). Hoặc kiểm tra độ dài trước, hoặc băm về cùng độ dài như trên.
  • Áp dụng cho chữ ký webhook, API key, token so với giá trị lưu. Xem webhook signature.

Log token, mật khẩu, dữ liệu cá nhân

Hay gặp

this.logger.log(`login ${JSON.stringify(req.body)}`); // { email, password }
this.logger.debug(`calling partner with token ${accessToken}`);
logger.info({ req }, 'incoming'); // headers.authorization, cookie

Nên viết

const logger = pino({
redact: {
paths: ['req.headers.authorization', 'req.headers.cookie', 'body.password', '*.token'],
censor: '[REDACTED]',
},
});
logger.info({ userId: user.id, route: 'POST /login', outcome: 'success' }, 'auth.login');

Vì sao

  • Log được giữ hàng tháng, nhiều người và nhiều hệ thống đọc được, ít khi được bảo vệ như database. Một dòng debug là token còn hạn nằm trong kho log.
  • OWASP Logging Cheat Sheet liệt kê thứ không ghi trực tiếp: access token, session id, mật khẩu, connection string, PII nhạy cảm.
  • Log id và kết quả, không log payload. Xem Logging.

Trả stack trace và lỗi nội bộ cho client

Hay gặp

@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
catch(err: any, host: ArgumentsHost) {
host.switchToHttp().getResponse().status(500)
.json({ message: err.message, stack: err.stack });
// message: duplicate key value violates unique constraint "users_email_key"
}
}

Nên viết

@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
private readonly logger = new Logger('Unhandled');
catch(err: unknown, host: ArgumentsHost) {
const res = host.switchToHttp().getResponse();
if (err instanceof HttpException) return res.status(err.getStatus()).json(err.getResponse());
const errorId = randomUUID();
this.logger.error({ errorId, err });
res.status(500).json({ statusCode: 500, message: 'Internal server error', errorId });
}
}

Vì sao

  • Tên constraint, câu SQL, đường dẫn file giúp kẻ tấn công đoán schema và vị trí injection.
  • Filter mặc định của NestJS đã trả {"statusCode":500,"message":"Internal server error"} cho lỗi lạ; lỗi thường do filter tự viết. Express không có NODE_ENV=production thì handler mặc định gửi nguyên err.stack (đã chạy thử với Express 5).
  • errorId trong response giúp support tra log. Xem Lỗi không được kể chuyện nội bộ.

Retry POST không có idempotency key

Hay gặp

// client gọi service thanh toán, timeout thì thử lại
for (let attempt = 1; attempt <= 3; attempt++) {
try {
return await http.post('/payments', { orderId, amount });
} catch (err) {
if (attempt === 3) throw err;
}
}

Nên viết

const key = randomUUID(); // tạo MỘT lần, ngoài vòng retry
for (let attempt = 1; attempt <= 3; attempt++) {
try {
return await http.post('/payments', { orderId, amount }, {
headers: { 'Idempotency-Key': key },
});
} catch (err) {
if (attempt === 3 || !isRetryable(err)) throw err;
await sleep(backoffWithJitter(attempt));
}
}
// server: UNIQUE (idempotency_key) + INSERT ... ON CONFLICT, trả lại kết quả cũ

Vì sao

  • Timeout không có nghĩa là thất bại: request có thể đã xử lý xong, chỉ response bị mất. Retry POST là charge lần hai.
  • RFC 9110 xếp POST vào nhóm không idempotent. Key giúp server nhận ra “lần thử lại của cùng thao tác” và trả kết quả cũ.
  • Key phải sinh ngoài vòng lặp; sinh trong vòng lặp là mỗi lần một key, vô dụng. Phía server: ON CONFLICT, Khi nào retry.
  • Không template string chứa SQL với biến; grep raw(, sql.raw, whereRaw, Unsafe, where(`.
  • ValidationPipe toàn cục có whitelist + forbidNonWhitelisted; không @Body() body: any; DTO riêng cho từng use case.
  • Giá, userId, role, trạng thái lấy từ server, không từ body.
  • Mọi truy vấn theo id có điều kiện sở hữu/tenant ngay trong query.
  • JWT qua verify/verifyAsync có algorithms; không decode để xác thực.
  • CORS là danh sách origin cụ thể khi bật credentials.
  • Redirect không nhận URL tự do; nếu nhận thì parse và so origin.
  • Token sinh bằng node:crypto; so sánh secret bằng timingSafeEqual.
  • Log không chứa body đăng nhập, header authorization, cookie, token.
  • 5xx trả message chung + errorId; production có NODE_ENV=production.
  • Mọi retry của POST có Idempotency-Key sinh một lần; server chống trùng bằng UNIQUE.

Tiếp theo: Hiệu năng.