Siti melengkapi profilnya di toko Budi dengan satu kolom isian: “situs web”. Di halaman lain, tombol “selesai” membawa parameter ?lanjut=/katalog supaya pengguna kembali ke tempat yang tepat setelah login. Kedua nilai itu berupa teks yang kelihatannya URL — dan teks yang kelihatannya URL bisa saja berupa javascript:alert(...), protokol yang bukan mengarah ke halaman mana pun, melainkan menyuruh browser menjalankan program. Tautan seperti itu baru berbahaya ketika diklik, tetapi penyusupannya terjadi jauh lebih awal: saat string mentah kita tulis ke atribut href.

Jalur masuknya banyak dan biasa saja: kolom situs web di profil, parameter redirect setelah login, tautan “baca selengkapnya” dari feed, src pada iframe, hingga action pada form yang dibangun dinamis. Semua jalur itu berbagi satu kebutuhan: memutuskan apakah sebuah teks boleh diperlakukan sebagai URL, dan URL ke mana.

Kabar baiknya, JavaScript punya parser URL bawaan yang bekerja sama baik di browser dan di Node: new URL(). Parser itu memecah string menjadi komponen-komponennya, dan dari komponen itulah kita menyusun aturan penerimaan. Chapter ini menyusunnya langkah demi langkah: parsing, daftar izin protokol, pemeriksaan origin, dan cara membangun tautan baru dengan aman.

new URL: Parser, Bukan Penilai #

new URL() tidak menilai keamanan apa pun; tugasnya memecah string menjadi bagian-bagian yang bisa dibaca program:

js
const url = new URL("https://toko.example/katalog?cari=buku");
console.log(url.protocol, url.hostname, url.pathname);
console.log(url.searchParams.get("cari"));

Output yang Dihasilkan:

text
https: toko.example /katalog
buku

Tiga perilaku parser yang penting untuk validasi selanjutnya:

  • Protokol diseragamkan ke huruf kecil. String seperti JaVaScRiPt: dibaca sebagai javascript: — kita tidak perlu menebak-nebak variasi penulisan. Host pun mengalami hal yang sama.
  • String relatif butuh titik acuan. Bentuk kedua, new URL(kandidat, base), menghitung string relatif terhadap base; tanpa base, string relatif langsung ditolak.
  • String yang tidak sah melempar galat. new URL("hampir-url") menghasilkan TypeError: Invalid URL — karena itu pemanggilan selalu di dalam try...catch.

Parser memberi kita komponen; keputusan mana yang boleh lewat tetap aturan kita. Untuk membaca isi query, searchParams yang sudah kita pakai di section Browser API menangani dekode otomatis — nilai ?cari=buku%20mahal dibaca sebagai buku mahal tanpa pekerjaan tambahan.

Menyaring Protokol dengan Daftar Izin #

Berkas berikut menetapkan dua aturan untuk dua konteks: tautan profil (harus URL mutlak dengan protokol aman) dan parameter redirect (relatif boleh, asal berujung di situs sendiri):

// validasi-tautan.js — hanya protokol http/https yang boleh lewat
const PROTOKOL_DIIZINKAN = new Set(["http:", "https:"]);
const ORIGIN_SITUS = "https://toko.example";

// tautan profil: harus URL mutlak dengan protokol aman
function urlEksternal(kandidat) {
  let url;
  try {
    url = new URL(kandidat); // tanpa base: string relatif langsung ditolak
  } catch {
    return { ok: false, alasan: "bukan URL mutlak yang sah" };
  }
  if (!PROTOKOL_DIIZINKAN.has(url.protocol)) {
    return { ok: false, alasan: `protokol ${url.protocol} dilarang` };
  }
  return { ok: true, href: url.href };
}

// parameter redirect: relatif boleh, asal berujung di origin situs kita
function urlInternal(kandidat) {
  let url;
  try {
    url = new URL(kandidat, ORIGIN_SITUS + "/");
  } catch {
    return { ok: false, alasan: "bukan URL yang sah" };
  }
  if (!PROTOKOL_DIIZINKAN.has(url.protocol)) {
    return { ok: false, alasan: `protokol ${url.protocol} dilarang` };
  }
  if (url.origin !== ORIGIN_SITUS) {
    return { ok: false, alasan: `origin ${url.origin} di luar situs` };
  }
  return { ok: true, href: url.href };
}

// 1. tautan profil yang sah
console.log(urlEksternal("https://siti.example/blog"));

