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)
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 whitelist | upload .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 กลับ
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 จริง
วิธีแกะ
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)
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();🟡 High — req.params.id interpolate ตรงเข้า path โดยไม่ normalize หรือตรวจ traversal
Exploit
GET /conicle/downloadFile/../../../../etc/passwdpath.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 เพิ่มเติม
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
วิธีแกะ
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)
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)) {🟡 Medium — fileInfo.filePath มาจาก DB (lookup ผ่าน getFileById(fileId))
ถ้ามี SQL injection ที่ไหนสักแห่ง (เช่น statLog ในบท 9.6) ที่เขียน Files.filePath column ได้ ผู้โจมตี set filePath = '../../../etc/passwd' แล้ว endpoint นี้จะอ่านไฟล์นั้น
Authentication
// ตรวจ 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)
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)
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
| # | ปัญหา | Severity | Fix |
|---|---|---|---|
| 1 | multer no filter | 🟡 High | fileFilter + limits + magic number |
| 2 | /conicle/downloadFile traversal | 🟡 High | path.relative + check .. |
| 3 | /file/:id DB-driven traversal | 🟡 Medium | normalize + validate in storage_dir |
| 4 | static mount / expose storage | 🟡 Medium | อย่า mount root ใช้ access control |
| 5 | genSlip exec pattern | 🟡 High/Medium | ใช้ execFile + arg array |
| 6 | Content-Type: file.length bug | 🟢 Low | fix typo |
อ่านต่อ → 9.8 Network + infra exposure