D
P
0
← Semua artikel Read in English

Next.js & React di Produksi

Data Tier Privat Masih Ada di Payload RSC Setiap Halaman? Menu `'use client'` Menerima Dokumen Payload CMS Utuh

· · 7 menit baca
Data Tier Privat Masih Ada di Payload RSC Setiap Halaman? Menu `'use client'` Menerima Dokumen Payload CMS Utuh

Instruksi klien soal tier keanggotaan privatnya jelas: tier itu tidak boleh muncul di situs publik. Tier tersebut khusus undangan dan hanya bisa dicapai lewat satu alamat tak terdaftar yang membawa token, tanpa tautan navigasi dan tanpa URL publik. Pada 15 September 2026, kalimat yang masih menyebutnya dibuang dari teks halaman. Namanya tetap ada di setiap halaman publik, kali ini di dalam payload RSC, dan yang membawanya ke sana justru menu navigasi.

Yang sudah dibereskan lebih dulu

Kebocoran lewat payload ini bukan yang pertama ditemukan untuk tier yang sama. Pada 14 September, sebuah audit menemukan bahwa koleksi tier menjawab siapa saja lewat REST, sehingga tier privat itu ikut terbuka. Pesan commit tambalannya mencatat bahwa lewat pintu REST dan GraphQL, tier khusus undangan itu beserta harganya, benefitnya, dan perkenalan privatnya bisa dibaca publik. Sesudah ditambal, aturan bacanya tenant-scoped dan hanya mendaftar tier publik ditambah tier yang dipegang member itu sendiri. Hari itu juga, kolom tier tersebut sengaja tidak ditambahkan ke tabel perbandingan publik, karena ucapan klien sendiri menjauhkan tier itu dari situs publik.

Yang dibereskan berikutnya adalah teks yang terlihat. Sampai commit pembersihan pada 15 September pukul 17.24, intro harga di halaman membership dan jawaban soal biaya di FAQ (di home, pricing, dan contact) masih memuat kalimat pendek bahwa tier itu hanya lewat undangan. Kalimat tersebut dibuang dari seed dan dari tiga berkas string, dan barisnya diubah manual di database lokal dan staging dalam tiga bahasa. Di database staging, itu berarti 3 baris intro dan 9 jawaban, angka yang cocok dengan satu intro dan tiga jawaban FAQ, masing-masing dalam tiga bahasa. Judul commit-nya menyatakan tier itu tidak lagi disebut di halaman publik.

Sisa namanya ada di payload RSC

Penyebabnya ada di header. Header itu menyerahkan item navigasi ke menu klien dengan relasi halamannya dalam keadaan ter-populate. Akibatnya, dokumen utuh setiap halaman yang ditaut menu, termasuk blok, tier, harga, dan benefit tier privat itu, ikut terbawa di payload setiap halaman. Di halaman home, payload itu tercatat 192 KB. Pesan commit perbaikannya merinci isi yang sama: layout, blok, dan tier keanggotaan beserta harga dan benefitnya terserialisasi ke payload setiap halaman, sehingga data tier privat berada di halaman-halaman yang menurut klien tidak boleh memuat jejaknya sedikit pun.

Kalau payload RSC masih terdengar abstrak: di App Router, komponen yang berkasnya diawali 'use client' dirender di server untuk HTML pertama, lalu dihidrasi di browser. Supaya hidrasi itu bisa berjalan, props yang dia terima dari server component diserialisasi ke payload RSC, dan pada muatan halaman pertama payload itu ditanam langsung di HTML sebagai potongan skrip self.__next_f.push(...). Serialisasi itu tidak peduli field mana yang benar-benar dipakai komponennya. Menu yang hanya menampilkan label tetap mengirim seluruh objek yang dia terima, dan karena header ada di setiap halaman, objek-objek itu ikut terkirim bersama setiap halaman.

Dari item menu sampai harga tier

