Skip to content

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 กรณี

  1. binary response — PDF, Excel, file download — GraphQL response เป็น JSON ไม่ natural สำหรับ binary stream
  2. browser direct navigationwindow.open(url) ที่ browser ยิงโดยตรง ไม่ผ่าน apollo client
  3. third-party webhook — service ภายนอกที่ POST เข้ามาโดยไม่รู้ GraphQL schema

ผลคือ vtrc-api มี REST surface คู่กัน 1,302 บรรทัดใน routes.js


โครงสร้าง routes.js

1:48:vtrc-api/api/src/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 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)

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

51:99:vtrc-api/api/src/routes.js
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

73:73:vtrc-api/api/src/routes.js
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:355tax 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

79:82:vtrc-api/api/src/routes.js
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)

  1. เปลี่ยน execSync เป็น library pure JS สำหรับ encrypt PDF (เช่น pdf-lib + pdfjs-dist)
  2. หรือ outsource PDF generation ไป worker thread / dedicated service
  3. ใช้ streaming response แทน sendFile + setTimeout
  4. URL sign ด้วย HMAC แทน SHA ของ plaintext

/webdeployment — severity S2

975:984:vtrc-api/api/src/routes.js
    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

389:462:vtrc-api/api/src/routes.js
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

464:495:vtrc-api/api/src/routes.js
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)

497:499:vtrc-api/api/src/routes.js
    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 ไหนก็ได้ใน 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

986:988:vtrc-api/api/src/routes.js
    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 ที่ไม่ทำอะไร

990:993:vtrc-api/api/src/routes.js
    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)

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 เปิดไม่ได้ — เช็กตามลำดับ

  1. signed URL หมดอายุไหม (timestamp > 1h)
  2. centralize ตอบ payslip ไหม (ดูใน createslip/)
  3. qpdf พังไหม (ดู console.log(err) ใน routes.js:84)
  4. file ถูก delete ก่อน send ไหม (ดู timing ใน log)

ถ้าเจอ "execSync" ใน routes.js

assume vulnerable จนกว่าจะพิสูจน์เป็นอย่างอื่น — แม้ input จะดู safe ตอนนี้ ก็อาจจะไม่ safe ในอนาคตถ้ามี code path ใหม่

ถ้าจะเพิ่ม REST endpoint ใหม่

  1. ใส่ apiKey check เสมอ (copy pattern จาก /file/:id)
  2. ห้ามใช้ execSync — ใช้ library pure JS หรือ spawn worker
  3. ถ้ารับ dynamic input เป็น :table หรือ :id → whitelist ที่ controller
  4. ใส่ 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)


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

ไป บท 3.8 Cron + background jobs