เปรียบเทียบและตรวจสอบต้นทางเว็บและเว็บไซต์

ฟีเจอร์บนเว็บ: Origin

แม้ว่าการเปรียบเทียบ event.origin กับสตริงลิเทอรัลแบบคงที่และพอร์ตที่ปรับให้เป็นมาตรฐาน (เช่น event.origin === 'https://app.example.com') จะใช้ได้กับรายการที่อนุญาตแบบง่าย แต่การเปรียบเทียบสตริงด้วยตนเองและนิพจน์ทั่วไปจะใช้งานไม่ได้เมื่อตรวจสอบ URL ที่กำหนดเอง, เอลิเมนต์ของลิงก์ DOM, การเปลี่ยนเส้นทางข้ามโดเมนย่อย หรือข้อความที่ทำแซนด์บ็อกซ์

  • การปลอมแปลงคำนำหน้าและสตริงย่อย: การตรวจสอบ url.startsWith('https://app.example.com') จะถูกข้ามโดย 'https://app.example.com.attacker.example'
  • URL ที่ไม่ได้ปรับให้เป็นมาตรฐาน: URL แบบเต็มที่มีเส้นทาง, สตริงการค้นหา หรือพอร์ตเริ่มต้นที่ชัดเจน ('https://app.example.com:443/path') จะไม่ตรงกับ 'https://app.example.com' ภายใต้ความเท่ากันของสตริงโดยไม่ต้องแยกวิเคราะห์และปรับให้เป็นมาตรฐานก่อน
  • ข้อผิดพลาดในรายการคําต่อท้ายสาธารณะ: การแยกชื่อโฮสต์ใน . เพื่อตรวจสอบความสัมพันธ์ของเว็บไซต์เดียวกันจะดำเนินการไม่สำเร็จในคําต่อท้ายสาธารณะแบบหลายป้ายกำกับ (เช่น .co.uk หรือโดเมนที่ฝากบริการอย่าง .github.io) และละเว้นรูปแบบที่ไม่ตรงกัน (http: เทียบกับ https:)
  • การทับซ้อนของต้นทาง "null" ที่ไม่เปิดเผย: iframe ที่ทำแซนด์บ็อกซ์ (<iframe sandbox="allow-scripts">) และ URL data: จะแปลง event.origin เป็นสตริง "null" ตามตัวอักษร การเปรียบเทียบต้นทางรันไทม์ที่เรียงลำดับ 2 รายการกับ originA === originB จะทำให้บริบททึบแสง 2 รายการที่ไม่เกี่ยวข้อง ("null" === "null") เท่ากันอย่างไม่ถูกต้อง

Origin API (Origin.from(), isSameOrigin(), isSameSite() และ opaque) มีการเปรียบเทียบต้นทางที่มีโครงสร้างและเป็นไปตามข้อกำหนด รวมถึงการเปรียบเทียบเว็บไซต์เดียวกันที่มีสคีมา ซึ่งสนับสนุนโดยโปรแกรมแยกวิเคราะห์ URL ของเบราว์เซอร์และ Public Suffix List

