연결 허용 목록으로 아웃바운드 네트워크 연결 제한

웹 기능: 연결 허용 목록

Connection-Allowlist HTTP 응답 헤더는 문서 또는 작업자가 명시적인 네트워크 엔드포인트 목록과만 통신하도록 제한합니다. 리소스별 지시문(connect-src, img-src, script-src, font-src, form-action)을 별도로 유지해야 하고 <link rel="dns-prefetch">, <link rel="preconnect">, WebRTC와 같은 추측성 또는 비가져오기 네트워크 채널을 제어하지 않는 콘텐츠 보안 정책(CSP)과 달리 Connection-Allowlist는 컨텍스트 (fetch(), XMLHttpRequest, 하위 리소스, navigator.sendBeacon(), <a ping>, 최상위 및 프레임 탐색, 리소스 힌트, 추측 규칙, WebSocket, WebTransport, WebRTC)에서 시작된 모든 아웃바운드 연결에 단일 네트워크 계층 허용 목록을 적용합니다.

구현 방법

  1. 허용 목록을 RFC 9651 내부 목록 (...)로 직렬화: Connection-Allowlist (또는 Connection-Allowlist-Report-Only)를 공백으로 구분된 항목의 단일 괄호 목록으로 형식 지정합니다. 따옴표가 없는 토큰 response-origin를 포함하여 문서 또는 작업자의 자체 출처를 허용하고, 컨텍스트가 연결되거나 탐색하는 모든 합법적인 외부 엔드포인트 (API, 이미지/글꼴 CDN, 아웃바운드 <a href>, <form action>, OAuth 또는 결제 리디렉션 타겟)에 대해 큰따옴표로 묶인 URLPattern 문자열을 포함합니다. 구성 또는 URL 객체에서 동적으로 패턴을 빌드할 때 ws:/wss:를 http:/https:로 정규화하고 원본 전용 URL에서 후행 슬래시를 제거합니다(new URL('https://api.example.com').href가 /를 추가하여 URLPattern 일치를 루트 경로 /로만 제한하기 때문).
  2. Sec-Fetch-Dest별 미들웨어 범위 지정 및 작업자 포함: 브라우저는 문서/탐색 응답 및 작업자 스크립트 응답 (Sec-Fetch-Dest 값 document, iframe, worker, sharedworker, serviceworker)에서만 Connection-Allowlist를 적용합니다. Sec-Fetch-Dest별로 헤더를 변경할 때는 공유 캐시가 헤더 없는 응답을 제공하지 않도록 Vary: Sec-Fetch-Dest를 포함하세요. 손상된 페이지 스크립트가 worker.postMessage()를 통해 데이터를 유출할 수 없도록 전용 및 공유 작업자 스크립트에 일치하는 허용 목록을 제공합니다. 서비스 워커는 캐시 부적중 및 탐색 미리 로드 시 자체 네트워크 가져오기를 실행하므로 Connection-Allowlist를 제어하는 모든 문서의 허용 목록의 상위 집합으로 구성합니다.
  3. 위반 보고 및 단계적 또는 이중 출시 구성: Reporting-Endpoints 헤더를 정의하고 닫는 ) 뒤에 따옴표가 없는 ; report-to=<endpoint-name> 매개변수를 추가합니다. 초기 출시 중에 Connection-Allowlist-Report-Only만 전송하거나 동일한 응답에 Connection-Allowlist (기준 허용 목록 적용) 및 Connection-Allowlist-Report-Only (더 엄격한 후보 허용 목록 감사)를 모두 전송할 수 있습니다. 위반 보고서는 "type": "connection-allowlist" 및 본문 { url, connection, allowlist, disposition }과 함께 전송됩니다(여기서 connection는 차단된 도착 URL 또는 "webrtc"이고 disposition는 "enforce" 또는 "report"임).
  4. 기본 리디렉션 및 WebRTC 차단 고려, 클라이언트 오류 처리: 기본적으로 Connection-Allowlist는 모든 3xx HTTP 리디렉션 (redirects=block, 허용 목록에 추가된 URL 또는 동일 출처 URL 간에 리디렉션하는 경우에도 해당)과 모든 RTCPeerConnection 트래픽 (webrtc=block)을 차단합니다. 신뢰할 수 있는 엔드포인트에 서버 측 리디렉션이 필요한 경우에만 ; redirects=allow를 추가하고 (초기 요청은 여전히 허용 목록과 일치해야 함) WebRTC가 필요한 경우에만 ; webrtc=allow를 추가합니다. 클라이언트에서 아웃바운드 fetch() 호출을 try...catch로 래핑하고 WebSocket error 이벤트를 처리하여 TypeError (net::ERR_NETWORK_ACCESS_REVOKED)로 거부된 요청이 처리되지 않은 거부를 일으키는 대신 처리된 오류 상태를 반환하고 UI 상태를 업데이트하도록 합니다.

