So sánh và xác thực nguồn gốc trang web và trang web

Tính năng web: Origin

Mặc dù việc so sánh event.origin với một giá trị cố định dạng chuỗi cố định, được chuẩn hoá theo cổng (chẳng hạn như event.origin === 'https://app.example.com') hoạt động cho các danh sách cho phép đơn giản, nhưng các phép so sánh chuỗi thủ công và biểu thức chính quy sẽ không hoạt động khi xác thực URL tuỳ ý, phần tử liên kết DOM, lệnh chuyển hướng giữa các miền con hoặc thông báo trong hộp cát:

  • Giả mạo tiền tố và chuỗi con: Việc kiểm tra url.startsWith('https://app.example.com') bị 'https://app.example.com.attacker.example' bỏ qua.
  • URL chưa chuẩn hoá: URL đầy đủ có đường dẫn, chuỗi truy vấn hoặc cổng mặc định rõ ràng ('https://app.example.com:443/path') không khớp với 'https://app.example.com' theo tính bằng nhau của chuỗi nếu không phân tích cú pháp và chuẩn hoá trước.
  • Lỗi Danh sách hậu tố công khai: Việc phân tách tên máy chủ trên . để kiểm tra mối quan hệ cùng trang web sẽ không thành công đối với các hậu tố công khai có nhiều nhãn (chẳng hạn như .co.uk hoặc tên miền được lưu trữ dùng chung như .github.io) và bỏ qua các trường hợp không khớp về giao thức (http: so với https:).
  • Xung đột nguồn gốc "null" mờ: Các iframe hộp cát (<iframe sandbox="allow-scripts">) và URL data: chuyển đổi tuần tự event.origin thành chuỗi ký tự "null". Việc so sánh hai nguồn gốc thời gian chạy được chuyển đổi tuần tự bằng originA === originB sẽ đánh đồng hai ngữ cảnh mờ không liên quan ("null" === "null").

Origin API (Origin.from(), isSameOrigin(), isSameSite() và opaque) cung cấp các phép so sánh có cấu trúc, tuân thủ quy cách về nguồn gốc và cùng trang web theo lược đồ, được hỗ trợ bởi trình phân tích cú pháp URL của trình duyệt và Danh sách hậu tố công khai.

