D
P
0
← Semua artikel Read in English

Next.js & React di Produksi

`cat.trim is not a function` dari `?cat=a&cat=b`? `searchParams` Next.js Berisi Array, Bukan String

· · 9 menit baca
`cat.trim is not a function` dari `?cat=a&cat=b`? `searchParams` Next.js Berisi Array, Bukan String

Komentar di helper perbaikan ini memuat satu kalimat yang tidak enak dibaca: sebelum helper itu ada, siapa pun bisa merusak empat halaman situs hanya dengan sebuah URL. Yang dibutuhkan cuma satu kunci query yang ditulis dua kali, misalnya ?cat=a&cat=b.

Masalah ini masuk lewat daftar pengerasan keamanan sebuah situs Next.js yang sedang saya kerjakan. Verifikasi ulang yang read-only pada 30 September mencatat 45 akar masalah, 4 sudah tertutup dan 41 masih terbuka, lalu memeringkat delapan langkah berikutnya. Serah terima 1 Oktober menaruh kelompok nilai URL di peringkat kedua, mencatat bahwa kelompok itu tidak butuh persetujuan, dan menyarankannya sebagai titik mulai. Isinya campuran, dari allowlist untuk ?cat= dan ?chain= sampai satu helper kecil untuk membaca nilai query. Pada 5 Oktober pukul 14.10 WIB saya memutuskan butir 5 dan 6 dari kelompok itu dikerjakan langsung dan sisanya dicatat dulu. Butir 5 adalah kunci query yang diulang. Butir 6 perbaikan lain yang tidak saya bahas di sini.

Reproduksi di staging

Sebelum mengubah apa pun, saya mereproduksinya di staging. Empat halaman listing merender error state begitu kunci filternya diulang: direktori yang disaring per kategori lewat cat, indeks istilah yang disaring per huruf lewat letter, dan dua listing berpaginasi, satu disaring lewat cat dan satu lagi lewat chain. Dua URL lain dengan kunci yang diulang tetap aman, yaitu rentang grafik di halaman detail dan nomor halaman di listing artikel. Path di bawah ini saya samarkan, query-nya apa adanya:

# staging, sebelum perbaikan (path disamarkan, query apa adanya)
/directory?cat=a&cat=b        error state, HTTP 200, ada error digest
/entries?letter=a&letter=b    error state, HTTP 200, ada error digest
/list-a?cat=a&cat=b           error state, HTTP 200, ada error digest
/list-b?chain=a&chain=b       error state, HTTP 200, ada error digest
/item/x?range=7d&range=30d    aman
/posts?page=2&page=3          aman

Yang membuat kasus ini licin adalah bentuk kegagalannya. Error state itu dikirim dengan status HTTP 200 dan membawa error digest, jadi kalau dilihat dari kode status, semuanya tampak sehat. Di build produksi, Next.js memang tidak mengirim pesan galat asli dari server component ke browser, hanya sebuah digest untuk dicocokkan dengan log server. Dari luar, yang berubah adalah isinya: ada digest di respons, dan ukurannya menyusut. Respons error state hanya 37 sampai 65 KB, padahal halaman yang sehat 200 KB atau lebih. Hitungannya, 37 dibagi 200 sekitar 0,19 dan 65 dibagi 200 sekitar 0,33, jadi halaman yang rusak paling besar sekitar sepertiga ukuran normalnya.

Anotasi string yang tidak jujur

Komentar helper yang sama juga menjelaskan akarnya: Next menyerahkan ?cat=a&cat=b ke halaman sebagai array ["a", "b"]. Itu bukan bug Next. Dokumentasinya sendiri mengetik setiap nilai searchParams sebagai string | string[] | undefined, dan proyek ini berjalan di Next.js 16.3.6. Yang keliru adalah tipe yang ditulis keempat halaman itu untuk dirinya sendiri, yang menyatakan nilainya selalu string. Baris-baris di bawah ini kutipan apa adanya dari keadaan sebelum perbaikan, dan komentar di ujung baris adalah tambahan saya untuk menunjukkan asalnya:

// sebelum perbaikan: tipe di listing berpaginasi pertama
type Search = { page?: string; cat?: string };
 
// satu baris dari tiap halaman yang patah
const filter = cat?.toLowerCase();                             // direktori
letterParam && grouped.has(letterParam.toUpperCase())          // indeks istilah
const category = cat?.trim() || undefined;                     // listing pertama
const currentPage = cat?.trim() ? 1 : parsePage(page);         // listing pertama, generateMetadata
const chainFilter = chain?.trim().toLowerCase() || undefined;  // listing kedua