// 2. jebakan klasik: protokol javascript:
console.log(urlEksternal("javascript:alert(document.cookie)"));

// 3. variasi huruf besar: parser URL menyeragamkan protokol ke huruf kecil
console.log(urlEksternal("JaVaScRiPt:alert(document.cookie)"));

// 4. tanpa protokol: tidak sah sebagai tautan profil
console.log(urlEksternal("siti-example.blog"));

// 5. redirect internal yang sah (jalur relatif di situs sendiri)
console.log(urlInternal("/katalog?cari=buku"));

// 6. dua cara menyelundupkan origin lain: protokol-relative dan URL mutlak
console.log(urlInternal("//lain.example/gratis"));
console.log(urlInternal("https://lain.example/gratis"));

Output yang Dihasilkan:

text
{ ok: true, href: 'https://siti.example/blog' }
{ ok: false, alasan: 'protokol javascript: dilarang' }
{ ok: false, alasan: 'protokol javascript: dilarang' }
{ ok: false, alasan: 'bukan URL mutlak yang sah' }
{ ok: true, href: 'https://toko.example/katalog?cari=buku' }
{ ok: false, alasan: 'origin https://lain.example di luar situs' }
{ ok: false, alasan: 'origin https://lain.example di luar situs' }

Mari kita telaah baris-baris pentingnya:

  1. Baris 2–3 (daftar izin): Set berisi dua protokol yang kita pahami dan ingin dukung. Bentuknya daftar izin (allowlist), bukan daftar larangan — javascript: dan data: ditolak bukan karena tercantum, melainkan karena tidak tercantum. Daftar larangan selalu kalah cepat: cukup satu protokol yang terlewat untuk membuka celah.
  2. Baris 8–12 (parsing tanpa base): tautan profil dipanggil new URL(kandidat) tanpa acuan. String relatif seperti siti-example.blog melempar galat dan jatuh ke catch — kita tidak menerima “URL” yang sebenarnya teks biasa.
  3. Baris 13–15 (pemeriksaan protokol): url.protocol telah diseragamkan parser, sehingga uji keluaran ke-2 dan ke-3 menghasilkan pesan penolakan yang identik — penulisan JaVaScRiPt: tidak membawa ke mana pun.
  4. Baris 23 (parsing dengan base): parameter redirect dipanggil dengan acuan situs sendiri. Jalur relatif /katalog?cari=buku dihitung menjadi URL lengkap milik toko — keluaran ke-5 menerimanya dengan href yang sudah rapi.
  5. Baris 30–32 (pemeriksaan origin): origin — gabungan protokol, host, dan port (konsep yang sama dengan chapter CORS) — dibandingkan dengan konstanta situs. Dua cara menyelundupkan tujuan lain, bentuk protokol-relative //lain.example/... dan URL mutlak, sama-sama ditangkap: keduanya dihitung menjadi origin yang bukan milik toko.

Perbandingan origin mencakup port juga: https://toko.example dan https://toko.example:8443 adalah dua origin berbeda meski hostnya sama. Di lingkungan pengembangan, halaman di http://localhost:5173 dan API di http://localhost:3000 juga berbeda origin — pengalaman yang mungkin sudah kita rasakan saat mengatur CORS di section Network dan API. Konstanta ORIGIN_SITUS memakai origin lengkap persis karena itu.

Keluaran setiap penolakan berisi alasan yang bisa ditampilkan ke pengguna atau dicatat ke log — penolakan yang menjelaskan diri lebih mudah diuji daripada penolakan senyap.

Membangun Tautan, Bukan Menyambung String #

Validasi mengurus arah masuk; arah keluar pun punya jebakannya. Saat kita menyusun tautan berisi data pengguna — misalnya istilah pencarian — penyambungan string memasukkan teks mentah ke dalam query, dan teks itu bisa membawa &, =, atau # yang mengubah arti tautan. URLSearchParams, yang bisa diakses lewat url.searchParams, mengurus penyandiannya:

js URLSearchParams menyandikan nilai, penyambungan string tidak
const istilahCari = "buku & pernak-pernik";
const urlBaik = new URL("https://toko.example/cari");
urlBaik.searchParams.set("q", istilahCari);
urlBaik.searchParams.set("urut", "naik");
console.log(urlBaik.href);

const urlNaif = "https://toko.example/cari?q=" + istilahCari + "&urut=naik";
console.log(urlNaif);

