1. Home
  2. Insights
  3. Security
  4. File Upload Security Checklist สำหรับระบบ production
Security

File Upload Security Checklist สำหรับระบบ production

สรุป checklist สำหรับออกแบบ file upload ให้ปลอดภัยในระบบ production ตั้งแต่ validation, storage, signed URL, malware scanning, rate limiting ไปจนถึง audit trail และ observability

File Upload Security Checklist สำหรับระบบ production

File upload เป็นฟีเจอร์ที่ดูเหมือนง่ายมากในช่วงเริ่มต้นของระบบ

  • รับไฟล์จาก form
  • เซฟลง storage
  • เก็บ path ไว้ใน database
  • ส่งผลลัพธ์กลับไปว่าอัปโหลดสำเร็จ

แต่พอขึ้น production จริง เรื่องจะไม่ง่ายแบบนั้น เพราะ file upload เป็นจุดที่เชื่อมหลายชั้นของระบบเข้าด้วยกันพร้อมกัน

  • input จากผู้ใช้
  • validation
  • application server
  • object storage
  • public access control
  • malware scanning
  • audit trail
  • observability
  • abuse prevention

ถ้าออกแบบหลวมไป จุดนี้จะกลายเป็นทั้งช่องโหว่ด้าน security และแหล่งปัญหาด้าน operations ได้พร้อมกัน เช่น

  • อัปโหลดไฟล์ที่ไม่ควรรับ
  • ปลอม content type
  • ยิงไฟล์ใหญ่จนระบบหน่วง
  • ใช้ upload endpoint เป็นทาง spam หรือ abuse
  • เผลอเปิดไฟล์ให้ public ทั้ง bucket
  • เก็บเอกสารสำคัญโดยไม่มีสิทธิ์เข้าถึงที่ชัด
  • ไม่มีหลักฐานว่าใครอัปโหลดหรือลบไฟล์เมื่อไร

บทความนี้สรุป checklist สำหรับ file upload ในระบบ production แบบที่ทีม dev, backend, และ product ควรคุยกันให้ครบก่อนปล่อยใช้จริง

TL;DR

ถ้าจะสรุปให้สั้นที่สุด

อย่าเชื่อไฟล์จาก client, อย่าเชื่อ filename, อย่าเชื่อ content-type, อย่าให้ public access ง่ายเกินไป, และต้องมีทั้ง validation, storage policy, rate limiting, audit trail, และ observability

ทำไม File Upload ถึงเป็นจุดเสี่ยง

เวลาเรารับไฟล์ เราไม่ได้รับแค่ binary data แต่กำลังรับ “วัตถุที่ผู้ใช้ควบคุมได้” เข้ามาในระบบ

สิ่งที่เสี่ยงมีหลายแบบ เช่น

  • อัปโหลดไฟล์ executable หรือ script ที่ไม่ควรรับ
  • ปลอมชนิดไฟล์ด้วยนามสกุล เช่น .jpg แต่จริง ๆ ไม่ใช่รูป
  • ใช้ชื่อไฟล์แปลก ๆ เพื่อพัง path handling
  • อัปโหลดไฟล์ใหญ่เกินไปเพื่อกิน bandwidth หรือ disk
  • อัปโหลดซ้ำจำนวนมากเพื่อโจมตีระบบ
  • อัปโหลดเอกสารสำคัญแล้วระบบเปิด URL ให้เดาง่าย
  • อัปโหลดไฟล์ที่ควรลบทิ้งแต่ไม่มี retention policy
  • อัปโหลดผ่าน flow ที่ไม่มี audit หรือ tracing ทำให้ตามย้อนหลังไม่ได้

ดังนั้น file upload ไม่ควรถูกมองเป็นแค่ feature ฝั่ง form แต่เป็น workflow ด้าน security และ operations เต็มตัว

Checklist ภาพรวมที่ควรมี

