使用连接许可清单限制出站网络连接

Web 功能: 连接许可清单

Connection-Allowlist HTTP 响应标头会限制文档或工作器只能与明确的网络端点列表进行通信。与内容安全政策 (CSP) 不同,Connection-Allowlist 会对上下文(fetch()、XMLHttpRequest、子资源、navigator.sendBeacon()、<a ping>、顶级和框架导航、资源提示、推测规则、WebSocket、WebTransport 和 WebRTC)发起的所有出站连接强制执行单一的网络层许可清单。CSP 需要维护单独的每个资源指令(connect-src、img-src、script-src、font-src、form-action),并且不管理推测性或非提取网络通道(例如 <link rel="dns-prefetch">、<link rel="preconnect"> 或 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:,并去除仅包含源的网址末尾的斜杠(因为 new URL('https://api.example.com').href 会附加 /,这会将 URLPattern 匹配限制为仅匹配根路径 /)。
  2. 按 Sec-Fetch-Dest 确定中间件范围并涵盖 worker:浏览器仅在文档/导航响应和 worker 脚本响应(Sec-Fetch-Dest 值为 document、iframe、worker、sharedworker 和 serviceworker)中遵循 Connection-Allowlist。按 Sec-Fetch-Dest 改变标头时,请添加 Vary: Sec-Fetch-Dest,以便共享缓存不会提供无标头的响应。在专用和共享工作器脚本上提供匹配的许可清单,这样被入侵的页面脚本就无法通过 worker.postMessage() 渗漏数据。由于 Service Worker 会在缓存未命中和导航预加载时自行执行网络提取,因此请将其 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 是被屏蔽的目标网址或 "webrtc",disposition 是 "enforce" 或 "report")。
  4. 考虑默认重定向和 WebRTC 屏蔽,并处理客户端错误:默认情况下,Connection-Allowlist 会屏蔽所有 3xx HTTP 重定向(redirects=block,即使在许可清单中的网址或同源网址之间重定向也是如此)和所有 RTCPeerConnection 流量 (webrtc=block)。仅当可信端点需要服务器端重定向(初始请求仍必须与许可清单匹配)时,才附加 ; redirects=allow;或者在需要 WebRTC 时附加 ; webrtc=allow。在客户端上,将出站 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: 仍处于允许状态)。
  • 务必省略源级网址格式 ("https://api.example.com") 的尾部斜杠,或使用显式通配符 ("https://api.example.com/*"),以便允许使用 /v1/users 等子路径。在序列化模式之前,去除 new URL(origin).href 添加的尾随斜杠。
  • 务必在 Connection-Allowlist 中使用 "http://..." 和 "https://..." 模式将 WebSocket(ws:// 和 wss://)端点列入许可清单,因为浏览器会在匹配前将 WebSocket 架构标准化为 HTTP/HTTPS。
  • 务必在受限文档的许可清单中包含预期的外部导航目标(<a href>、<form action>、OAuth/SSO 提供商、付款结账),并记住,除非指定了 ; redirects=allow,否则默认的 redirects=block 甚至会阻止同源 3xx 重定向。
  • 请勿将 response-origin 或内部列表参数值(; report-to=conn-reports、; redirects=allow、; webrtc=allow)用引号括起来,也不要用英文逗号分隔 (...) 中的项。
  • 请勿在网址格式中使用自定义正则表达式组(例如 (\d+))或相对路径(例如 "/api/*");浏览器的网络服务解析器会拒绝不含协议和主机名的正则表达式组和格式。
  • 请勿在生产环境中依赖实验性 <iframe connectionAllowlist="..."> 属性或 Allow-Connection-Allowlist-From 嵌入式强制执行标头,因为在发布的浏览器中,嵌入式强制执行默认处于停用状态。

后备策略

由于 Connection-Allowlist 是通过 HTTP 响应标头在浏览器网络层强制执行的,因此无法在客户端 JavaScript 中进行 polyfill。如果您的基准目标包括尚不支持 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。添加 worker-src 'self'(不包括 blob:),这样受限网页就无法生成 blob: SharedWorker 来绕过文档级限制。
  • 将允许的来源映射到每个资源的 CSP 指令:与 Connection-Allowlist(使用单个端点列表且不控制内嵌标记)不同,CSP 中的 default-src 'self' 会限制每种资源类型(connect-src、img-src、style-src、font-src、script-src),并屏蔽内嵌 <style> 和 <script> 块,除非通过哈希或 Nonce 获得授权。
  • 在 CSP connect-src 中添加 wss://:虽然 Connection-Allowlist 会根据 "https://..." 模式匹配 wss:// 连接,但在跨浏览器环境中,如果使用 WebSocket,CSP connect-src 应明确列出 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'"
/>