השוואה ואימות של מקורות באינטרנט ואתרים

תכונות באינטרנט: מקור

השוואה בין 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 API (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 למקורות אטומים – גורמת ל-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 }) סינתטיים שנוצרו בבדיקות יחידה מאכלסים רק את המחרוזת event.origin ומפעילים את 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 (same) בארגז חול מניבות אובייקטים מסוג Origin שהם isSameOrigin() זה עם זה (true).
  • הודעות שנשלחות מ-iframes שונים בארגז חול (sandbox) – או קריאות נפרדות ל-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, כדי שכתובות URL לא חוקיות, מחרוזות "null" או אובייקטים לא נתמכים יחזירו false באופן בטוח במקום להפעיל TypeError שלא נתפס.
  • אל תשוו בין שתי מחרוזות 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;
  }
}