Chọn phép so sánh phù hợp với tình huống của bạn

  • Sử dụng isSameOrigin() cho ranh giới đặc quyền nghiêm ngặt: Yêu cầu khớp chính xác (scheme, host, port) khi xử lý các lệnh postMessage trên nhiều cửa sổ (chẳng hạn như cửa sổ bật lên thanh toán hoặc xác thực được nhúng) hoặc quyết định xem có đính kèm thông tin xác thực hay không. Ngay cả một miền con khác (https://blog.example.com so với https://app.example.com) cũng là một nguồn gốc khác và phải bị từ chối để lỗ hổng bảo mật trên một miền con không thể kiểm soát miền con khác.
  • Sử dụng isSameSite() để điều hướng giữa các miền con và phân loại đường liên kết: Cho phép mọi miền con hoặc cổng có cùng giao thức và miền có thể đăng ký khi xác thực URL chuyển hướng sau khi đăng nhập (?returnTo=https://billing.example.co.uk/orders) hoặc phân loại đường liên kết là nội bộ hay bên ngoài. isSameSite() từ chối cả việc hạ cấp lược đồ http: và đối tượng thuê đồng cấp trên các hậu tố công khai dùng chung (.co.uk, .github.io).
  • Sử dụng origin.opaque và Origin.from(event) cho bối cảnh mờ hoặc hộp cát: Từ chối URL data: để chuyển hướng (origin.opaque === true) hoặc ghim Origin mờ được trích xuất qua Origin.from(event) khi giao tiếp với một tiện ích <iframe sandbox="allow-scripts"> cụ thể có event.origin được chuyển đổi tuần tự là "null".

1. Xác minh nguồn gốc chính xác cho các hành động đặc quyền (Origin.from() và isSameOrigin())

Sử dụng Origin.from() để trích xuất đối tượng Origin và so sánh đối tượng đó với Origin đáng tin cậy bằng isSameOrigin():

  • Cả hai bên phải là thực thể Origin: isSameOrigin(other) và isSameSite(other) chỉ chấp nhận đối tượng Origin – việc truyền chuỗi thô sẽ gửi TypeError.
  • Dữ liệu đầu vào Origin.from() được hỗ trợ:
    • Chuỗi URL được chuyển đổi tuần tự (ví dụ: 'https://app.example.com:443/path'). Xin lưu ý rằng mọi chuỗi URL không hợp lệ (ví dụ: 'example' hoặc chuỗi 'null' được event.origin chuyển đổi tuần tự cho các nguồn gốc không rõ ràng) đều khiến Origin.from() gửi một TypeError.
    • Các đối tượng nền tảng xác định các bước trích xuất nguồn gốc trong thông số kỹ thuật HTML: Origin, URL, HTMLAnchorElement (<a>), HTMLAreaElement (<area>), Window hoặc WorkerGlobalScope (Origin.from(self)) cùng nguồn gốc và các thực thể MessageEvent do trình duyệt gửi (Origin.from(event)).
    • Các đối tượng như Location, WorkerLocation, Document, Request và Response không xác định các bước trích xuất nguồn gốc và gửi TypeError nếu được truyền trực tiếp đến Origin.from() — thay vào đó, hãy truyền self (cho ngữ cảnh hiện tại) hoặc request.url/response.url.
  • Xử lý cả thực thể do trình duyệt gửi và thực thể tổng hợp MessageEvent: Các sự kiện postMessage thực tế của trình duyệt mang nguồn gốc nội bộ do Origin.from(event) trích xuất, trong khi các sự kiện new MessageEvent('message', { origin }) tổng hợp được tạo trong kiểm thử đơn vị chỉ điền sẵn chuỗi event.origin và gửi trên Origin.from(event). Việc quay lại Origin.from(event.origin) bên trong try...catch sẽ xử lý cả hai một cách rõ ràng trong khi vẫn từ chối các chuỗi không hợp lệ hoặc "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
});

Khi xác thực mục tiêu chuyển hướng (chẳng hạn như tham số ?returnTo=) hoặc đường liên kết nội bộ trên các miền con của cùng một miền có thể đăng ký (ví dụ: https://app.example.co.uk và https://billing.example.co.uk:8443), hãy sử dụng isSameSite():

  • isSameSite() thực hiện so sánh schemeful same-site: https://sub.example.com và http://sub.example.com không cùng trang web vì lược đồ của chúng khác nhau.
  • isSameSite() sử dụng Danh sách hậu tố công khai tích hợp sẵn của trình duyệt, vì vậy, các đối tượng thuê riêng biệt trên các hậu tố công khai dùng chung (chẳng hạn như https://tenant-a.github.io và https://tenant-b.github.io, hoặc https://a.co.uk và https://b.co.uk) sẽ được đánh giá là 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. Phân biệt và ghim nguồn gốc không rõ ràng (origin.opaque và Origin.from(event))

Iframe hộp cát (<iframe sandbox="allow-scripts">), URL data: và new Origin() tạo ra nguồn gốc mờ (origin.opaque === true). Mặc dù quá trình chuyển đổi tuần tự chuỗi của các nguồn gốc này luôn là "null", nhưng Origin.from(event) sẽ trích xuất nguồn gốc mờ thực tế của người gửi từ một MessageEvent do trình duyệt gửi:

  • Hai thông báo được gửi từ cùng một iframe hộp cát sẽ tạo ra các đối tượng Origin isSameOrigin() với nhau (true).
  • Các thông báo được gửi từ nhiều iframe hộp cát (hoặc các lệnh gọi Origin.from('data:...')/new Origin() riêng biệt) sẽ tạo ra các nguồn gốc mờ riêng biệt, đánh giá isSameOrigin() thành false, ngăn chặn hành vi giả mạo "null" === "null" trên các bối cảnh mờ không liên quan.
  • new Origin() tạo một nguồn gốc mờ đục mới, duy nhất, không khớp với bất kỳ nguồn gốc nào ngoại trừ chính nó. Đây là một lính gác từ chối tất cả mặc định hữu ích trước khi nguồn gốc ngang hàng dự kiến được ghim một lần khi khởi chạy.
// 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;
  }
}

