Keamanan · 2 menit baca · 21 Oktober 2026

Satu token, satu aksi: upload yang tidak bisa berkeliaran di bucket

CMS mengunggah file langsung dari browser ke Worker penyimpanan. Yang mencegah endpoint itu menjadi bucket terbuka adalah token yang hanya hidup lima menit dan menyebut satu hal yang boleh disentuhnya.

Kunci kartun berkepala timer membuka satu laci lemari arsip, sementara laci lainnya tetap bergembok
5 menit
umur setiap token penyimpanan
1
aksi per token: upload, hapus, atau list
0
jenis file yang diambil dari klaim browser

01 · Masalahnya

File besar tidak bisa lewat server aplikasi.

CMS-nya berjalan di Vercel, yang membatasi body request di 4,5 MB. Video jauh lebih besar dari itu, jadi browser mengunggah langsung ke Cloudflare Worker di depan bucket R2. Worker yang menerima upload dari browser adalah endpoint publik. Tanpa pengecekan, siapa pun yang menemukan URL-nya bisa upload, melihat isi, atau menghapus.

02 · Satu token per aksi

CMS menandatangani token baru untuk setiap aksi.

Setelah sesi dicek, server CMS menandatangani JWT. Issuer-nya CMS, audience-nya Worker penyimpanan, dan kedaluwarsa dalam 300 detik. Isinya tepat satu izin: upload ke satu folder, hapus satu key, atau list satu prefix. Token yang bocor tidak bisa dipakai ke mana-mana.

type Grant =
  | { scope: "upload"; folder: "images" | "files" | "videos" }
  | { scope: "delete"; key: string }
  | { scope: "list"; prefix: string };

export function mintStorageToken(userId: number, grant: Grant) {
  return new SignJWT({ ...grant })
    .setProtectedHeader({ alg: "HS256" })
    .setSubject(String(userId))
    .setIssuer("cms")
    .setAudience("storage-worker")
    .setIssuedAt()
    .setExpirationTime("300s")
    .sign(secret);
}

03 · Worker mengecek semuanya

Worker percaya pada token, bukan pada request.

Worker memverifikasi tanda tangan, issuer, audience, dan masa berlaku. Lalu scope harus cocok dengan route: token upload yang dikirim ke route hapus mendapat 403. Lalu targetnya harus cocok: folder, key, atau prefix diambil dari token, bukan dari body request.

const { payload } = await jwtVerify(token, secret, {
  algorithms: ["HS256"],
  issuer: "cms",
  audience: "storage-worker",
});
if (payload.scope !== scope) {
  throw new AuthError(403, `Token scope "${payload.scope}" cannot be used here`);
}

04 · Jangan percaya browser soal jenis file

file.type adalah apa pun yang dikatakan browser.

Worker menentukan content type dari ekstensi file dan mengabaikan kiriman client. Ekstensi yang bisa membawa script, seperti .svg dan .html, diblokir. Setiap folder punya batas ukuran sendiri: 10 MB untuk gambar, 25 MB untuk file, 200 MB untuk video. Key harus berada langsung di bawah folder yang diizinkan, dan apa pun yang mengandung .. ditolak.

05 · Satu pintu masuk

Codebase-nya hanya punya satu jalur upload.

Kode fitur tidak boleh merender <input type="file"> sendiri. Semua upload lewat satu asset picker, yang meminta token lalu berbicara dengan Worker. Aturan file di Worker dan di CMS ada di dua file yang harus diubah bersamaan, dan aturan proyeknya menuliskan itu secara eksplisit.

06 · Harganya

Satu round trip tambahan, dan satu secret yang harus dijaga.

Setiap upload meminta token ke CMS dulu, satu request kecil sebelum file yang bisa mencapai 200 MB. Token ditandatangani dengan secret yang sama dengan sesi CMS, jadi merotasinya juga mengeluarkan semua editor dari sesi. Lain kali saya akan memberi penyimpanan secret-nya sendiri.

Tulisan berikutnyaPublish tanpa rebuild