مقارنة عناوين الويب المصدر والمواقع الإلكترونية والتحقّق منها

ميزات الويب: المصدر

على الرغم من أنّ مقارنة 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". تؤدي مقارنة مصدرَي وقت تشغيل متسلسلَين باستخدام originA === originB إلى مساواة سياقَين مبهمَين غير مرتبطَين ("null" === "null") بشكل خاطئ.

توفّر واجهة برمجة التطبيقات Origin (Origin.from() وisSameOrigin() وisSameSite() وopaque) مقارنات منظَّمة ومتوافقة مع المواصفات للمواقع الإلكترونية والمواقع الإلكترونية نفسها التي تستخدم المخطط نفسه، وتكون مدعومة بالمحلّل اللغوي لعناوين URL في المتصفّح وقائمة اللاحقات العامة.

اختيار المقارنة المناسبة للسيناريو الخاص بك

  • استخدام 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():

  • يجب أن يكون كلا الجانبين مثيلَين لـ Origin: لا يقبل isSameOrigin(other) وisSameSite(other) سوى كائن Origin، ويؤدي تمرير سلسلة أولية إلى ظهور TypeError.
  • إدخالات Origin.from() المتوافقة:
    • سلاسل عناوين URL المتسلسلة (على سبيل المثال، 'https://app.example.com:443/path'). يُرجى العِلم أنّ أي سلسلة عنوان URL غير صالحة، مثل 'example' أو سلسلة 'null' المتسلسلة من خلال event.origin للمصادر المبهمة، تؤدي إلى ظهور TypeError في Origin.from().
    • كائنات النظام الأساسي التي تحدّد خطوات استخراج المصدر في مواصفات 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 بدلاً من ذلك.
  • Handle both browser-dispatched and synthetic MessageEvent instances: Real browser postMessage events carry an internal origin extracted by Origin.from(event), whereas synthetic new MessageEvent('message', { origin }) events created in unit tests only populate the event.origin string and throw on Origin.from(event). يؤدي الرجوع إلى Origin.from(event.origin) داخل try...catch إلى معالجة كليهما بشكل سليم مع رفض السلاسل غير الصالحة أو "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 الذي يرسله المتصفّح:

  • تؤدي رسالتان تم إرسالهما من نفس إطار iframe المعزول إلى إنشاء كائنَين من النوع Origin isSameOrigin() مع بعضهما (true).
  • الرسائل المُرسَلة من إطارات iframe مختلفة في وضع الحماية، أو طلبات Origin.from('data:...') أو new Origin() منفصلة، تؤدي إلى إنشاء مصادر مبهمة ومختلفة يتم تقييمها على أنّها isSameOrigin() إلى false، ما يمنع انتحال هوية "null" === "null" في سياقات مبهمة غير مرتبطة.
  • تنشئ الدالة new Origin() مصدرًا معتمًا جديدًا وفريدًا لا يتطابق مع أي شيء باستثناء نفسه، وهو مفيد كحارس افتراضي يرفض كل شيء قبل تثبيت مصدر نظير متوقع مرة واحدة عند الإعداد.
// 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;
  }
}

أفضل الممارسات

  • يجب تحويل كلا طرفَي المقارنة إلى مثيلات Origin من خلال Origin.from() قبل استدعاء isSameOrigin(other) أو isSameSite(other).
  • يجب تمرير MessageEvent الذي يرسله المتصفّح مباشرةً إلى Origin.from(event) عندما تحتاج إلى الحفاظ على هوية المصدر غير الشفاف، واستخدام Origin.from(self) (بدلاً من Location أو Document، اللذين يعرضان TypeError) للحصول على Origin لسياق التنفيذ الحالي.
  • يجب تضمين طلبات Origin.from() على الإدخالات غير الموثوق بها أو إدخالات وقت التشغيل في مجموعة try...catch لكي يتم عرض false بأمان بدلاً من عرض TypeError غير معالَج، وذلك في حال استخدام عناوين URL غير صالحة أو سلاسل "null" أو عناصر غير متوافقة.
  • لا تقارن بين سلسلتَي event.origin أو url.origin في وقت التشغيل مباشرةً بدون رفض "null" (origin !== 'null') أولاً، لأنّ مصدرَين غير مرتبطَين وغير شفافَين يتم تسلسلهما إلى "null".
  • لا تتحقّق من صحة المصادر أو المواقع الإلكترونية باستخدام startsWith() أو includes() أو endsWith() أو تقسيم اسم المضيف .split('.') البسيط.

استراتيجية احتياطية

إذا كان هدف Baseline يتضمّن متصفّحات لا تتيح استخدام واجهة Origin بعد، يمكنك رصد ميزة 'Origin' in globalThis والعودة إلى تحليل عناوين URL باستخدام new URL():

  • العودة إلى المصدر نفسه (مصادر الصفوف): استخرِج سلسلة عنوان 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;
  }
}