Cybersecurity

Xác thực tệp tải lên trong Python: Không chỉ dựa vào phần mở rộng

Hướng dẫn xác thực tệp tải lên trong Python bằng nội dung thực: magic bytes cho PDF, âm thanh và kiểm tra ZIP an toàn cho DOCX.

Ảnh đại diện Long Nguyen

Long Nguyen

Lập trình viên Fullstack · Kỹ sư AI · Nhà nghiên cứu

• • 3 phút đọc •
Kiểm tra phần mở rộng bị đánh lừa bởi tệp đã đổi tên, trong khi kiểm tra nội dung đọc các byte đầu tệp sẽ chặn tệp đó

Phần mở rộng không phải là xác thực

Loại lỗ hổng này có tên gọi là lỗ hổng Tải tệp không hạn chế (được phân loại là CWE-434) và liên tục xuất hiện trong các vụ rò rỉ dữ liệu thực tế. Dựa vào phần mở rộng hoặc tiêu đề Content-Type do phía máy khách gửi lên là hai cách phổ biến nhất dẫn đến sai lầm — cả hai đều có thể dễ dàng bị vượt qua chỉ bằng cách đổi tên tệp hoặc giả mạo tiêu đề.

Nếu backend của bạn chấp nhận tệp tải lên và tin vào phần mở rộng để quyết định tệp đó là gì, thì thực ra bạn chưa xác thực điều gì cả. Phần mở rộng chỉ là phần cuối của tên tệp, và bất kỳ ai cũng có thể đổi tên bất kỳ thứ gì — một tệp độc hại được đặt tên resume.pdf sẽ vượt qua nguyên vẹn bước kiểm tra phần mở rộng. Để xác thực tệp tải lên trong Python đúng cách, bạn phải đọc nội dung thực sự của tệp, thay vì tin vào tên mà tệp tự nhận.

Hướng dẫn này trình bày một module xác thực nhỏ, không cần dependency: xác minh PDF và tệp âm thanh bằng chữ ký byte, đồng thời kiểm tra DOCX an toàn dựa trên cấu trúc archive. Mọi thành phần đều chỉ sử dụng thư viện chuẩn của Python.

Đọc phần đầu tệp mà không làm hỏng luồng dữ liệu

Mọi bước kiểm tra đều bắt đầu bằng việc đọc vài byte đầu tiên của tệp — nhưng trình xác thực không được đọc hết luồng dữ liệu, nếu không đoạn mã lưu tệp sau đó sẽ nhận được một stream rỗng. Vì vậy, hàm hỗ trợ đọc một phần dữ liệu rồi đưa con trỏ về đầu bằng seek(0):

def _read_head(f, size):
    f.seek(0)
    head = f.read(size)
    f.seek(0)
    return head

Lệnh seek(0) ở đầu cũng rất quan trọng: trước khi đến trình xác thực, tệp có thể đã bị một thành phần khác đọc qua, khiến con trỏ nằm giữa chừng. Đưa con trỏ về đầu trước khi đọc và một lần nữa sau khi đọc giúp hàm an toàn dù được gọi ở bất kỳ thời điểm nào. Đối tượng tệp ở đây hoạt động như mọi tệp Python khác — xem tài liệu io để biết cách seek và read hoạt động.

Xác thực tệp PDF

Một tệp PDF thực sự tự nhận diện bằng marker %PDF-. Điểm tinh tế là đặc tả PDF cho phép có dữ liệu rác tùy ý trước phần header đó — nhiều trình đọc thực tế chấp nhận %PDF- xuất hiện ở bất kỳ vị trí nào trong 1024 byte đầu tiên, thay vì bắt buộc nằm tại byte 0. Vì vậy, bước kiểm tra tìm kiếm trong kilobyte đầu tiên thay vì chỉ kiểm tra vài byte đầu:

def is_pdf(f):
    # The PDF spec allows junk before the header; readers accept %PDF-
    # anywhere in the first 1024 bytes.
    return b'%PDF-' in _read_head(f, 1024)

Đây là ví dụ điển hình cho thấy lời khuyên “chỉ kiểm tra 4 byte đầu tiên” quá đơn giản. Các định dạng có những đặc điểm riêng, và việc mô phỏng hành vi trong thực tế — ở đây là quét cả một vùng thay vì một vị trí cố định — mới tạo nên sự khác biệt giữa trình xác thực hoạt động hiệu quả và trình xác thực từ chối các tệp hợp lệ. Chữ ký này được định nghĩa trong đặc tả PDF (tài liệu tổng quan về container của MDN là nguồn tham khảo hữu ích về cách các định dạng tự khai báo).

Xác thực DOCX (trường hợp phức tạp)

DOCX là nơi các bước kiểm tra chữ ký đơn giản dễ thất bại. Tệp .docx không phải một định dạng độc lập — nó là một ZIP archive, nên bắt đầu bằng chữ ký ZIP PK\x03\x04. Tuy nhiên, mọi tệp dựa trên ZIP khác cũng có chữ ký này: .xlsx, .pptx, .epub và .zip thông thường. Xác nhận “đây là ZIP” không đồng nghĩa với xác nhận “đây là tài liệu Word”.

