Membandingkan dan memvalidasi origin dan situs web

Fitur web: Origin

Meskipun membandingkan event.origin dengan literal string tetap yang dinormalisasi port (seperti event.origin === 'https://app.example.com') berfungsi untuk daftar yang diizinkan sederhana, perbandingan string manual dan ekspresi reguler akan gagal saat memvalidasi URL arbitrer, elemen link DOM, pengalihan lintas subdomain, atau pesan dalam sandbox:

  • Spoofing awalan dan substring: Pemeriksaan url.startsWith('https://app.example.com') diabaikan oleh 'https://app.example.com.attacker.example'.
  • URL yang tidak dinormalisasi: URL lengkap dengan jalur, string kueri, atau port default eksplisit ('https://app.example.com:443/path') tidak cocok dengan 'https://app.example.com' dalam kesetaraan string tanpa penguraian dan normalisasi terlebih dahulu.
  • Kesalahan Daftar Akhiran Publik: Memisahkan nama host di . untuk memeriksa hubungan situs yang sama akan gagal pada akhiran publik multi-label (seperti .co.uk atau domain hosting bersama seperti .github.io) dan mengabaikan ketidakcocokan skema (http: vs. https:).
  • Tabrakan origin "null" buram: Iframe dengan sandbox (<iframe sandbox="allow-scripts">) dan URL data: melakukan serialisasi event.origin ke string literal "null". Membandingkan dua origin runtime yang diserialisasi dengan originA === originB akan menyamakan dua konteks buram yang tidak terkait ("null" === "null") secara keliru.

Origin API (Origin.from(), isSameOrigin(), isSameSite(), dan opaque) menyediakan perbandingan origin dan situs yang sama dengan skema yang terstruktur dan sesuai spesifikasi, yang didukung oleh parser URL browser dan Daftar Sufiks Publik.

