Skip to content

10.7 · File upload + path traversal

บทนี้ cover file handling — ตั้งแต่ multer config ที่ไม่ validate ประเภทไฟล์ ไปจนถึง path traversal ในหลาย endpoint ที่อนุญาตให้อ่านไฟล์นอก directory ที่กำหนด


File handling surface

Upload path
├── POST /upload                          (multer, no filter)
├── POST /hospitalslip/upload             (multer, no filter)
├── POST /hospitaldocument/upload         (multer, no filter)
└── POST /leave/attachment                (multer, no filter)

Download path
├── GET /file/:id                         (DB-driven path)
├── GET /conicle/downloadFile/:id         (path traversal)
└── static mount /                        (entire storage exposed)

ทั้ง upload และ download มีปัญหา — upload รับไฟล์อะไรก็ได้, download อนุญาตให้อ่านไฟล์นอก scope


1 · multer config ไม่ validate (High)

43:45:vtrc-api/api/src/routes.js
const storage = multer.memoryStorage()
const upload = multer({ storage: storage })
const cpUpload = upload.fields([{ name: 'attachFiles', maxCount: 100 }])

multer ตั้งค่าด้วย memoryStorage และ ไม่มี fileFilter + ไม่มี limits (นอกจาก maxCount: 100)

ช่องโหว่

ปัญหาImpact
ไม่มี MIME whitelistupload .html, .svg (XSS), .exe ได้
ไม่มี extension whitelistไฟล์ .php, .js ผ่านได้
ไม่มี magic number checkตั้งชื่อ .jpg แต่เนื้อไฟล์เป็น script
ไม่มี size limit (นอกเหนือจาก body parser 50mb)DoS ผ่านไฟล์ใหญ่
originalname ใช้โดยตรงfilename injection (../../tmp/evil)

ผลลัพธ์ตอน serve กลับ

