Skip to content

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 patternAuthจำนวน (ประมาณ)
PDF generation/readfile/prs/*, /tax/*, /salaryCert/*, /failpayment/*URL-embedded signed hash (1h)~10
File storage/file/:id, /file/:id/download, /upload, /uploadExcelapiKey header~5
Excel export/download/excel/:table, /summary/:empCode/*apiKey + empCode in URL~15
Webhook + integration/ais-smsgw, /conicle/getFileFromUrlnone หรือ apiKey~5
Ops/webdeployment, /resources/*, / (static)none~5

PDF pipeline — qpdf execSync (severity S1)

จุดที่เปราะบางที่สุดในระบบ

vtrc-api/api/src/routes.js:73-73

javascript
                                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:277payslip variant
routes.js:355tax 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

javascript
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

javascript
    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

javascript
    app.get(`${API_PREFIX_PATH}/file/:id`, async function (req, res) {

/file/:id/downloadไม่มี apiKey check

vtrc-api/api/src/routes.js:464

javascript
    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

javascript
    app.get(`/download/excel/:table`, async (req, res) => {
        await downloadExcel(req, res)
    })

req.params.table ผ่านไป excel/excel.js#downloadExcelqueryDataForExcelExport(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

javascript
    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

javascript
    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)

EndpointMethodAuthPurposeRisk
/readfile/prs/:year/:month/:pid/:empcode/:device/:ts/:hashGETsigned URLpayslip PDFS1 (execSync qpdf)
/tax/:year/:month/:pid/:empcode/:device/:ts/:hashGETsigned URLtax PDFS1
/salaryCert/...GETsigned URLsalary cert PDFS1
/failpayment/...GETsigned URLfail payment PDF-
/file/:idGETapiKeyinline file-
/file/:id/downloadGETnoneattachment fileS3
/uploadPOSTapiKeysingle file upload-
/uploadExcelPOSTapiKeybulk upload 100 files-
/download/excel/:tableGETapiKeydynamic Excel exportM (table injection)
/summary/:empCode/summaryWelfareReportGETnonewelfare report PDF-
/webdeploymentALLnoneexecSync shell binaryS2
/resources/*GETnonestatic /storage/prs-
/GETnonestatic /storage-
/ais-smsgwALLnoneSMS webhook stub-
/conicle/getFileFromUrlGETapiKeyConicle 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.2corss typo ทำให้ allowlist ไม่ทำงาน แล้ว default cors เปิดหมด


ขั้นตอนถัดไป

ไป 3.1.8 Cron + background jobs