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를 전달하세요.
- 직렬화된 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
});
2. 교차 하위 도메인 리디렉션 및 링크 검증 (isSameSite())
동일한 등록 가능 도메인의 하위 도메인 (예: 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으로 전송된 전용MessageChannelMessagePort를 통해 통신합니다.
// 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;
}
}