ถ้าจะออกแบบให้รัดกุม ควรเช็กอย่างน้อยเรื่องเหล่านี้

  1. จำกัดชนิดไฟล์ที่ยอมรับ
  2. จำกัดขนาดไฟล์
  3. ตรวจ content type และ file signature
  4. ไม่ใช้ชื่อไฟล์จาก client ตรง ๆ
  5. แยก storage path และ access policy ให้ชัด
  6. ใช้ signed URL หรือ private object access ตามความเหมาะสม
  7. มี rate limiting และ abuse protection
  8. มี malware scanning หรือ async review ถ้าระบบเสี่ยง
  9. มี audit trail สำหรับ upload, delete, replace
  10. มี request id, structured logging, และ observability
  11. มี retention policy และ lifecycle policy
  12. แยก public files ออกจาก sensitive files

1) จำกัดชนิดไฟล์ที่ยอมรับ

อย่ารับ “อะไรก็ได้” แล้วค่อยไปหวังว่า UI จะควบคุมเอง เพราะ accept ใน HTML เป็นแค่ตัวช่วย UX ไม่ใช่ security control

ตัวอย่างที่ควรกำหนดให้ชัด เช่น

  • รูปภาพ: jpg, jpeg, png, webp
  • เอกสาร: pdf
  • spreadsheet เฉพาะกรณีจำเป็น
  • หลีกเลี่ยง executable หรือ archive ถ้าไม่จำเป็น

สิ่งสำคัญคือ policy ต้องมาจาก server ไม่ใช่เชื่อจาก client

2) จำกัดขนาดไฟล์

ควรกำหนดเพดานชัดเจน เช่น

  • รูปโปรไฟล์: ไม่เกิน 5 MB
  • เอกสารยืนยันตัวตน: ไม่เกิน 10 MB
  • ไฟล์แนบงานทั่วไป: ไม่เกิน 20 MB

อย่าปล่อยให้ไฟล์ใหญ่เข้ามาถึง business logic ก่อนค่อย reject เพราะจะเปลือง bandwidth, memory, temp storage และทำให้ระบบโดน abuse ได้ง่าย

3) อย่าเชื่อ Content-Type จาก client อย่างเดียว

client สามารถส่ง header แบบไหนมาก็ได้ เช่น

Content-Type: image/jpeg

แต่นั่นไม่ได้แปลว่าไฟล์จริงเป็น JPEG เสมอไป

ในระบบ production ควรตรวจเพิ่มอย่างน้อย 2 ชั้น

  • ตรวจชนิดไฟล์ตาม allowlist
  • ตรวจ file signature หรือ magic bytes ถ้าระบบต้องการความมั่นใจสูงขึ้น

สรุปคือ

  • extension ช่วยเรื่อง UX
  • content type ช่วยเรื่องการจัดการเบื้องต้น
  • file signature ช่วยเรื่องความถูกต้องจริงของไฟล์

4) อย่าใช้ชื่อไฟล์จาก client ตรง ๆ

ชื่อไฟล์จาก client อาจมีปัญหาได้หลายแบบ

  • มีอักขระแปลก
  • มี path traversal intent
  • ยาวเกินไป
  • ซ้ำกัน
  • หลุดข้อมูลส่วนตัว เช่น passport-john-doe-final.pdf

แนวทางที่ดีกว่าคือ

  • generate filename ใหม่ฝั่ง server
  • เก็บ original filename เป็น metadata ถ้าจำเป็น
  • sanitize ก่อนเก็บ
  • แยก object key ออกจากชื่อไฟล์ที่แสดงบน UI

ตัวอย่าง key ที่ปลอดภัยกว่า

uploads/customers/cus_1024/doc_7f9d3e2a.pdf

5) แยกประเภท storage ตามความอ่อนไหวของไฟล์

ไฟล์ทุกประเภทไม่ควรอยู่ใน policy เดียวกัน

ตัวอย่างการแยกที่ดี

  • public assets
  • user-generated non-sensitive files
  • private documents
  • compliance documents
  • internal-only attachments

ถ้าระบบมีเอกสารสำคัญ เช่น บัตรประชาชน, สัญญา, invoice, claim document หรือเอกสารการเงิน ควรเก็บใน private bucket หรือ private object path และใช้ access policy ชัดเจน

