Как ограничить исходящие сетевые подключения с помощью белых списков

Заголовок HTTP-ответа Connection-Allowlist ограничивает обмен данными документа или воркера только с явным списком конечных точек сети. В отличие от Content Security Policy (CSP), где нужно поддерживать отдельные директивы для каждого ресурса (connect-src, img-src, script-src, font-src, form-action) и которая не управляет спекулятивными или не связанными с Fetch сетевыми каналами, такими как <link rel="dns-prefetch">, <link rel="preconnect"> или WebRTC, Connection-Allowlist применяет единый белый список сетевого уровня ко всем исходящим подключениям, инициированным контекстом (fetch(), XMLHttpRequest, субресурсы, navigator.sendBeacon(), <a ping>, навигация верхнего уровня и фреймов, подсказки ресурсов, правила спекуляции, WebSocket, WebTransport и WebRTC).

Рекомендации по внедрению

  1. Сериализуйте белый список как внутренний список RFC 9651 (...). Отформатируйте Connection-Allowlist (или Connection-Allowlist-Report-Only) как один список в скобках, элементы которого разделены пробелами. Добавьте токен response-origin без кавычек, чтобы разрешить собственный источник документа или обработчика, а затем строки URLPattern в двойных кавычках для каждой допустимой внешней конечной точки, к которой подключается или переходит контекст (API, CDN изображений/шрифтов и исходящие цели <a href>, <form action>, OAuth или перенаправления платежей). При динамическом создании шаблонов из объектов конфигурации или URL нормализуйте ws:/wss: до http:/https: и удалите косые черты в конце URL, содержащих только источник (поскольку new URL('https://api.example.com').href добавляет /, что ограничивает сопоставление URLPattern только корневым путем /).
  2. Ограничьте область действия промежуточного ПО с помощью Sec-Fetch-Dest и охватите воркеры. Браузеры учитывают Connection-Allowlist только в ответах на запросы документов и навигации, а также в ответах скриптов воркеров (значения Sec-Fetch-Dest: document, iframe, worker, sharedworker и serviceworker). При изменении заголовков с помощью 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 блокирует все HTTP-переадресации 3xx (redirects=block, даже при переадресации между URL из белого списка или URL одного источника) и весь трафик RTCPeerConnection (webrtc=block). Добавляйте ; redirects=allow, только если доверенным конечным точкам требуется переадресация на стороне сервера (исходный запрос по-прежнему должен соответствовать белому списку), или ; webrtc=allow, если требуется WebRTC. На стороне клиента оберните исходящие вызовы fetch() в try...catch и обработайте события WebSocket error, чтобы запросы, отклоненные с кодом TypeError (net::ERR_NETWORK_ACCESS_REVOKED), возвращали обработанное состояние ошибки и обновляли статус пользовательского интерфейса, а не вызывали необработанные отклонения.

Пример кода

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, перед сериализацией шаблонов.
  • Добавьте конечные точки WebSocket (ws:// и wss://) в белый список, используя шаблоны "http://..." и "https://..." в Connection-Allowlist, поскольку браузер нормализует схемы WebSocket до HTTP/HTTPS перед сопоставлением.
  • РЕКОМЕНДУЕТСЯ включать ожидаемые внешние цели навигации (<a href>, <form action>, поставщики OAuth/SSO, платежные шлюзы) в белый список документа с ограниченным доступом. Помните, что значение redirects=block по умолчанию блокирует даже переадресацию 3xx в пределах одного источника, если не указано значение ; redirects=allow.
  • НЕ заключайте в кавычки значения параметров 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) (и элементом <meta http-equiv="Content-Security-Policy"> для ограничений на получение документов и подресурсов, когда заголовки HTTP-ответов нельзя настроить):

  • Дополнительная углубленная защита. Поддерживаемые браузеры применяют оба правила одновременно: Connection-Allowlist блокирует каналы утечки данных, не связанные с CSP (dns-prefetch, preconnect, WebRTC и переадресации), а CSP ограничивает fetch(), дочерние ресурсы и переходы <form action> в браузерах без Connection-Allowlist.
  • Явно укажите form-action и worker-src. В CSP default-src не используется в качестве резервного варианта для отправки <form action>. Всегда явно указывайте form-action. Включите worker-src 'self' (за исключением blob:), чтобы страницы с ограниченным доступом не могли создавать blob: SharedWorkers для обхода ограничений на уровне документа.
  • Сопоставьте разрешенные источники с директивами CSP для каждого ресурса. В отличие от Connection-Allowlist (где используется один список конечных точек и не регулируется встроенная разметка), default-src 'self' в CSP ограничивает каждый тип ресурса (connect-src, img-src, style-src, font-src, script-src) и блокирует встроенные блоки <style> и <script>, если они не авторизованы с помощью хешей или одноразовых чисел.
  • Добавьте wss:// в CSP connect-src. Хотя Connection-Allowlist сопоставляет подключения wss:// с шаблонами "https://...", в CSP connect-src в кроссбраузерных средах должны быть явно указаны источники wss://, если используются WebSockets.
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'"
/>