3.1.7 · REST surface (routes.js)
บทนี้เล่าว่าทำไม vtrc-api ถึงมี REST คู่กับ GraphQL แต่ละ route ทำอะไร และจุดไหนที่เปราะบางที่สุด (qpdf, /webdeployment, /file/:id/download) ทุก line citation re-verify ตรงกับ routes.js บนดิสก์ (1,301 บรรทัด) เมื่อ 2026-07-13 — ตรงกับ v1 ทุกจุดเหมือนกันทุกประการ ไม่มี drift
ทำไมต้องมี REST คู่ GraphQL
GraphQL เหมาะกับ "frontend ดึงข้อมูล" แต่ไม่เหมาะกับ 3 กรณี — binary response (PDF, Excel, file download), browser direct navigation (window.open(url) ที่ไม่ผ่าน apollo client), third-party webhook (service ภายนอกที่ POST เข้ามาโดยไม่รู้ schema) ผลคือ vtrc-api มี REST surface คู่กัน 1,301 บรรทัดใน routes.js
API_PREFIX_PATH ปกติเป็น string ว่าง → routes อยู่ที่ root path
หมวด route — 5 กลุ่ม
| กลุ่ม | Endpoint pattern | Auth | จำนวน (ประมาณ) |
|---|---|---|---|
| PDF generation | /readfile/prs/*, /tax/*, /salaryCert/*, /failpayment/* | URL-embedded signed hash (1h) | ~10 |
| File storage | /file/:id, /file/:id/download, /upload, /uploadExcel | apiKey header | ~5 |
| Excel export | /download/excel/:table, /summary/:empCode/* | apiKey + empCode in URL | ~15 |
| Webhook + integration | /ais-smsgw, /conicle/getFileFromUrl | none หรือ apiKey | ~5 |
| Ops | /webdeployment, /resources/*, / (static) | none | ~5 |
PDF pipeline — qpdf execSync (severity S1)
จุดที่เปราะบางที่สุดในระบบ
vtrc-api/api/src/routes.js:73-73
let cmd = `qpdf --encrypt ${allData.passwordPDF} ${allData.passwordPDF} 40 -- ${allData.pathfilePDF}/${allData.file}1.pdf ${allData.pathfilePDF}/${allData.file}.pdf && rm ${allData.pathfilePDF}/${allData.file}1.pdf`รายละเอียด pipeline
Browser GET /readfile/prs/:year/:month/:pid/:empcode/:device/:timestamp/:hash
│ Step 1 — verify signed URL (timestamp ≤ 1h)
▼
createslip(req) — lookup Session จาก empcode + device, fetch payslip ผ่าน centralize, คำนวณ password = identityCard
▼
ejs.renderFile(report-template.ejs, {...}) — สร้าง HTML จาก template + data
▼
html-pdf.create(html).toFile(pdfTmpFile) — phantomjs แปลง HTML → PDF
▼
execSync(`qpdf --encrypt {password} {password} 40 -- ...`)
❌ command injection ถ้า password มี shell metacharacter
❌ blocks event loop ตลอดเวลาที่ qpdf รัน
▼
res.sendFile() + setTimeout(deletePDF, 5000)
❌ race condition — ถ้า download ช้ากว่า 5s ไฟล์หายSeverity S1 — command injection
passwordPDF derive จาก identityCard (national ID พนักงาน) — ถ้าค่ามี shell metacharacter เช่น ; | $() → execute command อะไรก็ได้ national ID ปกติเป็นตัวเลข 13 หลักทำให้ exploit ยากในทางปฏิบัติ แต่ถ้ามีทางใส่ identityCard ที่ไม่ใช่ตัวเลข (admin API หรือ data corruption) ก็เปิดช่อง
call sites ที่มี pattern เดียวกัน — verify ทุกบรรทัดตรงกับ v1 (2026-07-13):
| บรรทัด | Endpoint |
|---|---|
routes.js:73 | /readfile/prs/* (payslip) |
routes.js:120 | /tax/* |
routes.js:227 | /salaryCert/* |
routes.js:277 | payslip variant |
routes.js:355 | tax multi-year |
หมายเหตุ — ยังมี pattern เดียวกันอีก 3 จุดที่ถูก comment ออก (routes.js:1038, 1115, 1192) ไม่ active
Severity PERF-1 — execSync blocks event loop
execSync รอจนกว่า qpdf จะเสร็จ ระหว่างนั้น event loop ค้าง ทำให้ request อื่นบน replica เดียวกันรอ ถ้ามี PDF request 5 ตัวพร้อมกัน → request ที่ 6-10 รอคิว
Severity RACE-1 — 5s delete timeout
vtrc-api/api/src/routes.js:79-82
res.sendFile(returnFile)
console.log("setpasswordPDF success");
setTimeout(() => {
let pathFile = `${allData.pathfilePDF}/${allData.file}.pdf`
deletePDF(pathFile)
}, 5000);res.sendFile เป็น async — ถ้า client โหลดช้า ไฟล์อาจถูก delete ก่อน send ครบ workaround คือ client retry แต่จะเจอ 404 ก่อน
ทางแก้ (modernization target)
เปลี่ยน execSync เป็น library pure JS (pdf-lib + pdfjs-dist) หรือ outsource PDF generation ไป worker/dedicated service, ใช้ streaming response แทน sendFile + setTimeout, URL sign ด้วย HMAC แทน SHA plaintext
/webdeployment — severity S2
vtrc-api/api/src/routes.js:975-984
app.use('/webdeployment', async function (req, res) {
try {
let output = execSync("vtrc-update-uat-loadtest")
log.info(log.logTextFormat("INFO", 'Method:GET | Route:/webdeployment', 'Test deploy by itself.', output))
res.send("Success")
} catch (err) {
log.error(log.logTextFormat('ERROR', `Method:GET | Route:/webdeployment`, 'ERROR', (err.stack)))
res.sendStatus(500)
}
})route นี้ ไม่มี auth รัน shell binary vtrc-update-uat-loadtest (binary ภายนอกที่อยู่ใน PATH) trigger ได้ด้วย GET ธรรมดา — ควรมี IP whitelist, shared secret header, หรือเอาออกจาก production เพราะดูเหมือน dev tool
/file/:id vs /file/:id/download — severity S3
/file/:id — มี apiKey check
vtrc-api/api/src/routes.js:389
app.get(`${API_PREFIX_PATH}/file/:id`, async function (req, res) {/file/:id/download — ไม่มี apiKey check
vtrc-api/api/src/routes.js:464
app.get(`${API_PREFIX_PATH}/file/:id/download`, async function (req, res) {ผล — ถ้ารู้ fileId (UUID) ใครก็ download ได้โดยไม่ต้อง auth ไม่มี shared secret ไม่มี session check ไม่มี rate limit UUID มี 122-bit entropy ทำให้ brute force ยาก แต่ถ้า attacker ได้ fileId จากทางอื่น (log leak, response อื่น) → ดึงไฟล์ได้ทันที
Excel hub — dynamic table (severity M)
vtrc-api/api/src/routes.js:497-499
app.get(`/download/excel/:table`, async (req, res) => {
await downloadExcel(req, res)
})req.params.table ผ่านไป excel/excel.js#downloadExcel → queryDataForExcelExport(table, condition) → Models[table].findAll(...) ถ้า forward table โดยไม่ whitelist → attacker query model ไหนก็ได้ในทางทฤษฎี ในทางปฏิบัติ caller ปกติ filter ไว้ที่ controller แต่ถ้ามี code path ใหม่ที่ forward ตรง → ความเสี่ยงเทียบเท่า RCE (อ่าน PII จากทุกตาราง)
KBANK batch payment — ไม่ใช่ cron
routes.js มี endpoint ที่ trigger การ export ไฟล์ KPayroll / SmartCredit / cash สำหรับ KBANK (ธนาคารกสิกรไทย) — แต่ไม่ได้รันอัตโนมัติ HR ต้องกดปุ่มใน backoffice แล้วไป upload ไฟล์เองที่ KBANK portal ไม่มี cron อัตโนมัติดึงผลธนาคาร (importUpdateStatus เป็น manual trigger — ยืนยันตาม canonical truth) รายละเอียด business flow เต็มอยู่ใน Volume 1 Business
Static file hosting — 2 endpoint
vtrc-api/api/src/routes.js:986-988
app.use('/resources', express.static(process.env.PWD + '/storage/prs'));
app.use('/', express.static(process.env.PWD + '/storage'));ใช้ process.env.PWD ซึ่งไม่น่าเชื่อถือใน production (Node ไม่ได้ตั้งเอง shell ตั้ง) ควรเป็น process.cwd() การ mount / เป็น static storage ทำให้ไฟล์ใน storage/ access ผ่าน HTTP ได้โดยไม่ต้องผ่าน /file/:id — ถ้ามีไฟล์ sensitive อยู่ในนั้นจะ leak ได้
/ais-smsgw — webhook ที่ไม่ทำอะไร
vtrc-api/api/src/routes.js:990-993
app.all(`/ais-smsgw`, async (req, res) => {
console.log('Query String: ', req.query)
res.send(JSON.stringify({ result: 'OK' }))
})รับทุก method (GET/POST/PUT/...) ไม่ validate signature/sender แค่ log query string (PII risk ถ้า AIS ส่งเบอร์โทร) ตอบกลับ OK เสมอ — น่าจะเป็น webhook สำหรับ SMS delivery report จาก AIS แต่ไม่ประมวลผลจริง
ตารางสรุป — REST endpoint (top 15)
| Endpoint | Method | Auth | Purpose | Risk |
|---|---|---|---|---|
/readfile/prs/:year/:month/:pid/:empcode/:device/:ts/:hash | GET | signed URL | payslip PDF | S1 (execSync qpdf) |
/tax/:year/:month/:pid/:empcode/:device/:ts/:hash | GET | signed URL | tax PDF | S1 |
/salaryCert/... | GET | signed URL | salary cert PDF | S1 |
/failpayment/... | GET | signed URL | fail payment PDF | - |
/file/:id | GET | apiKey | inline file | - |
/file/:id/download | GET | none | attachment file | S3 |
/upload | POST | apiKey | single file upload | - |
/uploadExcel | POST | apiKey | bulk upload 100 files | - |
/download/excel/:table | GET | apiKey | dynamic Excel export | M (table injection) |
/summary/:empCode/summaryWelfareReport | GET | none | welfare report PDF | - |
/webdeployment | ALL | none | execSync shell binary | S2 |
/resources/* | GET | none | static /storage/prs | - |
/ | GET | none | static /storage | - |
/ais-smsgw | ALL | none | SMS webhook stub | - |
/conicle/getFileFromUrl | GET | apiKey | Conicle file proxy | - |
เคล็ดลับตอนทำงานจริง
ถ้า PDF slip เปิดไม่ได้ — เช็กตามลำดับ
signed URL หมดอายุไหม (timestamp > 1h) → centralize ตอบ payslip ไหม → qpdf พังไหม (ดู console.log(err) ใน routes.js:84) → file ถูก delete ก่อน send ไหม
ถ้าเจอ execSync ใน routes.js
assume vulnerable จนกว่าจะพิสูจน์เป็นอย่างอื่น
ถ้าจะเพิ่ม REST endpoint ใหม่
ใส่ apiKey check เสมอ (copy pattern จาก /file/:id), ห้ามใช้ execSync, ถ้ารับ dynamic input เป็น :table หรือ :id → whitelist ที่ controller, ใส่ rate limit (ปกติไม่มี — ช่องโหว่ DoS)
/file/:id/download ควรแก้ด่วน
add apiKey check ตาม sibling endpoint — copy 4 บรรทัดจาก /file/:id
app.use(cors()) ที่ index.js:91 ทับ allowlist
ดู 3.1.2 — corss typo ทำให้ allowlist ไม่ทำงาน แล้ว default cors เปิดหมด