Vì vậy, việc xác thực diễn ra qua hai bước: trước tiên kiểm tra chữ ký ZIP, sau đó xem bên trong archive để tìm cấu trúc bắt buộc của một DOCX thực sự — một mục [Content_Types].xml và một thư mục word/:

import zipfile


def is_docx(f):
    # DOCX is a ZIP archive (PK\x03\x04) containing [Content_Types].xml
    # and a word/ folder. Checking the archive listing rejects arbitrary
    # ZIPs without decompressing anything (zip-bomb safe).
    if _read_head(f, 4) != b'PK\x03\x04':
        return False
    try:
        with zipfile.ZipFile(f) as zf:
            names = zf.namelist()
            return '[Content_Types].xml' in names and any(
                n.startswith('word/') for n in names
            )
    except (zipfile.BadZipFile, OSError, ValueError):
        return False
    finally:
        f.seek(0)

Có hai chi tiết về an toàn cần lưu ý. Thứ nhất, namelist() chỉ đọc chỉ mục của archive — nó không giải nén nội dung, nhờ đó bước kiểm tra này an toàn trước zip bomb (một archive nhỏ nhưng phình ra thành hàng gigabyte). Xem tài liệu zipfile để biết namelist hoạt động như thế nào. Thứ hai, khối except rộng xem mọi archive bị lỗi hoặc không thể đọc đơn giản là “không phải DOCX hợp lệ”, thay vì để một tệp được tạo có chủ đích làm request bị lỗi — còn finally đưa con trỏ tệp về đầu để tệp vẫn có thể được sử dụng sau đó.

Nội dung phải khớp với tuyên bố về định dạng

Khi đã có các bước kiểm tra riêng lẻ, trình xác thực hồ sơ sẽ đối chiếu chúng với phần mở rộng được khai báo. Đây là một quy tắc tinh tế nhưng quan trọng: tệp không nên được chấp nhận chỉ vì nó khớp với một loại được cho phép nào đó — nó phải khớp với loại mà nó tự nhận là:

from pathlib import Path


def is_valid_resume_file(f):
    """Content must match the claimed extension, not just any allowed type."""
    ext = Path(f.name).suffix.lower()
    if ext == '.pdf':
        return is_pdf(f)
    if ext == '.docx':
        return is_docx(f)
    return False

Tại sao phải buộc tuyên bố về định dạng khớp với nội dung? Vì bản thân sự không khớp đã là một dấu hiệu đáng ngờ. Một tệp có đuôi .pdf nhưng các byte cho thấy đó là DOCX — hoặc ngược lại — либо là tệp bị hỏng, либо là một nỗ lực nào đó, và cả hai đều không nên đi vào pipeline của bạn. Lệnh return False mặc định cũng có nghĩa mọi thứ không được cho phép rõ ràng đều bị từ chối: đây là tư thế “mặc định từ chối”, hướng tiếp cận an toàn trong mã bảo mật.

Xác thực âm thanh: một định dạng, nhiều chữ ký

Âm thanh cho thấy một cạm bẫy khác: cùng một định dạng logic có thể bắt đầu theo nhiều cách hợp lệ. Trình duyệt tạo âm thanh thông qua MediaRecorder sẽ tạo ra các container khác nhau — Chrome, Edge và Firefox thường tạo WebM hoặc Ogg, còn Safari tạo MP4/M4A — trong khi frontend ở đây luôn đặt tên blob là answer.webm, bất kể định dạng thực sự là gì. Vì vậy, trình xác thực kiểm tra toàn bộ tập chữ ký mà các tệp trong thực tế có thể có, thay vì chỉ kiểm tra một chữ ký:

def is_valid_audio_file(f):
    head = _read_head(f, 12)
    if head.startswith(b'\x1aE\xdf\xa3'):            # EBML -> WebM/Matroska
        return True
    if head.startswith(b'OggS'):                     # Ogg
        return True
    if head[4:8] == b'ftyp':                          # MP4 / M4A
        return True
    if head.startswith(b'RIFF') and head[8:12] == b'WAVE':  # WAV
        return True
    if head.startswith(b'ID3'):                       # MP3 with ID3 tag
        return True
    if len(head) >= 2 and head[0] == 0xFF and (head[1] & 0xE0) == 0xE0:  # raw MP3 frame sync
        return True
    return False

Một vài trường hợp dưới đây đáng để hiểu rõ, vì chúng cho thấy chữ ký có thể đa dạng đến mức nào:

  • MP4/M4A không bắt đầu ở byte 0 — marker ftyp nằm tại offset 4, sau một trường độ dài, nên bước kiểm tra đọc head[4:8] thay vì phần đầu.
  • WAV cần hai marker: nó mở đầu bằng RIFF, nhưng các định dạng khác dựa trên RIFF cũng vậy, nên cần xác nhận thêm WAVE xuất hiện tại byte 8–12.
  • MP3 có hai dạng hợp lệ: một dạng có thẻ metadata ID3 ở đầu, và một dạng đi thẳng vào dữ liệu âm thanh với “frame sync” — 11 bit được đặt thành 1, được phát hiện bằng phép kiểm tra bitwise head[1] & 0xE0 == 0xE0.

