6.4 · Service patterns + raw SQL + SQL injection
บทนี้เจาะลึกรูปแบบการเขียน SQL ของ benefit-api ซึ่งเป็นจุดที่มี technical debt หนักที่สุดของ service นี้ — มี SQL injection หลายสิบจุดเพราะใช้ template literal โดยไม่ parameterize
รูปแบบ raw SQL ที่ใช้
มี 3 รูปแบบที่อยู่ใน codebase:
1. entityManager.query(templateLiteral) — พบบ่อยที่สุด, อันตรายที่สุด
const result = await this.vtrcEntityManager.query(
`SELECT * FROM MasterApprovers WHERE empCode = ${query.approverEmpCode}`,
);ถ้า query.approverEmpCode มาจาก user input → SQL injection
2. getRepository().find/findAndCount/update — TypeORM active record style
const result = await this.paymentTransactionRepository.find({
where: { docUUID: id },
});ปลอดภัย — TypeORM แปลงเป็น parameterized query อัตโนมัติ
3. createQueryBuilder() — พบน้อย
const qb = this.paymentTransactionRepository.createQueryBuilder('pt');
qb.where('pt.docUUID = :id', { id });ปลอดภัย — ใช้ named parameter
การค้นหา parameterized raw SQL — ไม่พบ
ผมค้นหา pattern .query(sql, [params]) (parameter-array syntax) ทั่วทั้ง codebase — ไม่พบการใช้ parameterized raw SQL แม้แต่จุดเดียว ทุก raw query ใน benefit-api คือ:
- static string (ปลอดภัย) หรือ
- template-literal interpolation (อันตราย)
SQL injection register — กรณีที่ยืนยันแล้ว
ทุกกรณีด้านล่างใช้ ${variable} interpolation ใน .query(\...`)` โดยที่ variable มาจาก user input หรือข้อมูลที่ user ควบคุมได้
Critical (user input ตรงๆ, ไม่ quote)
| # | ไฟล์:บรรทัด | Variable | Endpoint | Severity |
|---|---|---|---|---|
| 1 | approveDisaster.service.ts:416 | ${query.approverEmpCode} (numeric, unquoted) | GET approveDisaster/listBenefitForApprover | Critical |
| 2 | dropdown.service.ts:47 | ${type.empCode} (numeric, unquoted) | GET dropdown/get_dropdown_by_type (Public!) | Critical |
Code snippet — approveDisaster.service.ts:414-417
else if (!query.welfareType && query.approverEmpCode && query.isApprover == 'true' && query.approverEmpCode !== '') {
const approverData = await this.vtrcEntityManager.query(
`SELECT * FROM MasterApprovers ma WHERE empCode = ${query.approverEmpCode} AND isActive = 1`,
);query.approverEmpCode ไม่ถูกครอบด้วย quote และ interpolates ตรงเข้าไปใน SQL → classic 1 OR 1=1 vector
Code snippet — dropdown.service.ts:45-48 (Public endpoint!)
if (type.isApprover == 'true' && type.empCode && type.empCode !== '') {
const approverData = await this.vtrcEntityManager.query(
`SELECT * FROM MasterApprovers ma WHERE empCode = ${type.empCode} AND isActive = 1`,
);endpoint นี้ @Public() — ไม่ต้อง auth ก็ยิงได้
High (user input, quoted แต่ interpolate)
| # | ไฟล์:บรรทัด | Variable | Endpoint |
|---|---|---|---|
| 3 | dropdown.service.ts:100 | ${query.empCode} (quoted) | GET dropdown/getWelfareTypeListByEmp (Public) |
| 4 | dropdown.service.ts:114 | ${query.welfareTypeId}, ${query.orgCode} | POST dropdown/getCOAList (Public) |
| 5 | payments.service.ts:494 | ${payment.docUUID} (data-derived) | POST payments/cancelWelfare |
| 6 | payments.service.ts:570-571 | ${currentDate}, ${newOrderNo}, ${payment.docUUID} (UPDATE) | POST payments/cancelWelfare |
| 7 | payments.service.ts:1050 | ${empIdentityCardString} (built from user input) | POST payments/checkEmpDetail |
| 8 | payments.service.ts:1054 | ${profile.EmpID}, ${profile.SourceDB} | (same) |
| 9 | payments.service.ts:1141 | ${wdHospitalData.hospitalBankAccountId} | POST payments/updateEmpDetail |
| 10 | payments.service.ts:1421-1422 | ${newWDHospitalUpdate[...].orderNo/noteApprove/wdHospitalId} (UPDATE) | POST payments/updateFromUpdateWDHospital |
| 11 | payments.service.ts:1446 | ${withdrawNo} | findLastOrder helper |
| 12 | export-payments.service.ts:512 | ${payment.docUUID} | POST exportPayments/add |
| 13 | export-payments.service.ts:591-592 | ${wdHospitalStatus}, ${currentDate}, ${newOrderNo}, ${payDateKeep}, ${payment.docUUID} (UPDATE) | POST exportPayments/add |
| 14 | export-payments.service.ts:1072 | ${payment.docUUID} | POST exportPayments/importBankTransactionFile |
| 15 | exportpaymentreport.service.ts:6099 | ${query.wdExportPaymentTransactionId} (numeric, unquoted) | GET exportPaymentReport/getFileFmis |
| 16 | exportpaymentreport.service.ts:6136,6140 | ${query.wdExportPaymentTransactionId} | (same chain) |
| 17 | exportpaymentreport.service.ts:6170,6198 | ${ft.id}, ${jsonDataSuccess.FILE_NAME...} (string built from SFTP-fetched JSON) | SFTP-driven UPDATE |
| 18 | exportpaymentreport.service.ts:7343,7361 | ${query.wdExportPaymentTransactionId} | getCheckFileStatusBgDoc/Ledger |
| 19 | exportpaymentreport.service.ts:7398-7400 | ${query.status}, ${startDate}, ${endDate} | getExcelSummaryWithdraw |
| 20 | exportpaymentreport.service.ts:7417-7442 | ${startDate}, ${endDate} (4 queries) | (same) |
Code snippet — payments.service.ts:568-571 (UPDATE)
await vtrcTran.query(
`UPDATE WDHospitals
SET status='CANCELDOC', updatedAt='${currentDate}', orderNo=${newOrderNo}
WHERE wdHospitalId='${payment.docUUID}' and isHistory = 0
`
);Code snippet — exportpaymentreport.service.ts:7398-7400
WHERE h.status = '${query.status}'
AND h.createdAt >= '${startDate}'
AND h.createdAt <= '${endDate}'Lower risk (internally-derived date, แต่ pattern เดียวกัน)
Cron tasks interpolate bizDate เข้าไปใน HRMI queries — ค่า bizDate มาจาก WelfareUtil.getCurrentDate() ซึ่งสร้างจาก new Date().toISOString().slice(0,10) (welfare.util.ts:47-49) — ภายในควบคุมเอง ไม่ใช่ user-injectable แต่ pattern ยังเปราะบางและเสี่ยงถ้ามีการ refactor ในอนาคต
| ไฟล์:บรรทัด | รายละเอียด |
|---|---|
inheritance.task.service.ts:261 | FORMAT(hd.ModifiedDate, 'yyyy-MM-dd') = '${bizDate}' |
inheritance.task.service.ts:283 | pattern เดียวกัน |
funeral.task.service.ts:512 | pattern เดียวกัน |
funeral.task.service.ts:534 | pattern เดียวกัน |
Static raw SQL (ปลอดภัย — ไม่มี interpolation)
ตัวอย่าง raw query ที่ยอมรับได้ (ไม่มี variable):
const result = await this.paymentTransactionRepository.query('SELECT * FROM masterWelfareType'); SELECT * FROM masterFmis where masterType = 'ACCOUNT_CODE'(string literal 'ACCOUNT_CODE' — ปลอดภัย)
ผลกระทบเชิงโครงสร้าง
เนื่องจาก benefit-api ล็อกอินด้วย sa ในทุก connection (ดู 6.8 S11) SQL injection แม้แค่จุดเดียวก็เท่ากับ:
- เข้าถึงข้อมูลทั้งหมดใน
benefit,vtrc,dbHRMI_Centerschema - execute
xp_cmdshellหรือ MSSQL features อื่นเพื่อ RCE - อ่าน PII ของพนักงานทั้งหมด (จาก
vtrcschema)
ดู 6.8 สำหรับ remediation priority
รูปแบบที่ควรเป็น (best practice)
TypeORM รองรับ parameterized query สองรูปแบบ:
รูปแบบที่ 1 — positional parameters:
await this.vtrcEntityManager.query(
'SELECT * FROM MasterApprovers WHERE empCode = ? AND isActive = 1',
[empCode],
);รูปแบบที่ 2 — QueryBuilder:
await this.vtrcEntityManager
.createQueryBuilder()
.select('*')
.from('MasterApprovers', 'ma')
.where('ma.empCode = :empCode', { empCode })
.andWhere('ma.isActive = 1')
.getRawMany();ทั้งสองรูปแบบไม่เคยปรากฏใน codebase ปัจจุบัน