การเลือกการเปรียบเทียบที่เหมาะสมกับสถานการณ์ของคุณ

  • ใช้ isSameOrigin() สำหรับขอบเขตสิทธิ์ที่เข้มงวด: กำหนดให้ต้องมีการจับคู่ (scheme, host, port) ที่ตรงกันทุกประการเมื่อจัดการคำสั่ง postMessage ข้ามหน้าต่าง (เช่น ป๊อปอัปการชำระเงินหรือการตรวจสอบสิทธิ์ที่ฝังไว้) หรือตัดสินใจว่าจะแนบข้อมูลเข้าสู่ระบบหรือไม่ แม้แต่โดเมนย่อยที่ต่างกัน (https://blog.example.com เทียบกับ https://app.example.com) ก็ถือเป็นต้นทางที่ต่างกันและต้องถูกปฏิเสธ เพื่อไม่ให้ช่องโหว่ในโดเมนย่อยหนึ่งควบคุมโดเมนย่อยอื่นได้
  • ใช้ isSameSite() สำหรับการไปยังส่วนต่างๆ ข้ามโดเมนย่อยและการจัดประเภทลิงก์: อนุญาตโดเมนย่อยหรือพอร์ตที่ใช้รูปแบบและโดเมนที่จดทะเบียนเดียวกันเมื่อตรวจสอบ URL การเปลี่ยนเส้นทางหลังการเข้าสู่ระบบ (?returnTo=https://billing.example.co.uk/orders) หรือจัดประเภทลิงก์เป็นภายในเทียบกับภายนอก isSameSite() ปฏิเสธทั้งการดาวน์เกรดรูปแบบ http: และกลุ่มผู้ใช้ที่ใช้ทรัพยากรร่วมกันในโดเมนสาธารณะที่ใช้ร่วมกัน (.co.uk, .github.io)
  • ใช้ origin.opaque และ Origin.from(event) สำหรับบริบทแบบทึบแสงหรือแซนด์บ็อกซ์: ปฏิเสธ URL data: สำหรับการเปลี่ยนเส้นทาง (origin.opaque === true) หรือปักหมุด Origin แบบทึบแสงที่ดึงข้อมูลผ่าน Origin.from(event) เมื่อสื่อสารกับวิดเจ็ต <iframe sandbox="allow-scripts"> ที่เฉพาะเจาะจงซึ่งมี event.origin ที่เรียงลำดับเป็น "null"

1. ยืนยันต้นทางที่แน่นอนสำหรับการดำเนินการที่มีสิทธิ์ (Origin.from() และ isSameOrigin())

ใช้ Origin.from() เพื่อดึงออบเจ็กต์ Origin และเปรียบเทียบกับ Origin ที่เชื่อถือได้โดยใช้ isSameOrigin() ดังนี้

  • ทั้ง 2 ด้านต้องเป็นอินสแตนซ์ Origin: isSameOrigin(other) และ isSameSite(other) ยอมรับเฉพาะออบเจ็กต์ Origin เท่านั้น การส่งสตริงดิบจะแสดง TypeError
  • อินพุต Origin.from() ที่รองรับ:
    • สตริง URL ที่ทำให้เป็นอนุกรม (เช่น 'https://app.example.com:443/path') โปรดทราบว่าสตริง URL ที่ไม่ถูกต้อง เช่น 'example' หรือสตริง 'null' ที่ทำให้เป็นอนุกรมโดย event.origin สำหรับต้นทางที่ไม่เปิดเผย จะทำให้ Origin.from() แสดง TypeError
    • ออบเจ็กต์แพลตฟอร์มที่กำหนดขั้นตอนการดึงข้อมูลต้นทางในข้อกำหนด HTML: Origin, URL, HTMLAnchorElement (<a>), HTMLAreaElement (<area>), Window ต้นทางเดียวกันหรือ WorkerGlobalScope (Origin.from(self)) และอินสแตนซ์ MessageEvent ที่ส่งโดยเบราว์เซอร์ (Origin.from(event))
    • ออบเจ็กต์ เช่น Location, WorkerLocation, Document, Request และ Response จะไม่กำหนดขั้นตอนการดึงข้อมูลต้นทางและแสดง TypeError หากส่งไปยัง Origin.from() โดยตรง ให้ส่ง self (สำหรับบริบทปัจจุบัน) หรือ request.url / response.url แทน
  • จัดการทั้งอินสแตนซ์ MessageEvent ที่ส่งโดยเบราว์เซอร์และสังเคราะห์: เหตุการณ์ postMessage ของเบราว์เซอร์จริงจะมีต้นทางภายในที่ดึงข้อมูลโดย Origin.from(event) ส่วนเหตุการณ์ new MessageEvent('message', { origin }) สังเคราะห์ที่สร้างขึ้นใน Unit Test จะป้อนข้อมูลเฉพาะสตริง event.origin และแสดงข้อผิดพลาดใน Origin.from(event) การใช้ Origin.from(event.origin) ภายใน try...catch จะจัดการทั้ง 2 อย่างได้อย่างสมบูรณ์แบบ ขณะเดียวกันก็ยังปฏิเสธสตริงที่ไม่ถูกต้องหรือ "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
});

