แม้ว่าการเปรียบเทียบ 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">) และ URLdata:จะแปลง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)สำหรับบริบทแบบทึบแสงหรือแซนด์บ็อกซ์: ปฏิเสธ URLdata:สำหรับการเปลี่ยนเส้นทาง (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แทน
- สตริง 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
});
2. ตรวจสอบการเปลี่ยนเส้นทางและลิงก์ข้ามโดเมนย่อย (isSameSite())
เมื่อตรวจสอบเป้าหมายการเปลี่ยนเส้นทาง (เช่น พารามิเตอร์ ?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(หรือสื่อสารผ่านMessageChannelMessagePortเฉพาะที่โอนไปยัง 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;
}
}