使用連線許可清單限制傳出網路連線

網路功能: 連線許可清單

Connection-Allowlist HTTP 回應標頭會限制文件或 worker 只能與明確列出的網路端點通訊。內容安全政策 (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,允許文件或 worker 自己的來源,後面加上雙引號 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,以免共用快取提供不含標頭的回應。在 Dedicated 和 Shared Worker 指令碼上提供相符的許可清單,這樣一來,遭入侵的網頁指令碼就無法透過 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) 拒絕的要求就會傳回已處理的錯誤狀態,並更新 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: 仍可使用)。
  • 建議省略來源層級網址模式 ("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=block 甚至會封鎖同源 3xx 重新導向,除非指定 ; redirects=allow。
  • 請勿在 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 則會限制 fetch()、子資源和 <form action> 在不支援 Connection-Allowlist 的瀏覽器中導覽。
  • 明確涵蓋 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 會比對 wss:// 連線與 "https://..." 模式,但在跨瀏覽器環境中,CSP connect-src 應明確列出使用 WebSocket 時的 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'"
/>