6) ใช้ Signed URL เมื่อเหมาะสม

หลายระบบไม่จำเป็นต้องให้ backend รับไฟล์ทุก byte ด้วยตัวเองเสมอไป โดยเฉพาะกรณีที่ใช้ object storage เช่น Google Cloud Storage, S3 หรือเทียบเท่า

ในหลายกรณี flow ที่เหมาะกว่าคือ

  1. client ขอ upload session จาก backend
  2. backend ตรวจสิทธิ์และออก signed URL
  3. client อัปโหลดตรงไปที่ storage
  4. backend รับ callback หรือ metadata เพื่อยืนยันการอัปโหลด

ข้อดีคือ

  • ลดภาระ application server
  • คุมสิทธิ์การอัปโหลดได้
  • กำหนดอายุ URL ได้
  • จำกัด path และ method ได้
  • scale ได้ดีกว่า proxy ผ่าน backend ทั้งหมด

แต่ signed URL ก็ไม่ใช่คำตอบทุกกรณี ต้องออกแบบให้ชัดว่ากำลังป้องกันอะไรและยังต้อง validate metadata หลังอัปโหลดอยู่ดี

7) แยก “upload success” ออกจาก “file trusted”

อันนี้สำคัญมาก

เพียงเพราะไฟล์ถูกส่งขึ้น storage สำเร็จ ไม่ได้แปลว่าไฟล์นั้นพร้อมใช้งานทันทีเสมอไป

บางระบบควรมี state ของไฟล์ เช่น

  • uploaded
  • pending_scan
  • approved
  • rejected
  • quarantined

วิธีนี้ทำให้คุณแยกได้ว่า

  • upload transport สำเร็จแล้ว
  • แต่ยังไม่ผ่าน security review หรือ malware scan

โดยเฉพาะระบบที่รับไฟล์จากคนทั่วไป หรือรับเอกสารที่อาจเสี่ยง ควรมีชั้นนี้อย่างชัดเจน

8) มี malware scanning หรือ review flow เมื่อความเสี่ยงสูง

ไม่ใช่ทุกระบบต้องมี antivirus scan เต็มรูปแบบ แต่บางระบบควรมีอย่างมาก เช่น

  • ระบบรับไฟล์จาก public users
  • ระบบแลกเปลี่ยนเอกสารกับคู่ค้า
  • ระบบ HR/สมัครงาน
  • ระบบ claim/insurance
  • ระบบ support ticket ที่แนบไฟล์ได้
  • ระบบที่ไฟล์ถูกส่งต่อไปเปิดโดยเจ้าหน้าที่

แนวทางหนึ่งที่ใช้ได้จริงคือ

  • upload เข้า temporary/private area
  • queue งาน scan
  • mark state ว่า approved หรือ quarantined
  • อนุญาตให้ downstream ใช้ไฟล์เฉพาะที่ผ่านแล้ว

9) ต้องมี Rate Limiting และ Abuse Protection

upload endpoint มักแพงกว่า endpoint ปกติ เพราะกินทั้ง network, CPU, storage, และบางครั้งกระตุ้น scan/thumbnail/processing ต่ออีกหลายชั้น

ดังนั้นควรมีอย่างน้อย

  • per-user rate limiting
  • per-IP protection
  • request size limits
  • concurrency limits
  • authentication ก่อน upload ถ้าธุรกิจรองรับ
  • bot protection ในกรณี public endpoint

อย่าคิดว่า upload endpoint เล็กน้อย เพราะถ้าไม่มี control จุดนี้จะโดนยิงได้ง่ายมาก

10) มี Audit Trail สำหรับ action สำคัญ