Selama tipenya string, TypeScript tidak punya alasan untuk protes, dan anotasi tipe toh hilang saat runtime. Begitu kuncinya diulang, yang datang adalah array, array tidak punya method trim, dan galat yang dicatat komentar helper berbunyi cat.trim is not a function. Pesan commit perbaikannya merangkum keempat halaman dengan pola yang sama: nilainya diketik sebagai string, lalu trim(), toLowerCase(), atau toUpperCase() dipanggil langsung, sehingga kunci yang diulang melempar galat dan halamannya merender error state. Di listing pertama, trim() dipanggil di badan halaman dan sekali lagi di generateMetadata.

Dua tempat yang sudah selamat

Dua URL yang tetap aman ternyata lebih banyak bercerita daripada empat yang rusak. Parser nomor halaman di listing artikel mengembalikan 1 untuk apa pun yang bukan string berisi bilangan bulat positif polos, dan pemeriksa rentang grafik membandingkan nilainya dengan === terhadap daftar rentang yang dikenal. Nama konstantanya saya samarkan, sisanya apa adanya:

// parser nomor halaman di listing artikel
if (typeof raw !== "string" || !/^[1-9][0-9]*$/.test(raw)) return 1;
 
// pemeriksa rentang grafik di halaman detail
return RANGES.some((r) => r.value === v);

Parser pertama memeriksa typeof sebelum menyentuh nilainya, dan komentarnya bahkan menyebut param yang diulang secara eksplisit sebagai nilai yang dibaca sebagai halaman 1, sejajar dengan "0", "-3", "2.5", "03", dan "abc". Pemeriksa kedua tidak perlu tahu apa-apa soal array. Array yang datang dari URL adalah objek baru, dan === tidak pernah menganggapnya sama dengan nilai mana pun di daftar, jadi range yang diulang gagal dengan sendirinya. Pesan commit perbaikannya mencatat bahwa listing artikel, listing kategori, dan rentang di halaman detail sudah toleran array, jadi ketiganya tidak disentuh.

Polanya sederhana. Kode yang memeriksa bentuk nilai sebelum memakainya selamat, sedangkan kode yang percaya pada anotasi lalu langsung memanggil method string patah begitu bentuknya berubah.

Helper yang hanya melihat nilai pertama

Perbaikannya satu helper kecil beserta tesnya, dipakai di keempat berkas halaman yang patah. Nama fungsi dan tanda tangannya saya samarkan dan sederhanakan, sedangkan nilai bawaan maxLength, baris first, dan pemeriksaan typeof di dalamnya apa adanya:

// disederhanakan: nama fungsi dan tanda tangannya disamarkan,
// nilai bawaan maxLength, baris first, dan pemeriksaan typeof apa adanya
export function firstParam(
  value: string | string[] | undefined,
  maxLength = 120,
): string | undefined {
  const first: unknown = Array.isArray(value) ? value[0] : value;
  if (typeof first !== "string" || first.length > maxLength) return undefined;
  return first;
}

Kalau nilainya array, helper mengambil elemen pertama. Kalau bukan, nilainya dipakai apa adanya. Hasilnya undefined kalau nilai pertama itu bukan string atau lebih panjang dari 120 karakter. Komentar helper menegaskan bahwa nilai yang melewati batas dianggap tidak ada, bukan dipotong, dengan alasan tidak ada filter asli di situs itu yang mendekati batas tersebut. Bagi pemanggilnya, undefined sama bentuknya dengan parameter yang tidak dikirim sama sekali.

Tiga baris dari tesnya menunjukkan kontrak itu lebih tegas daripada penjelasan mana pun:

// tiga baris dari tes helper, nama fungsinya disamarkan
expect(firstParam(["a", "b"])).toBe("a");
expect(firstParam(["a".repeat(500), "ok"])).toBeUndefined();
expect(() => firstParam(raw)?.trim().toLowerCase()).not.toThrow();

Baris kedua yang paling penting. Nilai pertama sepanjang 500 karakter menghasilkan undefined walaupun nilai keduanya "ok", karena helper tidak mencari nilai yang layak di antara sisanya. Dia hanya melihat yang pertama. Baris ketiga dijalankan untuk setiap masukan di daftar uji dan memastikan rantai trim().toLowerCase() yang dulu patah tidak lagi melempar galat. Menurut pesan commit, cat, chain, dan letter dibaca lewat helper ini di keempat halaman, dan nilai tunggal tetap berperilaku persis seperti sebelumnya, termasuk canonical dan title-nya.

Draf pertama ikut membaca page

Draf pertama memakai helper yang sama untuk satu parameter lagi. Catatan ringkas pukul 14.30 WIB, sebelum hasil review masuk, menyebut helper itu dipakai untuk cat, chain, letter, dan page. Di build produksi lokal, draf itu sudah merender keempat URL berkunci ganda dengan canonical yang benar dan tanpa error digest.