Trường hợp MP3 cuối cùng là kiểu chi tiết mà một bảng chữ ký trong tutorial thường không cảnh báo: bỏ sót nó, bạn sẽ từ chối các tệp MP3 hợp lệ được tạo bởi một số encoder. Công việc thực sự là bắt kịp sự lộn xộn của các tệp trong thực tế.

Xác thực nội dung chỉ là một lớp bảo vệ

Module này trả lời chính xác một câu hỏi — “tệp này có thực sự thuộc loại mà nó tự nhận không?” — mà không cần dependency bên thứ ba. Tuy nhiên, đây chỉ là một lớp trong nhiều lớp bảo vệ, không phải giải pháp hoàn chỉnh cho việc phòng thủ khi tải tệp lên. Bạn vẫn cần giới hạn kích thước được áp dụng trước khi đọc tệp, lưu tệp bên ngoài mọi đường dẫn có thể thực thi hoặc được máy chủ mã nguồn phục vụ, và phân phối tệp trở lại theo cách một tên tệp được tạo có chủ đích không thể thoát khỏi thư mục của nó. Tư duy cốt lõi là về ranh giới tin cậy: một tệp đi từ người dùng vào hệ thống của bạn vẫn không đáng tin cho đến khi nội dung của nó chứng minh điều ngược lại.

Khi xây dựng đúng các lớp bảo vệ, từng bước kiểm tra riêng lẻ — như những bước ở trên — sẽ trở thành một phần của cơ chế phòng thủ đủ vững trước dữ liệu đầu vào thực tế, có tính đối nghịch, thay vì chỉ hoạt động với các tệp thử nghiệm “ngoan ngoãn”.

Cũng cần hiểu giới hạn của việc xác thực nội dung: một tệp polyglot — được tạo ra để đồng thời hợp lệ dưới hai định dạng khác nhau — có thể chứa magic bytes hợp lệ của một loại được cho phép nhưng vẫn ẩn giấu nội dung nguy hiểm. Đây chính là lý do các hướng dẫn bảo mật nghiêm túc kết hợp nhiều lớp phòng thủ thay vì tin vào một bước kiểm tra duy nhất: xác thực magic bytes, giới hạn kích thước, lưu tệp bên ngoài các đường dẫn có thể thực thi và (đối với hình ảnh) mã hóa lại để loại bỏ nội dung nhúng. OWASP File Upload Cheat Sheet là tài liệu tham khảo nên đọc đầy đủ.

CÂU HỎI THƯỜNG GẶP

Câu hỏi thường gặp

Tại sao chỉ kiểm tra phần mở rộng không đủ để xác thực tệp tải lên?

Phần mở rộng chỉ là một phần của tên tệp và bất kỳ ai cũng có thể đổi tên tệp. Một tệp độc hại được đổi tên với đuôi .pdf vẫn vượt qua bước kiểm tra phần mở rộng. Chỉ việc đọc nội dung thực của tệp — tức chữ ký byte — mới cho biết tệp thực sự là gì.

Làm thế nào để xác thực tệp DOCX trong Python?

DOCX là một ZIP archive, vì vậy trước tiên bạn xác nhận chữ ký ZIP (PK\x03\x04), sau đó kiểm tra danh sách bên trong archive để tìm mục [Content_Types].xml và thư mục word/. Chỉ đọc chỉ mục archive bằng namelist() giúp tránh giải nén bất kỳ nội dung nào, nhờ đó an toàn trước zip bomb.

Tại sao trình xác thực âm thanh phải kiểm tra nhiều chữ ký khác nhau?

Một định dạng âm thanh logic có thể bắt đầu theo nhiều cách hợp lệ. Các trình duyệt tạo ra những container khác nhau thông qua MediaRecorder — WebM, Ogg, MP4/M4A — và riêng MP3 cũng có hai dạng hợp lệ. Trình xác thực đúng phải chấp nhận toàn bộ tập dạng mà các encoder thực tế tạo ra, thay vì chỉ kiểm tra một chữ ký trong tutorial.

Chỉ xác thực magic bytes có đủ để bảo mật tệp tải lên không?

Không. Cách này trả lời tốt câu hỏi “tệp có đúng là loại đã khai báo không?”, nhưng xử lý tệp tải lên an toàn còn cần giới hạn kích thước trước khi đọc, lưu tệp bên ngoài các đường dẫn có thể thực thi và phân phối tệp trở lại một cách an toàn. Xác thực nội dung là một lớp quan trọng trong cơ chế phòng thủ lớn hơn.

Cập nhật cùng Netalith

Nhận kiến thức công nghệ, cập nhật sản phẩm và ưu đãi đặc biệt qua email.