Memilih perbandingan yang tepat untuk skenario Anda

  • Gunakan isSameOrigin() untuk batas hak istimewa yang ketat: Wajibkan pencocokan (scheme, host, port) yang sama persis saat menangani perintah postMessage lintas jendela (seperti checkout tersemat atau pop-up autentikasi) atau memutuskan apakah akan melampirkan kredensial. Bahkan subdomain yang berbeda (https://blog.example.com vs. https://app.example.com) adalah origin yang berbeda dan harus ditolak agar kerentanan pada satu subdomain tidak dapat mengontrol subdomain lainnya.
  • Gunakan isSameSite() untuk navigasi lintas subdomain dan klasifikasi link: Izinkan subdomain atau port apa pun yang menggunakan skema dan domain yang dapat didaftarkan yang sama saat memvalidasi URL pengalihan pasca-login (?returnTo=https://billing.example.co.uk/orders) atau mengklasifikasikan link sebagai internal vs. eksternal. isSameSite() menolak downgrade skema http: dan tenant seinduk pada akhiran publik bersama (.co.uk, .github.io).
  • Gunakan origin.opaque dan Origin.from(event) untuk konteks buram atau dengan sandbox: Tolak URL data: untuk pengalihan (origin.opaque === true), atau sematkan Origin buram yang diekstrak melalui Origin.from(event) saat berkomunikasi dengan widget <iframe sandbox="allow-scripts"> tertentu yang event.origin-nya diserialisasi adalah "null".

1. Memverifikasi Origin yang Tepat untuk Tindakan Istimewa (Origin.from() dan isSameOrigin())

Gunakan Origin.from() untuk mengekstrak objek Origin dan membandingkannya dengan Origin tepercaya menggunakan isSameOrigin():

  • Kedua sisi harus berupa instance Origin: isSameOrigin(other) dan isSameSite(other) hanya menerima objek Origin—meneruskan string mentah akan menampilkan TypeError.
  • Input Origin.from() yang didukung:
    • String URL yang diserialisasi (misalnya, 'https://app.example.com:443/path'). Perhatikan bahwa string URL yang tidak valid—misalnya 'example', atau string 'null' yang diserialisasi oleh event.origin untuk origin opaque—menyebabkan Origin.from() menampilkan TypeError.
    • Objek platform yang menentukan langkah-langkah ekstraksi origin dalam spesifikasi HTML: Origin, URL, HTMLAnchorElement (<a>), HTMLAreaElement (<area>), Window origin yang sama atau WorkerGlobalScope (Origin.from(self)), dan instance MessageEvent yang dikirim browser (Origin.from(event)).
    • Objek seperti Location, WorkerLocation, Document, Request, dan Response tidak menentukan langkah-langkah ekstraksi origin dan menampilkan TypeError jika diteruskan langsung ke Origin.from()—teruskan self (untuk konteks saat ini) atau request.url / response.url.
  • Menangani instance MessageEvent yang dikirim browser dan sintetis: Peristiwa postMessage browser sungguhan membawa origin internal yang diekstrak oleh Origin.from(event), sedangkan peristiwa new MessageEvent('message', { origin }) sintetis yang dibuat dalam pengujian unit hanya mengisi string event.origin dan menampilkan error di Origin.from(event). Penggunaan penggantian ke Origin.from(event.origin) di dalam try...catch akan menangani keduanya dengan baik, sekaligus tetap menolak string yang tidak valid atau "null".
// Replace with your application's trusted origin(s)
const TRUSTED_ORIGIN = Origin.from('https://app.example.com');

function extractOrigin(candidate) {
  try {
    return Origin.from(candidate);
  } catch (err) {
    // Synthetic new MessageEvent('message', { origin }) objects in tests lack an
    // internal browser origin; fall back to parsing candidate.origin if it is a URL.
    if (typeof MessageEvent !== 'undefined' && candidate instanceof MessageEvent) {
      return Origin.from(candidate.origin);
    }
    throw err;
  }
}

export function isTrustedSameOrigin(candidate) {
  try {
    return extractOrigin(candidate).isSameOrigin(TRUSTED_ORIGIN);
  } catch {
    // Rejects invalid URLs, "null" strings, or unsupported objects
    return false;
  }
}

window.addEventListener('message', (event) => {
  if (!isTrustedSameOrigin(event)) return;

  // Safe to process privileged commands from the verified origin
});

Saat memvalidasi target pengalihan (seperti parameter ?returnTo=) atau link internal di seluruh subdomain dari domain yang dapat didaftarkan yang sama (misalnya, https://app.example.co.uk dan https://billing.example.co.uk:8443), gunakan isSameSite():

  • isSameSite() melakukan perbandingan schemeful same-site: https://sub.example.com dan http://sub.example.com bukan situs yang sama karena skemanya berbeda.
  • isSameSite() menggunakan Daftar Akhiran Publik bawaan browser, sehingga tenant yang berbeda pada akhiran publik bersama (seperti https://tenant-a.github.io dan https://tenant-b.github.io, atau https://a.co.uk dan https://b.co.uk) dievaluasi ke false.
// Replace with your application's primary origin
const APP_ORIGIN = Origin.from('https://app.example.co.uk');

export function isAllowedSameSiteUrl(candidateUrlOrElement) {
  try {
    return extractOrigin(candidateUrlOrElement).isSameSite(APP_ORIGIN);
  } catch {
    return false;
  }
}

// true: same scheme (https) and same registrable domain (example.co.uk)
isAllowedSameSiteUrl('https://billing.example.co.uk:8443/checkout');

// false: scheme downgrade (http vs. https) is rejected by schemeful same-site
isAllowedSameSiteUrl('http://billing.example.co.uk/checkout');

3. Membedakan dan Menyematkan Origin Opaque (origin.opaque dan Origin.from(event))

Iframe dengan sandbox (<iframe sandbox="allow-scripts">), URL data:, dan new Origin() menghasilkan origin opaque (origin.opaque === true). Meskipun serialisasi string-nya selalu "null", Origin.from(event) mengekstrak origin opaque dasar pengirim yang sebenarnya dari MessageEvent yang dikirim browser:

  • Dua pesan yang dikirim dari iframe dengan sandbox yang sama menghasilkan objek Origin yang isSameOrigin() satu sama lain (true).
  • Pesan yang dikirim dari iframe dengan sandbox yang berbeda—atau panggilan Origin.from('data:...')/new Origin() terpisah—menghasilkan origin buram berbeda yang mengevaluasi isSameOrigin() ke false, sehingga mencegah spoofing "null" === "null" di seluruh konteks buram yang tidak terkait.
  • new Origin() membuat origin buram baru yang unik dan tidak cocok dengan apa pun kecuali dirinya sendiri, yang berguna sebagai sentinel tolak semua default sebelum origin peer yang diharapkan disematkan sekali saat inisialisasi.
// Initialize with a unique opaque sentinel that matches no other origin
let pinnedSandboxOrigin = new Origin();
let isSandboxPinned = false;

export function handleSandboxedWidgetMessage(event, expectedWidgetWindow) {
  try {
    const senderOrigin = Origin.from(event);

    // Pin the opaque origin only once on initialization from the expected sandboxed iframe
    if (!isSandboxPinned && event.source === expectedWidgetWindow && event.data?.type === 'init') {
      pinnedSandboxOrigin = senderOrigin;
      isSandboxPinned = true;
    }

    // Subsequent messages from the SAME sandboxed iframe match pinnedSandboxOrigin;
    // messages from any other sandboxed iframe (also event.origin === "null") return false.
    return senderOrigin.isSameOrigin(pinnedSandboxOrigin);
  } catch {
    return false;
  }
}

Praktik terbaik

  • LAKUKAN konversi kedua sisi perbandingan ke instance Origin melalui Origin.from() sebelum memanggil isSameOrigin(other) atau isSameSite(other).
  • LAKUKAN penerusan MessageEvent yang dikirim browser langsung ke Origin.from(event) saat Anda perlu mempertahankan identitas origin buram, dan gunakan Origin.from(self) (bukan Location atau Document, yang menampilkan TypeError) untuk mendapatkan Origin konteks eksekusi saat ini.
  • LAKUKAN penggabungan panggilan Origin.from() pada input yang tidak tepercaya atau runtime dalam blok try...catch sehingga URL yang tidak valid, string "null", atau objek yang tidak didukung akan menampilkan false dengan aman, bukan menampilkan TypeError yang tidak tertangkap.
  • JANGAN membandingkan dua string event.origin atau url.origin runtime secara langsung tanpa terlebih dahulu menolak "null" (origin !== 'null'), karena dua origin buram yang tidak terkait akan diserialisasi ke "null".
  • JANGAN memvalidasi origin atau situs dengan startsWith(), includes(), endsWith(), atau pemotongan nama host .split('.') yang naif.

Strategi penggantian

Jika target Baseline Anda mencakup browser yang belum mendukung antarmuka Origin, deteksi fitur 'Origin' in globalThis dan gunakan pengganti untuk mengurai URL dengan new URL():

  • Penggantian origin yang sama (origin tuple): Ekstrak string URL (menggunakan event.origin untuk objek MessageEvent atau candidate.href untuk elemen link), uraikan URL kandidat dan tepercaya dengan new URL(), tolak origin buram "null" (candidateUrl.origin !== 'null'), dan bandingkan candidateUrl.origin === trustedUrl.origin. Karena origin buram tidak pernah memiliki origin yang sama dengan origin tuple seperti 'https://app.example.com', penolakan langsung terhadap 'null' akan cocok dengan Origin.isSameOrigin().
  • Penggantian situs yang sama (peringatan): Antarmuka URL tidak mengekspos Daftar Suffix Publik browser. Di browser tanpa Origin, hanya lakukan pencocokan akhiran terhadap domain pribadi eksplisit dan dikenal yang dapat didaftarkan yang Anda kontrol (memverifikasi protocol dan kecocokan hostname yang sama persis atau diawali titik), atau gunakan library Daftar Akhiran Publik yang dikelola.
  • Penggantian iframe buram/di-sandbox: Browser tanpa Origin tidak mengekspos identitas origin buram yang berbeda di MessageEvent (event.origin selalu "null"). Jika Anda sengaja menerima pesan dari <iframe sandbox="allow-scripts"> yang di-sandbox dan diketahui, verifikasi event.source === expectedWidgetWindow (atau berkomunikasi melalui MessageChannel MessagePort khusus yang ditransfer ke iframe saat inisialisasi), bukan membandingkan event.origin.
// Replace with your trusted reference URL and known registrable domain
const TRUSTED_URL = 'https://app.example.com';
const TRUSTED_SITE_DOMAIN = 'example.com';

function extractUrlString(candidate) {
  if (typeof candidate === 'string') return candidate;
  if (typeof MessageEvent !== 'undefined' && candidate instanceof MessageEvent) {
    return candidate.origin;
  }
  if (candidate === globalThis) {
    return globalThis.location?.href ?? '';
  }
  return candidate?.href ?? '';
}

function extractOrigin(candidate) {
  try {
    return Origin.from(candidate);
  } catch (err) {
    if (typeof MessageEvent !== 'undefined' && candidate instanceof MessageEvent) {
      return Origin.from(candidate.origin);
    }
    throw err;
  }
}

export function checkSameOriginWithFallback(candidate) {
  try {
    if ('Origin' in globalThis) {
      return extractOrigin(candidate).isSameOrigin(Origin.from(TRUSTED_URL));
    }
    const candidateUrl = new URL(extractUrlString(candidate));
    const trustedUrl = new URL(TRUSTED_URL);
    return candidateUrl.origin !== 'null' && candidateUrl.origin === trustedUrl.origin;
  } catch {
    return false;
  }
}

export function checkSameSiteWithFallback(candidate) {
  try {
    if ('Origin' in globalThis) {
      return extractOrigin(candidate).isSameSite(Origin.from(TRUSTED_URL));
    }
    const candidateUrl = new URL(extractUrlString(candidate));
    const trustedUrl = new URL(TRUSTED_URL);
    return (
      candidateUrl.origin !== 'null' &&
      candidateUrl.protocol === trustedUrl.protocol &&
      (candidateUrl.hostname === TRUSTED_SITE_DOMAIN ||
        candidateUrl.hostname.endsWith(`.${TRUSTED_SITE_DOMAIN}`))
    );
  } catch {
    return false;
  }
}