เมื่อตรวจสอบเป้าหมายการเปลี่ยนเส้นทาง (เช่น พารามิเตอร์ ?returnTo=) หรือลิงก์ภายในข้ามโดเมนย่อยของโดเมนที่จดทะเบียนเดียวกัน (เช่น https://app.example.co.uk และ https://billing.example.co.uk:8443) ให้ใช้ isSameSite() ดังนี้

  • isSameSite() ทำการเปรียบเทียบเว็บไซต์เดียวกันตามรูปแบบ: https://sub.example.com และ http://sub.example.com ไม่ใช่เว็บไซต์เดียวกันเนื่องจากมีรูปแบบต่างกัน
  • isSameSite() ใช้รายการคำต่อท้ายสาธารณะในตัวของเบราว์เซอร์ ดังนั้นผู้ใช้ที่ต่างกันในคำต่อท้ายสาธารณะที่ใช้ร่วมกัน (เช่น https://tenant-a.github.io และ https://tenant-b.github.io หรือ https://a.co.uk และ https://b.co.uk) จะประเมินเป็น 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. แยกแยะและปักหมุดต้นทางที่ไม่เปิดเผย (origin.opaque และ Origin.from(event))

iframe ที่ใช้แซนด์บ็อกซ์ (<iframe sandbox="allow-scripts">), URL data: และ new Origin() จะสร้างต้นทางที่ไม่เปิดเผย (origin.opaque === true) แม้ว่าการจัดรูปแบบสตริงจะเป็น "null" เสมอ แต่ Origin.from(event) จะดึงข้อมูลต้นทางที่ไม่เปิดเผยที่แท้จริงของผู้ส่งจาก MessageEvent ที่ส่งโดยเบราว์เซอร์

  • ข้อความ 2 ข้อความที่ส่งจาก iframe ที่ทำแซนด์บ็อกซ์เดียวกันจะให้ผลลัพธ์เป็นออบเจ็กต์ Origin ที่isSameOrigin()ซึ่งกันและกัน (true)
  • ข้อความที่ส่งจาก iframe ที่ทำแซนด์บ็อกซ์ต่างกัน หรือการเรียก Origin.from('data:...') / new Origin() แยกกัน จะให้ต้นทางทึบแสงที่แตกต่างกันซึ่งประเมิน isSameOrigin() เป็น false เพื่อป้องกันการปลอมแปลง "null" === "null" ในบริบททึบแสงที่ไม่เกี่ยวข้อง
  • new Origin() สร้างต้นทางแบบทึบแสงใหม่ที่ไม่ซ้ำกันซึ่งไม่ตรงกับสิ่งใดเลยยกเว้นตัวมันเอง ซึ่งมีประโยชน์ในฐานะ Sentinel ปฏิเสธทั้งหมดเริ่มต้นก่อนที่จะมีการปักหมุดต้นทางเพียร์ที่คาดไว้หนึ่งครั้งเมื่อเริ่มต้น
// 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;
  }
}