ระบบที่จริงจังกับ file upload ไม่ควรเก็บแค่ “มีไฟล์อยู่ใน bucket” แต่ควรรู้ว่า

  • ใครอัปโหลด
  • อัปโหลดเมื่อไร
  • อัปโหลดให้ resource ไหน
  • ใครลบ
  • ใคร replace
  • ใครดาวน์โหลด ถ้าธุรกิจนั้นต้องตรวจสอบย้อนหลังระดับนี้
  • ไฟล์สถานะเปลี่ยนจาก pending_scan เป็น approved เมื่อไร

ตัวอย่าง event สำคัญ เช่น

  • file.upload_requested
  • file.upload_completed
  • file.scan_passed
  • file.scan_failed
  • file.deleted
  • file.replaced
  • file.downloaded

11) ผูกกับ Request ID และ Structured Logging

เวลา file upload มีปัญหา ทีมมักต้องตอบคำถามพวกนี้

  • request นี้มาจาก user ไหน
  • ไปค้างที่ชั้นไหน
  • fail ตอนออก signed URL, ตอน upload, ตอน save metadata หรือ ตอน scan
  • ใช้เวลากี่ ms
  • ไฟล์ขนาดเท่าไร
  • object key ไหนเกี่ยวข้อง

ดังนั้น log ควรมี field ที่ค้นได้จริง เช่น

  • request_id
  • actor_id
  • resource_type
  • resource_id
  • file_size_bytes
  • mime_type
  • storage_key
  • upload_strategy
  • scan_status
  • latency_ms

12) อย่าเปิด Public URL แบบเดาง่ายถ้าไฟล์ไม่ควร public

ถ้าไฟล์เป็นเอกสารสำคัญ อย่าใช้ pattern ที่เดา path ได้ง่าย เช่น

/uploads/customer-123/passport.pdf

หรือปล่อย bucket ให้ public ทั้งก้อนเพียงเพราะสะดวกตอนพัฒนา

แนวทางที่ปลอดภัยกว่าคือ

  • private object storage
  • signed download URL
  • backend-controlled file proxy
  • access check ทุกครั้งก่อนปล่อยไฟล์

13) กำหนด Retention และ Lifecycle Policy

ไฟล์ที่ผู้ใช้อัปโหลดไม่ควรถูกเก็บไปเรื่อย ๆ โดยไม่มี policy

ควรถามให้ชัดว่า

  • ถ้า upload ไม่สมบูรณ์ จะลบทิ้งเมื่อไร
  • temporary files จะอยู่ได้กี่ชั่วโมง
  • rejected files จะเก็บกี่วัน
  • เอกสารที่หมดอายุแล้วต้อง archive หรือลบไหม
  • soft delete แล้วลบจริงเมื่อไร

เรื่องนี้สำคัญทั้งด้าน cost, compliance และลดความรกของ storage

14) ระวัง Metadata Leakage

แม้ตัวไฟล์จะปลอดภัย แต่ metadata ก็อาจหลุดข้อมูลได้ เช่น

  • original filename มีชื่อบุคคล
  • path มี tenant name หรือเลขอ้างอิงสำคัญ
  • response payload ส่ง internal storage key กลับ client ตรง ๆ

ดังนั้นควรแยก

  • storage key ภายใน
  • display name ที่ user เห็น
  • external URL ที่ใช้ชั่วคราว

15) แยก Public Asset Flow ออกจาก Sensitive Document Flow

หลายระบบพังเพราะใช้ flow เดียวกับทุกอย่าง เช่น

  • รูปโปรไฟล์
  • ไฟล์สลิป
  • เอกสารสัญญา
  • รูปประกอบโพสต์
  • เอกสารยืนยันตัวตน

แต่ไฟล์แต่ละประเภทมี risk profile ต่างกันมาก

ตัวอย่างที่เหมาะกว่า

Public Asset Flow

ใช้กับ

  • รูปหน้าปก
  • รูปประกอบ content
  • asset ที่ตั้งใจให้เปิดสาธารณะ

Sensitive Document Flow

ใช้กับ

  • บัตรประชาชน
  • statement
  • claim document
  • สัญญา
  • invoice ภายใน
  • เอกสารที่มี PII

สอง flow นี้ควรแยก policy ชัดเจนทั้งเรื่อง access, retention, logging, และ exposure