Komponen yang menerima item tadi adalah menu ponsel, sebuah komponen klien yang berkasnya dibuka dengan direktif 'use client'. Header yang berjalan di server mengambil item menu langsung dari data navigasi tanpa diubah, lalu meneruskannya sebagai prop. Bentuknya sebelum perbaikan, disederhanakan:

// Disederhanakan: nama koleksi, komponen, dan variabel khas proyek diganti
// nama netral, dan props lain milik menu dibuang.
 
// Server: data menu dibaca lewat Local API
payload.find({ collection: 'menus', where, limit: 1, depth: 2, overrideAccess: true, locale })
 
// Header, sebelum perbaikan: item diambil dan diteruskan apa adanya
const items = menu?.header ?? []
<MenuDrawer items={items} actions={actions} />
 
// MenuDrawer.client.tsx, baris pertama
'use client'

Navigasinya dibaca lewat Local API dengan depth: 2, dan pesan commit perbaikannya menyebut alasannya: halaman di dalam item menu perlu ter-populate supaya alamatnya bisa dibangun di server. Bagian itu sah. Masalahnya ada di langkah berikutnya, ketika item yang sama diserahkan ke menu klien apa adanya.

Dua tingkat relasi sudah cukup untuk sampai ke tier. Field tautan di item menu punya relasi page ke koleksi halaman, dan blok tabel harga di koleksi halaman punya relasi tiers ke koleksi tier keanggotaan:

// Disederhanakan dari konfigurasi koleksi; nama koleksi diganti nama netral.
 
// Field tautan pada item menu
{ name: 'page', type: 'relationship', relationTo: 'site-pages' }
 
// Blok tabel harga di koleksi halaman. Komentar aslinya di atas blok:
// "nothing here stores a tier name, a price or a benefit.
//  The block holds presentation, and the records supply the substance."
{ name: 'tiers', type: 'relationship', relationTo: 'plans', hasMany: true }

Dengan depth: 2, Payload mengisi relasi tingkat pertama, yaitu dokumen halaman di dalam item menu, lalu relasi tingkat kedua di dalam dokumen itu, yaitu record tier di blok harga. Komentar di atas blok harga menegaskan bahwa blok itu tidak menyimpan nama, harga, atau benefit tier. Blok memegang tampilan, record tier memegang isinya. Itu cara yang masuk akal untuk menjaga harga tetap di satu tempat. Konsekuensinya, dokumen halaman yang ter-populate dua tingkat membawa record tier itu sendiri, lengkap dengan harga dan benefitnya.

Lalu kenapa aturan baca yang dipasang sehari sebelumnya tidak menahannya? Komentar pada aturan itu sudah menjawabnya. Halaman pricing, join, dan undangan privat membaca tier lewat Local API dengan akses di-override dan klausa tenant mereka sendiri, jadi aturan tersebut memang untuk pintu REST dan GraphQL, yang menjawab siapa saja. Navigasi pun dibaca dengan overrideAccess: true. Di Local API, overrideAccess bawaannya memang true, dan relasi yang ter-populate ikut dibaca dengan setelan yang sama. Di jalur ini tidak ada aturan akses yang menyaring tier privat itu.

Komentar yang ditambahkan bersama perbaikannya merangkum rantai ini dalam satu kalimat. Terjemahannya:

Navigasi dibaca dengan halaman ter-populate supaya alamatnya bisa dibangun di sini, tetapi dokumen halaman membawa seluruh layout-nya, dan menyerahkannya ke komponen klien akan menserialisasi setiap halaman yang ditaut ke payload setiap halaman.

Yang menyeberang ke klien tinggal label dan alamat

Commit perbaikannya tercatat pukul 17.39 dan berdiri langsung di atas commit pembersihan teks, lima belas menit sesudahnya. Judulnya merangkum perubahannya: menu ponsel menerima label dan alamat, bukan dokumen halaman. Header sekarang memetakan setiap tautan menjadi item ramping berisi label, alamat yang sudah di-resolve, dan flag tab baru. Anak-anak menu dipetakan dengan cara yang sama, begitu juga tombol aksi header.