Draf itu lalu diperiksa workflow review adversarial dengan tiga peninjau read-only: bypass, regression, dan quality. Peninjau bypass menyetujui, dua lainnya meminta perubahan, dan tidak ada bug yang ditemukan di kedua perbaikan yang diperiksa. Salah satu perubahan yang diterapkan sesudahnya justru membatalkan sebagian isi draf: page yang diulang di dua listing berpaginasi kembali dibaca sebagai halaman 1, karena draf pertama memindahkan canonical-nya, dan itu perubahan SEO yang tidak diminta.

Kenapa cara membaca nomor halaman bisa memindahkan canonical? Karena di dua listing itu canonical memang bergantung pada nomor halaman. Pada 16 September, pihak SEO klien meminta pagination kedua listing dibuat self-canonical supaya Google bisa mencapai entri peringkat 4.950 lewat halaman 50. Rilis hari itu membuat halaman N self-canonical, sementara tampilan yang disaring cat atau chain ber-canonical ke listing polos. Dengan aturan seperti itu, mengubah cara membaca page sama saja dengan mengubah canonical, dan perbaikan untuk sebuah crash tidak punya urusan menggesernya.

Versi yang di-commit membuat tipenya jujur tanpa mengubah perilakunya:

// listing berpaginasi pertama, sesudah perbaikan
type Search = { page?: string | string[]; cat?: string | string[] };
 
// di dalam parsePage, di kedua listing
if (typeof raw !== "string") return 1;
 
// nomor halaman diteruskan apa adanya
const currentPage = parsePage(search.page);

Pesan commit-nya merumuskan keputusan itu dengan lugas: page yang diulang tidak pernah melempar galat dan tetap dibaca sebagai halaman 1, mengikuti aturan listing artikel, dan yang berubah hanya tipenya, yang sekarang jujur. Dengan tipe string | string[], TypeScript juga tidak akan lagi meloloskan cat?.trim() tanpa penyempitan tipe, karena trim tidak ada di string[].

Rilis dan batas verifikasinya

Ekspor bersih dari HEAD ditambah perubahan ini lulus 745 tes, tsc, eslint, dan build, dan saat dijalankan lokal, 21 URL tampil sesuai harapan. Pukul 15.00 WIB saya memutuskan commit dan rilis ke produksi, dengan commit yang terpisah dari butir 6. Perbaikan ini masuk develop sebagai commit sendiri, dan tree yang di-commit sama dengan tree yang diuji. Staging diperiksa dengan sebuah skrip pemeriksaan, lalu rilis ke main menyusul. Di produksi, 21 URL merender, dan ?page=2&page=3 menjadi halaman 1 dengan canonical polos. Catatan pukul 15.18 WIB menyatakan butir 5 dan 6 sudah dirilis dan diverifikasi, dan sehari kemudian, ketika staging menerima rilis perbaikan lain, skrip yang sama memberi hasil seperti sebelumnya.

Ada satu batas yang perlu saya tulis terus terang. Pengamatan yang menyebut keempat URL berkunci ganda secara eksplisit, yaitu merender dengan canonical yang benar dan tanpa error digest, tercatat di build produksi lokal dari draf pertama. Untuk produksi, catatannya hanya merangkum 21 URL merender tanpa merinci URL mana saja, sehingga satu-satunya URL berkunci ganda yang tercatat eksplisit di produksi adalah ?page=2&page=3.

Helper ini bukan allowlist

Helper itu sempit, dan memang sebaiknya begitu. Dia memastikan halaman menerima satu string dengan panjang yang wajar atau tidak menerima apa-apa, bukan memastikan string itu kategori yang benar-benar ada. Batasan isinya datang lewat rilis terpisah tiga hari kemudian: sejak 8 Oktober, ?cat= dan ?chain= hanya diterima dari daftar yang dikenal, dan tampilan yang disaring memakai halaman 1. Allowlist itu sudah tercantum di kelompok yang sama sejak serah terima 1 Oktober, bersebelahan dengan helper ini.

Kalau ada satu kebiasaan yang layak diambil dari butir ini, letaknya di garis antara URL dan kode. Nilai dari searchParams belum pantas disebut string sebelum ada yang memeriksanya, dan tempat paling murah untuk memeriksanya adalah satu helper di pintu masuk, bukan trim() yang tersebar di badan halaman. Tipe string | string[] yang jujur membuat compiler ikut berjaga. Error state yang dikirim dengan HTTP 200 tidak akan tertangkap pemeriksaan yang hanya melihat kode status. Di situs ini dia kelihatan sebagai halaman yang menyusut ke kira-kira sepertiga ukurannya atau kurang, dan sebagai digest di responsnya. Kalau sebuah perbaikan menyentuh parameter yang menentukan canonical, perlakukan itu sebagai perubahan SEO yang harus diminta, bukan efek samping yang boleh menumpang.