ตัวอย่าง Express/Node.js แบบเริ่มต้น

ตัวอย่างนี้ตั้งใจให้เห็น “โครงขั้นต่ำ” ของ upload endpoint แบบที่ปลอดภัยขึ้นกว่าการรับไฟล์ลอย ๆ แม้จะยังไม่ใช่ production เต็มรูปแบบ

const express = require("express");
const crypto = require("crypto");

const app = express();

app.post("/documents/upload-intent", express.json(), async (req, res) => {
  try {
    const actorId = req.get("X-Actor-Id");
    const requestId = req.get("X-Request-Id") || `req_${crypto.randomUUID()}`;

    if (!actorId) {
      return res.status(401).json({
        error: "Missing actor identity"
      });
    }

    const { originalFilename, mimeType, sizeBytes, resourceType, resourceId } = req.body;

    validateUploadIntent({ originalFilename, mimeType, sizeBytes, resourceType, resourceId });

    const safeExtension = resolveAllowedExtension(mimeType);
    const storageKey = `private/${resourceType}/${resourceId}/file_${crypto.randomUUID()}.${safeExtension}`;

    await writeAuditEvent({
      eventType: "file.upload_requested",
      actorId,
      resourceType,
      resourceId,
      requestId,
      metadata: {
        originalFilename,
        mimeType,
        sizeBytes,
        storageKey
      }
    });

    return res.status(201).json({
      success: true,
      uploadStrategy: "signed-url",
      storageKey,
      uploadUrl: `https://storage.example.com/upload/${encodeURIComponent(storageKey)}?signature=demo`,
      expiresInSeconds: 900,
      requiredHeaders: {
        "Content-Type": mimeType
      }
    });
  } catch (error) {
    console.error("Upload intent failed:", error);
    return res.status(400).json({
      error: error.message || "Upload intent failed"
    });
  }
});

function validateUploadIntent({ originalFilename, mimeType, sizeBytes, resourceType, resourceId }) {
  if (typeof originalFilename !== "string" || !originalFilename.trim()) {
    throw new Error("Invalid original filename");
  }

  if (typeof mimeType !== "string" || !mimeType.trim()) {
    throw new Error("Invalid mime type");
  }

  if (typeof sizeBytes !== "number" || sizeBytes <= 0) {
    throw new Error("Invalid file size");
  }

  if (sizeBytes > 10 * 1024 * 1024) {
    throw new Error("File too large");
  }

  const allowedMimeTypes = new Set([
    "application/pdf",
    "image/jpeg",
    "image/png",
    "image/webp"
  ]);

  if (!allowedMimeTypes.has(mimeType)) {
    throw new Error("Unsupported file type");
  }

  if (typeof resourceType !== "string" || !resourceType.trim()) {
    throw new Error("Invalid resource type");
  }

  if (typeof resourceId !== "string" || !resourceId.trim()) {
    throw new Error("Invalid resource id");
  }
}

function resolveAllowedExtension(mimeType) {
  const mapping = {
    "application/pdf": "pdf",
    "image/jpeg": "jpg",
    "image/png": "png",
    "image/webp": "webp"
  };

  const extension = mapping[mimeType];

  if (!extension) {
    throw new Error("Cannot resolve file extension");
  }

  return extension;
}

async function writeAuditEvent(event) {
  console.log("AUDIT", JSON.stringify(event, null, 2));
}

app.listen(3000, () => {
  console.log("Server listening on port 3000");
});

โค้ดชุดนี้กำลังช่วยอะไร

1) บังคับให้ upload ผ่าน intent flow ก่อน

เราไม่ได้ปล่อยให้ client โยนไฟล์มามั่ว ๆ แต่ต้องประกาศ metadata ก่อน เพื่อให้ server ตรวจ policy ได้ก่อน

2) ไม่ใช้ filename จาก client เป็น storage key ตรง ๆ

เราสร้าง storageKey ใหม่เองฝั่ง server

3) จำกัดชนิดและขนาดไฟล์