Các phương pháp hay nhất

  • NÊN chuyển đổi cả hai bên của phép so sánh thành các thực thể Origin thông qua Origin.from() trước khi gọi isSameOrigin(other) hoặc isSameSite(other).
  • NÊN truyền trực tiếp MessageEvent do trình duyệt gửi đến Origin.from(event) khi bạn cần giữ nguyên danh tính nguồn gốc mờ và sử dụng Origin.from(self) (thay vì Location hoặc Document, vốn gửi TypeError) để lấy Origin của ngữ cảnh thực thi hiện tại.
  • NÊN gói các lệnh gọi Origin.from() trên dữ liệu đầu vào không đáng tin cậy hoặc dữ liệu đầu vào trong thời gian chạy trong một khối try...catch để các URL không hợp lệ, chuỗi "null" hoặc đối tượng không được hỗ trợ trả về false một cách an toàn thay vì gửi một TypeError không phát hiện được.
  • KHÔNG so sánh trực tiếp hai chuỗi event.origin hoặc url.origin trong thời gian chạy mà không từ chối "null" (origin !== 'null') trước, vì hai nguồn gốc mờ không liên quan đều chuyển đổi tuần tự thành "null".
  • KHÔNG xác thực nguồn gốc hoặc trang web bằng startsWith(), includes(), endsWith() hoặc cắt tên máy chủ .split('.') đơn giản.

Chiến lược dự phòng

Nếu mục tiêu Đường cơ sở của bạn bao gồm các trình duyệt chưa hỗ trợ giao diện Origin, hãy phát hiện tính năng 'Origin' in globalThis và quay lại phân tích cú pháp URL bằng new URL():

  • Dự phòng cùng nguồn gốc (nguồn gốc bộ dữ liệu): Trích xuất chuỗi URL (bằng cách sử dụng event.origin cho đối tượng MessageEvent hoặc candidate.href cho phần tử liên kết), phân tích cú pháp cả URL đề xuất và URL đáng tin cậy bằng new URL(), từ chối "null" nguồn gốc mờ (candidateUrl.origin !== 'null') và so sánh candidateUrl.origin === trustedUrl.origin. Vì nguồn gốc mờ không bao giờ có cùng nguồn gốc với nguồn gốc bộ dữ liệu như 'https://app.example.com', nên việc từ chối ngay lập tức 'null' sẽ khớp với Origin.isSameOrigin().
  • Dự phòng cùng trang web (lưu ý): Giao diện URL không hiển thị Danh sách hậu tố công khai của trình duyệt. Trong các trình duyệt không có Origin, chỉ thực hiện so khớp hậu tố với một miền có thể đăng ký riêng tư, rõ ràng và đã biết mà bạn kiểm soát (xác minh cả protocol và kết quả khớp chính xác hoặc có tiền tố dấu chấm hostname) hoặc sử dụng thư viện Danh sách hậu tố công khai được duy trì.
  • Dự phòng iframe mờ/hộp cát: Trình duyệt không có Origin không hiển thị danh tính nguồn gốc mờ riêng biệt trên MessageEvent (event.origin luôn là "null"). Nếu bạn cố ý chấp nhận thông báo từ một <iframe sandbox="allow-scripts"> hộp cát đã biết, hãy xác minh event.source === expectedWidgetWindow (hoặc giao tiếp qua một MessageChannel MessagePort chuyên dụng được chuyển đến iframe khi khởi chạy) thay vì so sánh 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;
  }
}