Khi người dùng cần tải báo cáo PDF, file CSV xuất từ hệ thống hay ảnh đã upload, cách đúng trong Flask là dùng send_file() với as_attachment=True và download_name. Hàm này tự đoán MIME type, đặt đúng header Content-Disposition và hỗ trợ tải từng phần, thay vì bạn tự mở file và trả Response thủ công. Dùng send_file() cho mọi endpoint trả về file, dù file nằm trên đĩa hay mới tạo trong bộ nhớ.

Vấn đề

Tình huống quen thuộc: sau khi người dùng bấm “Xuất báo cáo”, server tạo file rồi trả về. Nhiều code mẫu làm thế này: ghép đường dẫn bằng f-string, mở file và trả Response với header tự ghi. Cách này có ba rủi ro. Thứ nhất, tên file chứa ../ có thể thoát khỏi thư mục cho phép và đọc file hệ thống — lỗi path traversal. Thứ hai, thiếu as_attachment khiến trình duyệt mở file trực tiếp thay vì tải về, hoặc hiển thị nội dung nhạy cảm ngay trong tab. Thứ ba, tự đặt MIME type sai làm trình duyệt hiểu nhầm file, ví dụ file CSV bị mở như HTML. Cả ba đều tránh được nếu kiểm tra đường dẫn trước và để Flask lo phần header.

from flask import Flask, Response
app = Flask(__name__)
@app.get("/download/<path:filename>")
def download_bad(filename):
# NGUY HIỂM: filename = "../../secret.txt" sẽ đọc ra ngoài thư mục exports
with open(f"exports/{filename}", "rb") as f:
data = f.read()
return Response(data, headers={"Content-Disposition": f"attachment; filename={filename}"})

Giải pháp

Quy tắc gồm hai bước: chặn đường dẫn ra ngoài thư mục cho phép, rồi giao file cho send_file(). Dùng Path.resolve() và is_relative_to() để kiểm tra, đừng tin tên file do người dùng gửi lên. Khi file đã nằm trong thư mục an toàn, send_file() lo phần còn lại: đoán MIME type, đặt Content-Disposition với download_name, và hỗ trợ yêu cầu Range để tải tiếp khi mạng chập chờn.

from pathlib import Path
from flask import Flask, abort, send_file
app = Flask(__name__)
BASE = Path(__file__).resolve().parent / "exports"
@app.get("/download/<path:filename>")
def download(filename):
path = (BASE / filename).resolve()
if not path.is_relative_to(BASE.resolve()) or not path.is_file():
abort(404)
return send_file(path, as_attachment=True, download_name=path.name)

Nếu file chỉ tồn tại trong bộ nhớ, ví dụ CSV vừa tạo bằng io.StringIO, chuyển sang BytesIO rồi truyền thẳng vào send_file() kèm mimetype.

import csv
import io
from flask import Flask, send_file
app = Flask(__name__)
@app.get("/export/users.csv")
def export_users():
text = io.StringIO()
writer = csv.writer(text)
writer.writerow(["id", "name", "email"])
writer.writerow([1, "Linh", "linh@example.com"])
buf = io.BytesIO(text.getvalue().encode("utf-8"))
return send_file(buf, mimetype="text/csv",
as_attachment=True, download_name="users.csv")

Áp dụng vào dự án của bạn

Trong dự án thật, đặt mọi file cho phép tải vào một thư mục riêng như exports/ hoặc uploads/, đừng để đường dẫn do client gửi lên quyết định file nào được trả. Với file xuất theo yêu cầu, tạo CSV trong bộ nhớ rồi trả bằng send_file() để tránh rác file tạm. Với file người dùng đã upload, kết hợp kiểm tra quyền sở hữu trước khi gọi send_file() — kiểm tra đường dẫn chỉ chặn traversal, không thay thế phân quyền.

Bài viết liên quan

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

send_file() khác gì send_from_directory()?

send_from_directory() gộp kiểm tra thư mục và gửi file trong một lần gọi, tiện khi file luôn nằm trong một thư mục tĩnh. send_file() linh hoạt hơn khi file ở vị trí động hoặc chỉ tồn tại trong bộ nhớ.

Khi nào dùng as_attachment=False?

Để False khi muốn trình duyệt hiển thị trực tiếp, ví dụ xem PDF hay ảnh trong tab. Để True khi muốn ép trình duyệt tải file về máy người dùng.

File lớn có làm đầy RAM không?

Không, nếu truyền đường dẫn file trên đĩa, Flask và Werkzeug stream file theo khối chứ không đọc toàn bộ vào RAM. Chỉ file BytesIO mới nằm hoàn toàn trong bộ nhớ, nên đừng dùng cách đó cho file lớn.

download_name có thực sự cần thiết không?

Có, khi tên file trên đĩa khác tên muốn hiển thị, hoặc tên chứa ký tự đặc biệt — Flask sẽ mã hoá đúng chuẩn trong header.

💬 Bạn đã thử cách này trong dự án của mình chưa? Nếu gặp lỗi hoặc có cách làm gọn hơn, để lại bình luận bên dưới — mình đọc hết và sẽ bổ sung những ý hay vào bài.

Nguồn tham khảo