ตัวอย่างนี้ reject ไฟล์ที่ไม่ได้อยู่ใน allowlist และ reject ไฟล์ใหญ่เกินที่กำหนด

4) เริ่มทำ audit ได้ตั้งแต่ก่อน upload จริง

แม้ยังเป็นตัวอย่างง่าย ๆ แต่เรามี file.upload_requested ซึ่งช่วยเรื่อง traceability ได้มากกว่าระบบที่ไม่บันทึกอะไรเลย

จุดที่ต้องระวังใน production

1) อย่าคิดว่า signed URL แทน validation ทั้งหมดได้

signed URL ช่วยเรื่อง upload transport และ offload server แต่ไม่ได้แทนการ validate business rule, ownership, document type, หรือ state ของ resource

2) อย่าปล่อยให้ไฟล์พร้อมใช้งานทันทีทุกกรณี

โดยเฉพาะไฟล์จาก public users หรือเอกสารสำคัญ ควรมี state เช่น pending_scan ก่อน

3) อย่าลืม cleanup ไฟล์ orphan

บางครั้ง client ขอ upload intent แต่ไม่อัปโหลดจริง หรืออัปโหลดแล้ว metadata transaction ไม่ complete ต้องมี job cleanup ไม่งั้น storage จะรกและเสีย cost

4) อย่าลืมแยก permission ของการ “ขออัปโหลด” กับ “อ่าน/ดาวน์โหลด”

คนที่อัปโหลดได้ ไม่ได้แปลว่าทุกคนควรดาวน์โหลดไฟล์นั้นได้เสมอไป

รีวิวแนวทางนี้แบบ production-minded

Correctness

แนวทางที่ดีควรแยก upload transport ออกจากการยอมรับไฟล์เข้า workflow จริง ทำให้ระบบคุม state ได้ดีกว่า flow ที่อัปโหลดแล้วถือว่าเสร็จทันที

Security

จุดสำคัญอยู่ที่ allowlist, access control, private storage, rate limiting, และการไม่เชื่อข้อมูลจาก client โดยตรง

Efficiency

signed URL และ direct-to-storage upload ช่วยลดภาระ app server ได้มาก โดยเฉพาะระบบที่มีไฟล์ขนาดกลางถึงใหญ่หรือมี concurrent uploads สูง

Error handling

ระบบจริงควรแยก error ให้ชัด เช่น validation error, authorization error, storage issue, scan failure, metadata persistence failure และควร log ด้วย request id ทุกครั้ง

Checklist สั้น ๆ ก่อนปล่อย File Upload ขึ้น production

  • มี allowlist ของชนิดไฟล์ที่ชัดเจน
  • จำกัดขนาดไฟล์
  • ไม่เชื่อ Content-Type และ filename จาก client ตรง ๆ
  • สร้าง storage key ใหม่ฝั่ง server
  • แยก public กับ private file flow
  • ใช้ signed URL หรือ private object access ตามความเหมาะสม
  • มี rate limiting และ abuse protection
  • มี malware scanning หรือ review flow ถ้าระบบเสี่ยง
  • มี audit trail สำหรับ upload, delete, replace
  • มี request id และ structured logging
  • มี retention / lifecycle policy
  • มี cleanup job สำหรับ orphan files
  • มี access control ตอนดาวน์โหลด ไม่ใช่แค่อัปโหลด

บทความที่ควรอ่านต่อ

สรุป

File upload ไม่ใช่แค่ฟีเจอร์อัปโหลดไฟล์ แต่เป็นจุดตัดของ security, storage, access control, observability, และ operational discipline

ถ้าออกแบบหลวม มันจะกลายเป็นทั้งช่องโหว่และต้นทางของ incident ได้ง่ายมาก

สรุปสั้นที่สุดคือ

รับให้น้อย ตรวจให้ชัด เก็บให้ปลอดภัย เปิดให้อ่านเท่าที่จำเป็น และตามย้อนหลังได้ทุกขั้น

💬 Chat (ตอบเร็ว)