3.7 · REST surface (routes.js)
บทนี้เล่าว่าทำไม vtrc-api ถึงมี REST คู่กับ GraphQL แต่ละ route ทำอะไร และจุดไหนที่เปราะบางที่สุด (qpdf, /webdeployment, /file/:id/download)
ถ้า debug "PDF slip เปิดไม่ได้" หรือ "Excel upload ช้า" — เริ่มที่บทนี้
ทำไมต้องมี REST คู่ GraphQL
GraphQL เหมาะกับ "frontend ดึงข้อมูล" แต่ไม่เหมาะกับ 3 กรณี
- binary response — PDF, Excel, file download — GraphQL response เป็น JSON ไม่ natural สำหรับ binary stream
- browser direct navigation —
window.open(url)ที่ browser ยิงโดยตรง ไม่ผ่าน apollo client - third-party webhook — service ภายนอกที่ POST เข้ามาโดยไม่รู้ GraphQL schema
ผลคือ vtrc-api มี REST surface คู่กัน 1,302 บรรทัดใน routes.js
โครงสร้าง routes.js
import { createslip, deletePDF } from '../src/lib/createslip'
import { createSalaryCert, createtax, ... } from '../src/lib/createtax'
import { getFileById, saveFile, ... } from '../src/lib/controllers/file/file'
import { API_PREFIX_PATH, APP_PORT, API_DEVICE_KEY, IS_PRODUCTION, API_ACCESS, NODE_ENV } from './config'
import { execSync } from 'child_process'
import mime from 'mime-types'
import { getCacheData, setCacheData } from './lib/controllers/redis.controller'
...
import multer from 'multer'
const storage = multer.memoryStorage()
const upload = multer({ storage: storage })
const cpUpload = upload.fields([{ name: 'attachFiles', maxCount: 100 }])
export default (app) => {
// routes ทั้งหมดอยู่ในนี้
}routes export เป็น function ที่รับ app (Express instance) แล้ว mount GET/POST/PUT/DELETE handlers
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)
นี่คือจุดที่เปราะบางที่สุดในระบบ
app.get(`${API_PREFIX_PATH}/readfile/prs/:year/:month/:pid/:empcode/:device/:timestamp/:hash`, async function (req, res) {
try {
let timeout = parseInt(Date.now() / 1000) - parseInt((req.params.timestamp / 1000));
if (timeout <= 3600) {
let allData = await createslip(req)
await ejs.renderFile(path.join(process.env.PWD, "src", "report-template.ejs"), {...}, (err, data) => {
// ...
pdf.create(data, options).toFile(pdfTmpFile, async function (err, data) {
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`
try {
execSync(cmd);
// ...
setTimeout(() => { deletePDF(pathFile) }, 5000);รายละเอียด 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, { date, detail, ... }) │
│ สร้าง 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
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`passwordPDF derive จาก identityCard (national ID ของพนักงาน) — ถ้าค่ามี shell metacharacter เช่น ; | $() → execute command อะไรก็ได้
national ID ปกติเป็นตัวเลข 13 หลัก ทำให้ exploit ยากในทางปฏิบัติ — แต่ถ้ามีทางใส่ identityCard ที่ไม่ใช่ตัวเลข (เช่นผ่าน admin API หรือ data corruption) ก็เปิดช่อง
call sites ที่มี pattern เดียวกัน
| บรรทัด | 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 |
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
res.sendFile(returnFile)
console.log("setpasswordPDF success");
setTimeout(() => {
let pathFile = `${allData.pathfilePDF}/${allData.file}.pdf`
deletePDF(pathFile)
}, 5000);res.sendFile เป็น async — ถ้า client โหลดช้า (network ช้า) ไฟล์อาจจะถูก delete ก่อนที่จะ send ครบ
workaround คือ client retry แต่จะเจอ error 404 ก่อน
ทางแก้ (modernization target)
- เปลี่ยน
execSyncเป็น library pure JS สำหรับ encrypt PDF (เช่นpdf-lib+pdfjs-dist) - หรือ outsource PDF generation ไป worker thread / dedicated service
- ใช้ streaming response แทน
sendFile+setTimeout - URL sign ด้วย HMAC แทน SHA ของ plaintext
/webdeployment — severity S2
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 ธรรมดา
ใครยิง https://vtrcapi.redcross.or.th/webdeployment ปุ๊บ binary รันปุ๊บ — ถ้า binary ทำอะไร heavy (เช่น redeploy) ก็กระทบ production
ควรเป็นอย่างน้อย
- จำกัดด้วย IP whitelist
- ใส่ shared secret header
- หรือเอาออกจาก production เลย เพราะดูเหมือนจะเป็น dev tool
/file/:id vs /file/:id/download — severity S3
มี 2 sibling endpoints
/file/:id — มี apiKey check
app.get(`${API_PREFIX_PATH}/file/:id`, async function (req, res) {
const apiKey = req.headers.apikey
if (typeof apiKey === "string") {
const mapKey = await allowedKeysReStruct.filter(value => value.key === apiKey)
if (mapKey.length === 0) {
return res.sendStatus(404)
} else {
// ... read file from storage and send
}
}
})/file/:id/download — ไม่มี apiKey check
app.get(`${API_PREFIX_PATH}/file/:id/download`, async function (req, res) {
const fileId = req.params.id;
try {
const fileInfo = await getFileById(fileId)
// ... read file from storage and send
}
})ผล — ถ้ารู้ 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)
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 ไหนก็ได้ใน registry
ในทางปฏิบัติ caller ปกติจะ filter ไว้ที่ controller แต่ถ้าอนาคตมี code path ที่ forward table ตรง → RCE เทียบเท่า (อ่าน PII จากทุกตาราง)
KBANK batch payment — ไม่ใช่ cron
audit doc Phase 4 note ไว้ว่า "ไม่มี KBANK cron อัตโนมัติ — batch payment เป็น manual trigger"
routes.js มี endpoint ที่ trigger การ export ไฟล์ KPayroll / SmartCredit / cash สำหรับ KBANK — แต่ไม่ได้รันอัตโนมัติ HR ต้องกดปุ่มใน backoffice แล้วไป upload ไฟล์เองที่ KBANK portal
รายละเอียด business flow เต็มอยู่ใน Volume 1 บท 1.11 Payments KBANK
Static file hosting — 2 endpoint
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 ที่ไม่ทำอะไร
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 list (top 20)
| 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 ไหม (ดูใน
createslip/) - qpdf พังไหม (ดู
console.log(err)ในroutes.js:84) - file ถูก delete ก่อน send ไหม (ดู timing ใน log)
ถ้าเจอ "execSync" ใน routes.js
assume vulnerable จนกว่าจะพิสูจน์เป็นอย่างอื่น — แม้ input จะดู safe ตอนนี้ ก็อาจจะไม่ safe ในอนาคตถ้ามี code path ใหม่
ถ้าจะเพิ่ม REST endpoint ใหม่
- ใส่ apiKey check เสมอ (copy pattern จาก
/file/:id) - ห้ามใช้
execSync— ใช้ library pure JS หรือ spawn worker - ถ้ารับ 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
ใน index.js มีการ define corss object ที่ allowlist 5 origins — แต่ต่อมามี app.use(cors()) ที่เปิด default (allow *) ทับ config นั้น ผลคือ CORS เปิดหมดจริง (ดู บท 3.2)