// Dari header sesudah perbaikan; nama fungsi dan variabel khas proyek
// diganti nama netral.
const toMenuItem = (link: SiteLink & { id?: string | null }): Item => ({
  id: link.id,
  label: link.label,
  linkType: 'external',
  url: hrefOf(link),
  newTab: link.newTab,
})
 
const items = (menu?.header ?? []).map((item) => ({
  ...toMenuItem(item),
  children: (item.children ?? []).map(toMenuItem),
}))
const actions = (menu?.headerActions ?? []).map(toMenuItem)

Setiap tautan dijadikan tautan bertipe external dengan url yang sudah dihitung di server lewat hrefOf. Untuk tautannya, yang sampai ke browser hanya string alamat, bukan dokumen halaman beserta semua relasinya. Commit itu cuma mengubah satu berkas, komponen header, dengan 13 baris tambahan dan 3 baris dihapus. Komponen menu ponsel tidak disentuh di commit itu, dan menurut pesan commit-nya menu yang dirender tidak berubah.

Cara navigasi dibaca juga tidak diubah. Di commit perbaikan, panggilan Local API-nya tetap depth: 2 dan overrideAccess: true, karena server masih butuh halaman yang ter-populate untuk membangun alamat. Yang berubah hanya apa yang boleh keluar dari server.

Yang terukur, dan yang tidak tercatat

Pesan commit yang sama mencatat dua hasil: payload halaman home tidak lagi menyebut tier privat itu, dan payload klien menyusut kira-kira seperempat. Hanya itu verifikasi yang tercatat. Catatan saya tidak menyebut di mana pemeriksaannya dijalankan, tidak menyimpan ukuran payload sesudahnya dalam KB, dan tidak memuat pengecekan ulang payload di staging sesudah deploy. Commit itu juga tidak menambahkan tes.

Yang tercatat adalah posisi deploy-nya: malam itu staging sudah membawa semua perubahan, termasuk perbaikan ini. Dua hari kemudian, pada 17 September, kartu mega menu di header ikut dirampingkan dengan pola yang sama, sehingga yang diteruskan hanya tautan, teks, serta URL, alt, lebar, dan tinggi gambarnya.

Perbaikan header juga tidak berarti semua payload sudah kecil. Larut malam 15 September, di build produksi lokal, payload RSC halaman About sebesar 282 KB masih tercatat sebagai salah satu tuas yang tersisa, bersama peta inline 117 KB di halaman yang sama dan prefetch tautan header sebesar 140 KB fetch. Catatan saya tidak menyebut apa isi 282 KB itu, dan belum ada catatan penanganannya.

Aturan akses tidak menjaga props

Kasus ini memisahkan dua hal yang gampang dianggap satu. Aturan akses Payload menjaga pintu yang bisa diketuk dari luar, yaitu REST dan GraphQL. Kode server yang membaca lewat Local API dengan overrideAccess: true boleh melihat apa saja, dan itu wajar selama hasilnya tinggal di server. Begitu hasil itu diteruskan sebagai prop ke komponen 'use client', dia ikut terkirim ke setiap pengunjung, dan di titik itu aturan akses sudah tidak berperan.

Jadi apa pun yang menyeberang ke komponen klien perlu dibentuk dengan sengaja: petakan dokumen CMS ke objek kecil yang hanya berisi field yang dirender, seperti item ramping di header ini, alih-alih meneruskan dokumennya apa adanya. Kalau tujuannya memastikan sesuatu sudah hilang dari situs publik, yang diperiksa adalah HTML mentah beserta payload-nya, bukan teks yang tampak di layar. Membersihkan teks yang tampak hanya menutup pintu yang paling kelihatan.