427:432:vtrc-api/api/src/routes.js
                        const storage_dir = path.join(__dirname, '../storage');
                        const file_dir = path.join(storage_dir, fileInfo.filePath)
                        console.log("file_dir ->", file_dir);
                        let mimeType = mime.lookup(file_dir)
                        fileInfo.mimeType = mimeType
                        if (fs.existsSync(file_dir)) {

mimeType derive จาก mime.lookup(file_dir) — ถ้าไฟล์ที่ upload มามีนามสกุล .html จะถูก serve กลับเป็น text/html → stored XSS ใน context ของ domain VTRC

Authentication ของ endpoints

upload endpoint ทั้งหมดตรวจแค่ apiKey header (ผ่าน apiKeyAuth middleware ที่ขึ้นกับแต่ละ endpoint) — ไม่ได้ verify Bearer JWT ที่ระดับ user ทำให้ใครที่ถือ prod device key (ที่ commit อยู่ใน repo — บท 9.5) สามารถ upload ได้โดยไม่ต้อง login เป็น user จริง

วิธีแกะ

javascript
const upload = multer({
  storage: storage,
  fileFilter: (req, file, cb) => {
    const allowed = ['image/jpeg', 'image/png', 'application/pdf']
    if (!allowed.includes(file.mimetype)) {
      return cb(new Error('Invalid file type'))
    }
    cb(null, true)
  },
  limits: { fileSize: 10 * 1024 * 1024 } // 10MB
})

// ตอน serve — force download + safe content-type
res.setHeader('Content-Disposition', 'attachment; filename="download"')
res.setHeader('Content-Type', 'application/octet-stream')
res.setHeader('X-Content-Type-Options', 'nosniff')

2 · /conicle/downloadFile/:id path traversal (High)

902:910:vtrc-api/api/src/routes.js
    app.get(`${API_PREFIX_PATH}/conicle/downloadFile/:id`, async function (req, res, next) {
        const id = req.params.id;
        const filePath = path.resolve(process.env.PWD, `storage/prs/${id}`);
        try {
            var file = fs.readFileSync(filePath, 'binary');
            res.setHeader('Content-Length', file.length);
            res.setHeader('Content-Type', file.length);
            res.write(file, 'binary');
            res.end();

🟡 Highreq.params.id interpolate ตรงเข้า path โดยไม่ normalize หรือตรวจ traversal

Exploit

GET /conicle/downloadFile/../../../../etc/passwd

path.resolve(process.env.PWD, 'storage/prs/../../../../etc/passwd') จะ resolve ออกนอก storage/prs/ เพราะ path.resolve collapse .. — มันไม่ได้ prevent traversal

ไฟล์ที่อ่านได้ใน container

  • /etc/passwd — user enumeration
  • /usr/src/app/logs/error_YYYY-MM-DD.log — log ที่มี refresh token (บท 9.9)
  • /usr/src/app/.env — env vars (ถ้า mount เข้ามา)
  • /usr/src/app/api/src/config.js — prod device keys + JWT secret

Bug เพิ่มเติม

906:906:vtrc-api/api/src/routes.js
            res.setHeader('Content-Type', file.length);

Content-Type set เป็น file.length (ตัวเลข) — bug เล็กแต่บ่งบอกว่า code ไม่ผ่าน review

Authentication

endpoint อยู่ใน ${API_PREFIX_PATH} ซึ่งอาจมี middleware auth — ต้อง verify แต่ pattern ของ Conicle endpoints มักใช้แค่ apiKey

วิธีแกะ

javascript
const id = req.params.id
const baseDir = path.resolve(process.env.PWD, 'storage/prs')
const filePath = path.resolve(baseDir, id)

// ตรวจว่า resolved path ยังอยู่ใน baseDir
const relative = path.relative(baseDir, filePath)
if (relative.startsWith('..') || path.isAbsolute(relative)) {
  return res.status(403).send('Forbidden')
}

// ถ้าผ่านค่อย read
const file = fs.readFileSync(filePath, 'binary')

3 · /file/:id DB-driven path traversal (Medium)

427:432:vtrc-api/api/src/routes.js
                        const storage_dir = path.join(__dirname, '../storage');
                        const file_dir = path.join(storage_dir, fileInfo.filePath)
                        console.log("file_dir ->", file_dir);
                        let mimeType = mime.lookup(file_dir)
                        fileInfo.mimeType = mimeType
                        if (fs.existsSync(file_dir)) {

🟡 MediumfileInfo.filePath มาจาก DB (lookup ผ่าน getFileById(fileId))

ถ้ามี SQL injection ที่ไหนสักแห่ง (เช่น statLog ในบท 9.6) ที่เขียน Files.filePath column ได้ ผู้โจมตี set filePath = '../../../etc/passwd' แล้ว endpoint นี้จะอ่านไฟล์นั้น

Authentication

393:397:vtrc-api/api/src/routes.js
        // ตรวจ apiKey header เท่านั้น

ตรวจแค่ apiKey header — ไม่ verify user JWT ใครที่ถือ prod device key commit ใน repo เข้าถึงได้

วิธีแกะ

  • Normalize path ก่อน read (เช่นเดียวกับข้อ 2)
  • Validate ว่า resolved path ยังอยู่ใน storage_dir
  • ที่ดีที่สุด — เก็บ file content ใน object storage (GCS, S3) แทน filesystem แล้ว signed URL

4 · Static mount / expose storage ทั้งหมด (Medium)

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'));

🟡 Medium — mount directory /storage ทั้งหมดที่ /

express.static เอง block .. traversal แต่การ mount /storage ที่ root หมายความว่า ไฟล์ทุกอันใน storage ที่ path สามารถเดาได้สามารถอ่านได้ public

Combined attack surface

upload file → stored at /storage/2026/01/01/uuid.pdf


     static mount / → GET /2026/01/01/uuid.pdf อ่านได้โดยไม่ต้องผ่าน /file/:id

ถ้าไฟล์ที่ upload มี PII (เช่น hospital slip ที่มีชื่อ + idcard) และ path เดาได้ (วันที่ + UUID ที่ brute-force ได้) → exfiltration

วิธีแกะ

  • อย่า mount /storage ที่ /
  • ใช้ path-based access control — ทุกไฟล์ต้องผ่าน /file/:id ที่ตรวจ user authorization
  • หรือเก็บไฟล์ใน private bucket + ใช้ signed URL

5 · genSlip async exec — dead code หรือไม่ (Medium)

86:94:vtrc-api/api/src/lib/genSlip/index.js
                    let cmd = `qpdf --encrypt ${passwordPDF} ${passwordPDF} 40 -- ${pathfilePDF}/${file}1.pdf ${pathfilePDF}/${file}.pdf && rm ${pathfilePDF}/${file}1.pdf`
                    exec(cmd, function (err) {
                        if (err) {
                            console.error('Error occured: ' + err);
                        } else {
                            console.log('PDF Encrypted Success :D');
                            let rm = `rm ${pathfilePDF}/${file}.pdf`
                            setTimeout(() => {
                                exec(rm, function (err) {

🟡 High ถ้า reachable / Medium ถ้า dead — pattern เดียวกับ routes.js แต่ใช้ exec (async) ไม่ใช่ execSync

ยังไม่ชัดเจนว่า genSlip ถูกเรียกจากที่ไหน — ต้อง trace call graph ก่อนสรุป severity


รวม — file handling checklist

#ปัญหาSeverityFix
1multer no filter🟡 HighfileFilter + limits + magic number
2/conicle/downloadFile traversal🟡 Highpath.relative + check ..
3/file/:id DB-driven traversal🟡 Mediumnormalize + validate in storage_dir
4static mount / expose storage🟡 Mediumอย่า mount root ใช้ access control
5genSlip exec pattern🟡 High/Mediumใช้ execFile + arg array
6Content-Type: file.length bug🟢 Lowfix typo

อ่านต่อ → 9.8 Network + infra exposure