مقایسه و اعتبارسنجی مبدأها و سایت‌های وب

ویژگی‌های وب: مبدأ

درحالی‌که مقایسه event.origin با یک رشته ثابت و نرمال‌شده ازنظر درگاه (مانند event.origin === 'https://app.example.com') برای فهرست‌های مجاز ساده کار می‌کند، مقایسه‌های دستی رشته‌ها و عبارت‌های باقاعده هنگام اعتبارسنجی نشانی‌های وب دلخواه، عناصر پیوند DOM، تغییرمسیرهای بین زیردامنه‌ها، یا پیام‌های محدودشده در محیط امن ازکار می‌افتند:

  • جعل پیشوند و رشته فرعی: بررسی url.startsWith('https://app.example.com') توسط 'https://app.example.com.attacker.example' دور زده می‌شود.
  • نشانی‌های وب غیرعادی: نشانی‌های وب کامل با مسیرها، رشته‌های پُرسمان، یا درگاه‌های پیش‌فرض صریح ('https://app.example.com:443/path') با 'https://app.example.com' تحت برابری رشته بدون تجزیه و عادی‌سازی اولیه مطابقت ندارند.
  • اشتباهات فهرست پسوند عمومی: تقسیم نام‌های میزبان در . برای بررسی روابط همان سایت در پسوندهای عمومی چندبرچسبی (مانند .co.uk یا دامنه‌های میزبانی مشترک مانند .github.io) ناموفق است و عدم تطابق طرح (http: در مقابل https:) را نادیده می‌گیرد.
  • تداخل‌های "null" مبدأ مات: iframeهای جعبه امن (<iframe sandbox="allow-scripts">) و نشانی‌های وب data:، event.origin را به رشته تحت‌اللفظی "null" تبدیل می‌کنند. مقایسه دو مبدأ زمان اجرای سریالی‌شده با originA === originB به‌اشتباه دو بافت مبهم نامرتبط ("null" === "null") را برابر می‌داند.

Origin API (Origin.from()، isSameOrigin()، isSameSite()، و opaque) مقایسه‌های ساختاریافته و مطابق با مشخصات مبدأ و سایت یکسان با طرح‌واره را ارائه می‌دهد که توسط تجزیه‌کننده نشانی وب مرورگر و «فهرست پسوند عمومی» پشتیبانی می‌شود.

انتخاب مقایسه مناسب برای سناریو شما

  • از isSameOrigin() برای مرزهای سخت‌گیرانه امتیاز استفاده کنید: هنگام رسیدگی به فرمان‌های postMessage بین‌پنجره‌ای (مانند تسویه‌حساب جاسازی‌شده یا بالاپر احراز هویت) یا تصمیم‌گیری درباره پیوست کردن اطلاعات کاربری، تطابق دقیق (scheme, host, port) را الزامی کنید. حتی یک زیردامنه متفاوت (https://blog.example.com در مقابل https://app.example.com) یک مبدأ متفاوت است و باید رد شود تا آسیب‌پذیری در یک زیردامنه نتواند دیگری را کنترل کند.
  • از isSameSite() برای پیمایش بین زیردامنه‌ها و طبقه‌بندی پیوندها استفاده کنید: هنگام اعتبارسنجی نشانی‌های وب بازهدایت پس‌از ورود به سیستم (?returnTo=https://billing.example.co.uk/orders) یا طبقه‌بندی پیوندها به‌عنوان داخلی درمقابل خارجی، به هر زیردامنه یا درگاهی که طرح و دامنه قابل‌ثبت یکسانی دارد اجازه دهید. ‫isSameSite() هم تنزل طرح http: و هم مستأجران هم‌نیا در پسوندهای عمومی مشترک (.co.uk،‏ .github.io) را رد می‌کند.
  • از origin.opaque و Origin.from(event) برای زمینه‌های مات یا جعبه امن استفاده کنید: نشانی‌های وب data: را برای تغییر مسیرها (origin.opaque === true) رد کنید، یا Origin مات استخراج‌شده ازطریق Origin.from(event) را هنگام برقراری ارتباط با ابزاره <iframe sandbox="allow-scripts"> خاصی که event.origin سریالی‌شده آن "null" است سنجاق کنید.

۱. درستی‌سنجی کردن مبدأ دقیق برای کنش‌های ممتاز (Origin.from() و isSameOrigin())

از Origin.from() برای استخراج شیء Origin و مقایسه آن با Origin معتمد بااستفاده از isSameOrigin() استفاده کنید:

  • هر دو طرف باید نمونه‌های Origin باشند: isSameOrigin(other) و isSameSite(other) فقط شیء Origin را می‌پذیرند—ارسال رشته خام باعث ایجاد TypeError می‌شود.
  • ورودی‌های Origin.from() پشتیبانی‌شده:
    • رشته‌های نشانی وب سریالی‌شده (برای مثال، 'https://app.example.com:443/path'). توجه داشته باشید که هر رشته نشانی وب نامعتبر (برای مثال '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 مراحل استخراج مبدأ را تعریف نمی‌کنند و اگر مستقیماً به Origin.from() ارسال شوند، TypeError را برمی‌گردانند—به‌جای آن، 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() مقایسه schemeful same-site را انجام می‌دهد: 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');

۳. Distinguish and Pin Opaque Origins (origin.opaque and Origin.from(event))

‫iframeهای سندباکس‌شده (<iframe sandbox="allow-scripts">)، نشانی‌های وب 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.from() به نمونه‌های Origin تبدیل کنید و سپس isSameOrigin(other) یا isSameSite(other) را فراخوانی کنید.
  • باید وقتی لازم است هویت مبدأ مبهم را حفظ کنید، MessageEvent ارسال‌شده از مرورگر را مستقیماً به Origin.from(event) ارسال کنید و برای دریافت Origin بافت اجرای فعلی، از Origin.from(self) (به‌جای Location یا Document که TypeError را برمی‌گردانند) استفاده کنید.
  • باید فراخوانی‌های Origin.from() در ورودی‌های غیرقابل‌اعتماد یا زمان اجرا را در بلوک try...catch قرار دهید تا نشانی‌های وب نامعتبر، رشته‌های "null"، یا اشیای پشتیبانی‌نشده به‌جای ایجاد TypeError غیرقابل‌کنترل، به‌طور ایمن false را برگردانند.
  • دو رشته زمان اجرای event.origin یا url.origin را مستقیماً بدون رد کردن "null" (origin !== 'null') مقایسه نکنید، زیرا دو مبدأ غیرشفاف نامرتبط هر دو به "null" سریال‌سازی می‌شوند.
  • مبدأها یا سایت‌ها را با startsWith()،‏ includes()،‏ endsWith()، یا برش نام میزبان .split('.') ساده اعتبارسنجی نکنید.

راهبرد جایگزین

اگر هدف Baseline شما شامل مرورگرهایی است که هنوز از میانای Origin پشتیبانی نمی‌کنند، 'Origin' in globalThis را تشخیص ویژگی کنید و به تجزیه نشانی‌های وب با new URL() برگردید:

  • جایگزین هم‌مبدأ (مبدأهای چندتایی): رشته نشانی وب را استخراج کنید (بااستفاده از event.origin برای اشیای MessageEvent یا candidate.href برای عناصر پیوند)، نشانی‌های وب نامزد و مطمئن را با 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.origin، event.source === expectedWidgetWindow را تأیید کنید (یا ازطریق MessageChannel MessagePort اختصاصی که در مقداردهی اولیه به iframe منتقل شده است ارتباط برقرار کنید).
// 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;
  }
}