예시 코드

Vary: Sec-Fetch-Dest
Reporting-Endpoints: conn-reports="https://app.example.com/api/security-reports"
Connection-Allowlist: (response-origin "https://api.example.com" "https://realtime.example.com" "https://*.cdn.example.com/*"); report-to=conn-reports
Connection-Allowlist-Report-Only: (response-origin "https://api.example.com/v2/*"); report-to=conn-reports
const ALLOWLIST_DESTS = new Set(['document', 'iframe', 'worker', 'sharedworker', 'serviceworker']);

// Normalize a config URL or URLPattern string for Connection-Allowlist:
// - Rewrites ws:// and wss:// to http:// and https:// (WebSocket matching rule).
// - Strips a bare trailing slash on origin-only URLs (e.g. from new URL(...).href)
//   so all subpaths on the origin are permitted instead of only "/".
export function normalizeAllowlistPattern(rawPattern) {
  const trimmed = String(rawPattern).trim().replace(/^ws(s?):\/\//i, 'http$1://');
  if (!/^https?:\/\//i.test(trimmed)) {
    throw new Error(`Pattern must include an http:// or https:// scheme: ${rawPattern}`);
  }
  if (/[\s,]/.test(trimmed)) {
    throw new Error(`Pattern must be a single URLPattern without spaces or commas: ${rawPattern}`);
  }
  if (/[()]/.test(trimmed)) {
    throw new Error(`Custom regex groups are rejected by Connection-Allowlist: ${rawPattern}`);
  }
  return trimmed.replace(/^(https?:\/\/[^/?#]+)\/$/i, '$1');
}

// Build an RFC 9651 Inner List value for Connection-Allowlist or Connection-Allowlist-Report-Only.
export function buildConnectionAllowlistHeader({
  allowSelf = true,
  patterns = [],
  reportTo,
  redirects = 'block',
  webrtc = 'block',
} = {}) {
  const items = allowSelf ? ['response-origin'] : [];
  for (const pattern of patterns) {
    const raw = String(pattern).trim();
    if (raw === 'response-origin') {
      if (!items.includes('response-origin')) items.push('response-origin');
      continue;
    }
    const normalized = normalizeAllowlistPattern(raw);
    items.push(`"${normalized.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`);
  }
  let header = `(${items.join(' ')})`;
  if (redirects === 'allow') header += '; redirects=allow';
  if (webrtc === 'allow') header += '; webrtc=allow';
  if (reportTo) {
    const endpointToken = String(reportTo).trim().replace(/^"+|"+$/g, '');
    header += `; report-to=${endpointToken}`;
  }
  return header;
}

// Server or middleware helper applying Connection-Allowlist and Reporting-Endpoints
// on document and worker responses.
export function setConnectionAllowlistHeaders(res, options = {}) {
  const {
    secFetchDest = 'document',
    patterns = ['https://api.example.com', 'https://realtime.example.com', 'https://*.cdn.example.com/*'],
    reportOnlyPatterns,
    reportEndpointName = 'conn-reports',
    reportEndpointUrl = 'https://app.example.com/api/security-reports',
    redirects = 'block',
    webrtc = 'block',
    reportOnly = false,
  } = options;

  const setHeader = (name, value) =>
    typeof res.setHeader === 'function' ? res.setHeader(name, value) : res.headers.set(name, value);

  if (typeof res.appendHeader === 'function') {
    res.appendHeader('Vary', 'Sec-Fetch-Dest');
  } else if (typeof res.headers?.append === 'function') {
    res.headers.append('Vary', 'Sec-Fetch-Dest');
  } else {
    setHeader('Vary', 'Sec-Fetch-Dest');
  }
  if (secFetchDest && !ALLOWLIST_DESTS.has(String(secFetchDest).toLowerCase())) return;

  setHeader('Reporting-Endpoints', `${reportEndpointName}="${reportEndpointUrl}"`);
  setHeader(
    reportOnly ? 'Connection-Allowlist-Report-Only' : 'Connection-Allowlist',
    buildConnectionAllowlistHeader({ allowSelf: true, patterns, reportTo: reportEndpointName, redirects, webrtc }),
  );

  if (!reportOnly && Array.isArray(reportOnlyPatterns)) {
    setHeader(
      'Connection-Allowlist-Report-Only',
      buildConnectionAllowlistHeader({
        allowSelf: true,
        patterns: reportOnlyPatterns,
        reportTo: reportEndpointName,
        redirects,
        webrtc,
      }),
    );
  }
}

// Client-side helper that catches blocked outbound requests gracefully without unhandled rejections.
export async function fetchWithPolicyHandling(url, { statusElement, ...fetchOptions } = {}) {
  try {
    const response = await fetch(url, fetchOptions);
    if (!response.ok) throw new Error(`HTTP ${response.status}`);
    const body = await response.text();
    if (statusElement) {
      statusElement.textContent = `Allowed: Loaded ${url} (${body.length} bytes).`;
    }
    return { ok: true, body, error: null };
  } catch (error) {
    const message = error instanceof Error ? error.message : String(error);
    if (statusElement) {
      statusElement.textContent = `Blocked: Request to ${url} was rejected by the outbound connection policy (${message}).`;
    }
    return { ok: false, body: null, error };
  }
}

권장사항

  • Connection-Allowlist를 공백으로 구분된 항목의 단일 RFC 9651 내부 목록 (...)으로 형식 지정합니다(권장). 비어 있지 않은 Connection-Allowlist 헤더가 최상위 구조화된 필드 파싱에 실패하면 (예: (...) 생략 또는 작은따옴표 '...' 사용) 브라우저가 빈 허용 목록으로 닫히고 모든 아웃바운드 네트워크 연결을 차단합니다. 로드 후 의도적으로 모든 네트워크 액세스를 취소하려는 경우 Connection-Allowlist: ()를 사용합니다 (로컬 스키마 data:, blob:, about:, filesystem:는 계속 허용됨).
  • 원본 전체 URL 패턴 ("https://api.example.com")에서 후행 슬래시를 생략하거나 명시적 와일드 카드 ("https://api.example.com/*")를 사용하여 /v1/users와 같은 하위 경로를 허용합니다. 패턴을 직렬화하기 전에 new URL(origin).href에서 추가한 후행 슬래시를 삭제합니다.
  • DO allowlist WebSocket (ws:// and wss://) endpoints using "http://..." and "https://..." patterns in Connection-Allowlist, because the browser normalizes WebSocket schemes to HTTP/HTTPS before matching.
  • 권장사항: 예상되는 외부 탐색 타겟 (<a href>, <form action>, OAuth/SSO 제공업체, 결제 체크아웃)을 제한된 문서의 허용 목록에 포함합니다. 기본 redirects=block는 ; redirects=allow가 지정되지 않는 한 동일 출처 3xx 리디렉션도 차단한다는 점에 유의하세요.
  • response-origin 또는 내부 목록 파라미터 값 (; report-to=conn-reports, ; redirects=allow, ; webrtc=allow)을 따옴표로 묶거나 (...) 내의 항목을 쉼표로 구분하지 마세요.
  • URL 패턴 내에서 (\d+)와 같은 맞춤 정규 표현식 그룹이나 "/api/*"와 같은 상대 경로를 사용하지 마세요. 브라우저의 네트워크 서비스 파서는 스키마와 호스트 이름이 없는 정규 표현식 그룹과 패턴을 거부합니다.
  • 출시된 브라우저에서는 기본적으로 삽입된 시행이 사용 설정되지 않으므로 아직 프로덕션에서 실험용 <iframe connectionAllowlist="..."> 속성이나 Allow-Connection-Allowlist-From 삽입된 시행 헤더를 사용하지 마세요.

대체 전략

Connection-Allowlist는 HTTP 응답 헤더를 통해 브라우저 네트워크 계층에서 적용되므로 클라이언트 측 JavaScript에서 폴리필할 수 없습니다. 기준 타겟에 아직 Connection-Allowlist를 지원하지 않는 브라우저가 포함된 경우 Connection-Allowlist를 Content-Security-Policy (CSP) 헤더와 페어링합니다(HTTP 응답 헤더를 구성할 수 없는 경우 문서 수준 가져오기 및 하위 리소스 제한을 위한 <meta http-equiv="Content-Security-Policy"> 요소도 포함).

  • 추가적인 심층 방어: 지원 브라우저는 두 정책을 동시에 시행합니다. Connection-Allowlist는 CSP가 아닌 유출 채널 (dns-prefetch, preconnect, WebRTC, 리디렉션)을 차단하고, CSP는 Connection-Allowlist가 없는 브라우저에서 fetch(), 하위 리소스, <form action> 탐색을 제한합니다.
  • form-action 및 worker-src를 명시적으로 포함: CSP에서 default-src는 <form action> 제출에 대한 대체가 아닙니다. 항상 form-action를 명시적으로 포함하세요. 제한된 페이지에서 blob: SharedWorkers를 생성하여 문서 수준 제한을 우회할 수 없도록 worker-src 'self'(blob: 제외)를 포함합니다.
  • 허용된 출처를 리소스별 CSP 지시문에 매핑: 단일 엔드포인트 목록을 사용하고 인라인 마크업을 제어하지 않는 Connection-Allowlist와 달리 CSP의 default-src 'self'는 각 리소스 유형(connect-src, img-src, style-src, font-src, script-src)을 제한하고 해시 또는 nonce를 통해 승인되지 않는 한 인라인 <style> 및 <script> 블록을 차단합니다.
  • CSP connect-src에 wss:// 포함: Connection-Allowlist는 wss:// 연결을 "https://..." 패턴과 일치시키지만, 교차 브라우저 환경의 CSP connect-src는 WebSockets가 사용될 때 wss:// 출처를 명시적으로 나열해야 합니다.
Content-Security-Policy: default-src 'self'; connect-src 'self' https://api.example.com https://realtime.example.com wss://realtime.example.com; img-src 'self' https://*.cdn.example.com data: blob:; form-action 'self'; worker-src 'self'; object-src 'none'; base-uri 'self'; report-to conn-reports
<!-- Document-level fallback when HTTP response headers cannot be set on static hosting -->
<meta
  http-equiv="Content-Security-Policy"
  content="default-src 'self'; connect-src 'self' https://api.example.com https://realtime.example.com wss://realtime.example.com; img-src 'self' https://*.cdn.example.com data: blob:; form-action 'self'; worker-src 'self'; object-src 'none'; base-uri 'self'"
/>