Output yang Dihasilkan:

text
https://toko.example/cari?q=buku+%26+pernak-pernik&urut=naik
https://toko.example/cari?q=buku & pernak-pernik&urut=naik

Baris pertama adalah tautan yang benar: & di dalam istilah disandikan menjadi %26, sehingga parameter q tetap satu nilai dan urut=naik tetap parameter kedua. Baris kedua rusak begitu dibaca parser: q hanya berisi buku, pernak-pernik menjadi parameter tanpa nama, dan urut=naik tetap lolos. Bila string semacam ini dibangun dari isian yang tidak kita kendalikan, penyerang bisa menambahkan parameter apa pun — termasuk mengganti tujuan redirect — tanpa menyentuh protokol sama sekali. Aturannya sederhana: nilai query selalu lewat searchParams.set(); penyambungan string hanya untuk teks yang sepenuhnya milik kode kita.

Di Mana URL Menjadi Kode #

Jebakan javascript: baru bekerja saat URL dipakai: browser mengeksekusi isi tautan javascript: ketika tautan diklik, dan hal serupa terjadi pada penugasan ke location.href atau pemanggilan window.open dengan string semacam itu. Atribut lain ikut tunduk pada aturan yang sama — src pada iframe dan gambar, action pada form, bahkan nilai src pada tag yang dibangun lewat innerHTML.

Karena itu hasil validasi harus dipakai dengan disiplin: hanya href dari hasil yang ok === true yang ditulis ke atribut, dan bila penolakan terjadi, tampilkan pesannya atau perlakukan isian itu sebagai teks biasa. Titik pemeriksaannya satu dan tepat: tepat sebelum string menjadi nilai atribut atau argumen navigasi — bukan jauh di hulu, jauh dari tempat string itu dipakai.

Validasi seperti ini menjaga kode yang kita tulis sendiri — menyaring arah masuk, membangun arah keluar dengan searchParams. Kebiasaan daftar izin ini akan muncul lagi di chapter Content Security Policy Overview, kali ini ditegakkan browser. Bagian berikutnya mengurus kode yang kita pasang dari luar: dependensi.

Berkas Sumber

Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.

validasi-tautan.js

Unduh berkas
javascript
// validasi-tautan.js — hanya protokol http/https yang boleh lewat
const PROTOKOL_DIIZINKAN = new Set(["http:", "https:"]);
const ORIGIN_SITUS = "https://toko.example";

// tautan profil: harus URL mutlak dengan protokol aman
function urlEksternal(kandidat) {
  let url;
  try {
    url = new URL(kandidat); // tanpa base: string relatif langsung ditolak
  } catch {
    return { ok: false, alasan: "bukan URL mutlak yang sah" };
  }
  if (!PROTOKOL_DIIZINKAN.has(url.protocol)) {
    return { ok: false, alasan: `protokol ${url.protocol} dilarang` };
  }
  return { ok: true, href: url.href };
}

// parameter redirect: relatif boleh, asal berujung di origin situs kita
function urlInternal(kandidat) {
  let url;
  try {
    url = new URL(kandidat, ORIGIN_SITUS + "/");
  } catch {
    return { ok: false, alasan: "bukan URL yang sah" };
  }
  if (!PROTOKOL_DIIZINKAN.has(url.protocol)) {
    return { ok: false, alasan: `protokol ${url.protocol} dilarang` };
  }
  if (url.origin !== ORIGIN_SITUS) {
    return { ok: false, alasan: `origin ${url.origin} di luar situs` };
  }
  return { ok: true, href: url.href };
}

// 1. tautan profil yang sah
console.log(urlEksternal("https://siti.example/blog"));

// 2. jebakan klasik: protokol javascript:
console.log(urlEksternal("javascript:alert(document.cookie)"));

// 3. variasi huruf besar: parser URL menyeragamkan protokol ke huruf kecil
console.log(urlEksternal("JaVaScRiPt:alert(document.cookie)"));

// 4. tanpa protokol: tidak sah sebagai tautan profil
console.log(urlEksternal("siti-example.blog"));

// 5. redirect internal yang sah (jalur relatif di situs sendiri)
console.log(urlInternal("/katalog?cari=buku"));

// 6. dua cara menyelundupkan origin lain: protokol-relative dan URL mutlak
console.log(urlInternal("//lain.example/gratis"));
console.log(urlInternal("https://lain.example/gratis"));