แนวทางปฏิบัติแนะนำ

  • ควรแปลงทั้ง 2 ด้านของการเปรียบเทียบเป็นอินสแตนซ์ Origin ผ่าน Origin.from() ก่อนเรียกใช้ isSameOrigin(other) หรือ isSameSite(other)
  • ควรส่ง MessageEvent ที่เบราว์เซอร์ส่งไปยัง Origin.from(event) โดยตรงเมื่อต้องการรักษาข้อมูลประจำตัวของต้นทางที่ทึบแสง และใช้ Origin.from(self) (แทน Location หรือ Document ซึ่งจะแสดง TypeError) เพื่อรับ Origin ของบริบทการดำเนินการปัจจุบัน
  • ควรรวมการเรียก Origin.from() ในอินพุตที่ไม่น่าเชื่อถือหรือรันไทม์ในบล็อก try...catch เพื่อให้ URL, สตริง "null" หรือออบเจ็กต์ที่ไม่รองรับซึ่งไม่ถูกต้องแสดงผล false อย่างปลอดภัยแทนที่จะแสดง TypeError ที่ไม่ถูกตรวจจับ
  • อย่าเปรียบเทียบสตริง event.origin หรือ url.origin ของรันไทม์ 2 รายการโดยตรงโดยไม่ปฏิเสธ "null" (origin !== 'null') ก่อน เนื่องจากต้นทางแบบทึบแสงที่ไม่เกี่ยวข้อง 2 รายการจะทำให้เกิดอนุกรมเป็น "null" ทั้งคู่
  • อย่าตรวจสอบต้นทางหรือเว็บไซต์ด้วย startsWith(), includes(), endsWith() หรือการแบ่งส่วนชื่อโฮสต์ .split('.') แบบง่ายๆ

กลยุทธ์สำรอง

หากเป้าหมาย Baseline ของคุณรวมเบราว์เซอร์ที่ยังไม่รองรับอินเทอร์เฟซ Origin ให้ตรวจหาฟีเจอร์ 'Origin' in globalThis และกลับไปแยกวิเคราะห์ URL ด้วย new URL() ดังนี้

  • ทางเลือกสำรองแบบต้นทางเดียวกัน (ต้นทางแบบ Tuple): ดึงสตริง URL (โดยใช้ event.origin สำหรับออบเจ็กต์ MessageEvent หรือ candidate.href สำหรับเอลิเมนต์ของลิงก์) แยกวิเคราะห์ทั้ง URL ที่เป็นไปได้และ URL ที่เชื่อถือได้ด้วย new URL() ปฏิเสธต้นทางที่ไม่เปิดเผย "null" (candidateUrl.origin !== 'null') และเปรียบเทียบ candidateUrl.origin === trustedUrl.origin เนื่องจากต้นทางแบบทึบแสงไม่เคยเป็นต้นทางเดียวกันกับต้นทางแบบทูเพิล เช่น 'https://app.example.com' การปฏิเสธ 'null' ทันทีจึงตรงกับ Origin.isSameOrigin()
  • ทางเลือกสำรองสำหรับเว็บไซต์เดียวกัน (ข้อควรระวัง): อินเทอร์เฟซ URL ไม่แสดงรายการคำต่อท้ายสาธารณะของเบราว์เซอร์ ในเบราว์เซอร์ที่ไม่มี Origin ให้ทำการจับคู่คำต่อท้ายกับโดเมนที่จดทะเบียนส่วนตัวที่รู้จักและชัดเจนซึ่งคุณควบคุมอยู่เท่านั้น (ยืนยันทั้ง protocol และการจับคู่ hostname ที่ตรงกันทุกประการหรือขึ้นต้นด้วยจุด) หรือใช้ไลบรารีรายการคำต่อท้ายสาธารณะที่ได้รับการดูแล
  • ทางเลือกสำรองสำหรับ iframe แบบทึบแสง/แซนด์บ็อกซ์: เบราว์เซอร์ที่ไม่มี Origin จะไม่แสดงข้อมูลระบุตัวตนของต้นทางแบบทึบแสงที่แตกต่างกันใน MessageEvent (event.origin จะเป็น "null" เสมอ) หากคุณตั้งใจยอมรับข้อความจาก <iframe sandbox="allow-scripts"> ที่อยู่ในแซนด์บ็อกซ์ที่รู้จัก ให้ยืนยัน event.source === expectedWidgetWindow (หรือสื่อสารผ่าน MessageChannel MessagePort เฉพาะที่โอนไปยัง iframe เมื่อเริ่มต้น) แทนการเปรียบเทียบ 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;
  }
}