웹 출처 및 사이트 비교 및 검증

웹 기능: 출처

event.origin를 고정된 포트 정규화 문자열 리터럴 (예: event.origin === 'https://app.example.com')과 비교하는 것은 간단한 허용 목록에 적합하지만, 임의의 URL, DOM 링크 요소, 교차 하위 도메인 리디렉션 또는 샌드박스 처리된 메시지를 검증할 때는 수동 문자열 비교 및 정규 표현식이 작동하지 않습니다.

  • 프리픽스 및 하위 문자열 스푸핑: url.startsWith('https://app.example.com') 확인이 'https://app.example.com.attacker.example'에 의해 우회됩니다.
  • 정규화되지 않은 URL: 경로, 쿼리 문자열 또는 명시적 기본 포트 ('https://app.example.com:443/path')가 있는 전체 URL은 먼저 파싱 및 정규화하지 않으면 문자열 동등성에서 'https://app.example.com'와 일치하지 않습니다.
  • 공개 접미사 목록 실수: .에서 호스트 이름을 분할하여 동일 사이트 관계를 확인하면 다중 라벨 공개 접미사 (예: .co.uk 또는 .github.io와 같은 공유 호스팅 도메인)에서 실패하고 스키마 불일치 (http: 및 https:)를 무시합니다.
  • 불투명 "null" 출처 충돌: 샌드박스 처리된 iframe (<iframe sandbox="allow-scripts">) 및 data: URL은 event.origin를 리터럴 문자열 "null"로 직렬화합니다. originA === originB를 사용하여 직렬화된 두 런타임 출처를 비교하면 관련 없는 두 개의 불투명 컨텍스트 ("null" === "null")가 잘못 동일시됩니다.

Origin API (Origin.from(), isSameOrigin(), isSameSite(), opaque)는 브라우저의 URL 파서 및 공개 서픽스 목록을 기반으로 구조화되고 사양을 준수하는 출처 및 스키마가 있는 동일 사이트 비교를 제공합니다.

시나리오에 적합한 비교 선택하기

  • 엄격한 권한 경계에 isSameOrigin() 사용: 교차 창 postMessage 명령어 (예: 삽입된 결제 또는 인증 팝업)를 처리하거나 사용자 인증 정보를 연결할지 결정할 때 정확한 (scheme, host, port) 일치를 요구합니다. 다른 하위 도메인 (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) 사용: 리디렉션 (origin.opaque === true)에 대한 data: URL을 거부하거나 직렬화된 event.origin가 "null"인 특정 <iframe sandbox="allow-scripts"> 위젯과 통신할 때 Origin.from(event)를 통해 추출된 불투명한 Origin를 고정합니다.

1. 권한이 있는 작업의 정확한 출처 확인 (Origin.from() 및 isSameOrigin())

Origin.from()를 사용하여 Origin 객체를 추출하고 isSameOrigin()를 사용하여 신뢰할 수 있는 Origin와 비교합니다.

  • 양쪽 모두 Origin 인스턴스여야 함: isSameOrigin(other) 및 isSameSite(other)는 Origin 객체만 허용합니다. 원시 문자열을 전달하면 TypeError가 발생합니다.
  • 지원되는 Origin.from() 입력:
    • 직렬화된 URL 문자열(예: 'https://app.example.com:443/path'). 잘못된 URL 문자열(예: 'example' 또는 불투명 출처에 대해 event.origin에 의해 직렬화된 'null' 문자열)은 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를 전달하세요.
  • 브라우저에서 전달된 인스턴스와 합성 MessageEvent 인스턴스를 모두 처리: 실제 브라우저 postMessage 이벤트는 Origin.from(event)에서 추출한 내부 출처를 전달하는 반면 단위 테스트에서 생성된 합성 new MessageEvent('message', { origin }) 이벤트는 event.origin 문자열만 채우고 Origin.from(event)에서 발생합니다. try...catch 내에서 Origin.from(event.origin)로 대체하면 잘못된 문자열이나 "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
});

동일한 등록 가능 도메인의 하위 도메인 (예: https://app.example.co.uk 및 https://billing.example.co.uk:8443)에서 리디렉션 타겟 (예: ?returnTo= 매개변수) 또는 내부 링크를 검증할 때는 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">), data: URL, new Origin()는 불투명 출처 (origin.opaque === true)를 생성합니다. 문자열 직렬화는 항상 "null"이지만 Origin.from(event)는 브라우저에서 전달된 MessageEvent에서 발신자의 실제 기본 불투명 출처를 추출합니다.

  • 동일한 샌드박스 처리된 iframe에서 전송된 두 메시지는 서로 isSameOrigin()인 Origin 객체를 생성합니다 (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;
  }
}

권장사항

  • isSameOrigin(other) 또는 isSameSite(other)를 호출하기 전에 Origin.from()를 통해 비교의 양쪽을 Origin 인스턴스로 변환해야 합니다.
  • 불투명한 출처 ID를 보존해야 하는 경우 브라우저에서 전달한 MessageEvent를 Origin.from(event)에 직접 전달하고, Origin.from(self)를 사용하여(TypeError를 발생시키는 Location 또는 Document 대신) 현재 실행 컨텍스트의 Origin를 가져옵니다(권장).
  • 권장사항: 신뢰할 수 없거나 런타임 입력에 대한 Origin.from() 호출을 try...catch 블록으로 래핑하여 잘못된 URL, "null" 문자열 또는 지원되지 않는 객체가 포착되지 않은 TypeError를 발생시키는 대신 false를 안전하게 반환하도록 합니다.
  • DO NOT: "null" (origin !== 'null')을 먼저 거부하지 않고 두 개의 런타임 event.origin 또는 url.origin 문자열을 직접 비교하지 마세요. 관련 없는 두 개의 불투명 출처가 모두 "null"로 직렬화되기 때문입니다.
  • startsWith(), includes(), endsWith() 또는 단순한 .split('.') 호스트 이름 슬라이싱을 사용하여 출처 또는 사이트를 검사하지 마세요.

대체 전략

Baseline 타겟에 아직 Origin 인터페이스를 지원하지 않는 브라우저가 포함된 경우 'Origin' in globalThis를 기능 감지하고 new URL()를 사용하여 URL을 파싱하도록 대체합니다.

  • 동일 출처 대체 (튜플 출처): URL 문자열을 추출하고(MessageEvent 객체에는 event.origin, 링크 요소에는 candidate.href 사용) new URL()를 사용하여 후보 URL과 신뢰할 수 있는 URL을 모두 파싱하고 "null" 불투명 출처 (candidateUrl.origin !== 'null')를 거부하고 candidateUrl.origin === trustedUrl.origin를 비교합니다. 불투명 출처는 'https://app.example.com'와 같은 튜플 출처와 동일 출처가 아니므로 'null'를 즉시 거부하는 것은 Origin.isSameOrigin()와 일치합니다.
  • 동일 사이트 대체 (주의사항): URL 인터페이스는 브라우저의 공개 서픽스 목록을 노출하지 않습니다. Origin가 없는 브라우저에서는 내가 관리하는 명시적이고 알려진 비공개 등록 가능 도메인에 대해서만 접미사 일치를 수행하거나(protocol 및 정확한 일치 또는 점 접두사 hostname 일치 모두 확인) 유지관리되는 공개 접미사 목록 라이브러리를 사용합니다.
  • 불투명/샌드박스 처리된 iframe 대체: Origin가 없는 브라우저는 MessageEvent에서 고유한 불투명 출처 ID를 노출하지 않습니다(event.origin는 항상 "null"). 알려진 샌드박스 처리된 <iframe sandbox="allow-scripts">의 메시지를 의도적으로 수락하는 경우 event.origin를 비교하는 대신 event.source === expectedWidgetWindow를 확인하거나 초기화 시 iframe으로 전송된 전용 MessageChannel MessagePort를 통해 통신합니다.
// 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;
  }
}