區域網路存取權

網頁功能: 區域網路存取權

如果網頁應用程式來自 public 來源 (或內部網路 local 來源),並連線至使用者的私人區域網路 (例如 192.168.1.x 或 device.local 的區域網路印表機、IoT 中樞或路由器),或連線至使用者機器上執行的本機隨附服務 (127.0.0.1、[::1] 或 localhost),瀏覽器會顯示區域網路存取 (LNA) 權限提示,要求使用者授予權限後才能連線。

區域網路存取權會根據 IP 位址空間網格 (public → local → loopback),將存取權拆分為兩項精細權限和 Permissions-Policy 權杖,並僅在要求從較不私密的位址空間跨入較私密的位址空間時,強制執行檢查:

  • local-network:從 public 來源連線至 local 位址空間時,必須提供這項資訊 (RFC 1918 私人 IPv4 範圍 10.0.0.0/8、172.16.0.0/12、192.168.0.0/16、連結本機 169.254.0.0/16/fe80::/10、IPv6 唯一本機位址 fc00::/7 和 .local mDNS 主機名稱)。
  • loopback-network:從 public 或 local 來源連線至 loopback 位址空間 (127.0.0.0/8、::1/128 或 localhost) 時,必須提供這個值。

由於 localhost (127.0.0.1 和 [::1]) 已位於最私密的 (loopback) 位址空間,因此在本機開發期間,從 http://localhost 提供網頁應用程式,預設不會觸發 LNA 檢查。如要在本機測試 LNA 提示和權限狀態,而不部署至公開伺服器,請使用 --ip-address-space-overrides=127.0.0.1:8080=public,[::1]:8080=public Chromium 旗標,將本機開發伺服器的 IPv4 和 IPv6 位址空間覆寫為 public,或在瀏覽器的「網站設定」中,手動切換「區域網路」和「回送網路」的 Ask、Allow 和 Block。

必要:任何會發出區域或回送網路要求的網頁應用程式,都必須透過安全情境 (https://) 提供。在不安全 (http://) 的頂層情境中,系統會自動拒絕區域網路存取權限。

請勿依賴已淘汰的私人網路存取權 (PNA) 預檢標頭 (Access-Control-Request-Private-Network: true/Access-Control-Allow-Private-Network: true)。區域網路存取權會透過用戶端使用者授權提示 (local-network和loopback-network),搭配標準 CORS 標頭 (Access-Control-Allow-Origin) 強制執行存取權,以便讀取跨源回應。PNA 預檢從未強制做為封鎖條件,也不需要做為備用機制。現有伺服器或裝置韌體若已傳回 Access-Control-Allow-Private-Network: true,可繼續執行,不會造成任何影響,但請勿在新的程式碼中加入 PNA 處理。

導入步驟

1. 預先檢查精細權限狀態,並根據使用者手勢控管

當權限狀態為 'prompt' 時,請勿在載入網頁 (DOMContentLoaded) 時無條件觸發區域網路或回送要求,也請勿從 Service Worker 或 Shared Worker 啟動第一個觸發權限的要求 (這些 Worker 沒有附加的 Document,如果權限尚未設為 'granted',就會立即失敗)。

請改為先查詢 navigator.permissions.query() 的 'local-network' 或 'loopback-network':

  • 如果state === 'granted',則自動連線。
  • 如果 state === 'prompt',請顯示清楚的 UI,說明應用程式為何需要存取本機裝置或隨附應用程式,並透過明確的使用者手勢 (例如點選按鈕) 觸發要求。
  • 如果為 state === 'denied',請顯示可操作的修復 UI,指示使用者如何在瀏覽器網站設定中重新啟用區域網路或回送存取權。
/**
 * Queries the granular Local Network Access permission for the target space.
 * @param {'local-network' | 'loopback-network'} permissionName
 */
export async function checkNetworkPermission(permissionName) {
  const status = await navigator.permissions.query({ name: permissionName });

  // Keep UI synchronized if the user changes permission in browser site settings
  status.addEventListener('change', () => {
    updatePermissionUI(permissionName, status.state);
  });

  updatePermissionUI(permissionName, status.state);
  return status.state;
}

function updatePermissionUI(permissionName, state) {
  const statusBadge = document.querySelector(`[data-permission-status="${permissionName}"]`);
  const remediationBox = document.querySelector(`[data-remediation="${permissionName}"]`);
  if (statusBadge) {
    statusBadge.textContent = state;
  }
  if (remediationBox) {
    // Show remediation instructions whenever permission is explicitly denied
    remediationBox.hidden = state !== 'denied';
  }
}

2. 在 fetch() 和 Request 中宣告 targetAddressSpace

當 https:// 網頁要求區域網路或回送介面上的 http:// 端點時,請在 fetch() 或 Request 選項中指定 targetAddressSpace: 'local' 或 targetAddressSpace: 'loopback'。

明確設定 targetAddressSpace 有兩大重要作用:

  1. 混合內容豁免:混合內容檢查會在 DNS 解析「之前」執行。宣告 targetAddressSpace: 'local' (或 'loopback') 可在 DNS 解析前通知瀏覽器,http:// 網址 (包括解析為本機 IP 的自訂 DNS 主機名稱) 指定本機/回送端點,且在使用者授予權限後,應免除混合內容封鎖。
  2. 位址空間驗證:DNS 解析後,瀏覽器會驗證解析的 IP 位址是否確實屬於宣告的 targetAddressSpace。如果含有 targetAddressSpace: 'local' 的主機名稱意外解析為 public IP 位址,瀏覽器會立即中止要求,並顯示 TypeError 網路錯誤。
/**
 * Connects to a local network device (e.g., LAN printer or IoT hub) over HTTP.
 * Example endpoint URL is illustrative; replace with your device's address.
 */
export async function fetchLocalDeviceStatus(deviceUrl = 'http://192.168.1.100:8080/api/status') {
  const request = new Request(deviceUrl, {
    method: 'GET',
    mode: 'cors',
    // MANDATORY: Declare 'local' when targeting RFC 1918 / .local LAN addresses
    targetAddressSpace: 'local',
  });

  const response = await fetch(request);
  if (!response.ok) {
    throw new Error(`Device responded with HTTP ${response.status}`);
  }
  return response.json();
}

/**
 * Connects to a local companion daemon running on the user's loopback interface.
 * Example loopback port is illustrative; replace with your service's port.
 */
export async function fetchLoopbackDaemon(daemonUrl = 'http://127.0.0.1:45678/health') {
  const request = new Request(daemonUrl, {
    method: 'GET',
    mode: 'cors',
    // MANDATORY: Declare 'loopback' when targeting 127.0.0.1, [::1], or localhost
    targetAddressSpace: 'loopback',
  });

  const response = await fetch(request);
  if (!response.ok) {
    throw new Error(`Companion app responded with HTTP ${response.status}`);
  }
  return response.json();
}

3. 與WebSocket和WebTransport即時交流

區域網路存取權限制也控管 WebSocket 和 WebTransport 連線:

  • WebSocket:WebSocket(url, protocols) 建構函式只接受子通訊協定字串或陣列做為第二個引數,請勿傳遞 { targetAddressSpace } 選項物件,否則會擲回 SyntaxError。相反地,瀏覽器會在 DNS 解析前檢查 WebSocket 網址:如果網址主機在語法上可辨識為本機或迴圈 (具體來說,是私人或迴圈 IP 常值 (ws://192.168.1.100:8080、ws://127.0.0.1:8080、ws://[::1]:8080)、localhost (*.localhost) 或 .local mDNS 主機名稱 (ws://device.local:8080)),且受到 local-network 或 loopback-network 權限提示的限制,則從 https:// 頁面啟動的 ws:// 連線不會遭到混合內容封鎖。如果本機端點是透過自訂非 .local DNS 主機名稱定址,請透過 wss:// 連線。
  • WebTransport:由於 WebTransport 嚴格要求 https:// (HTTP/3 over QUIC with 傳輸層安全標準:TLS 或 serverCertificateHashes 適用於本機自行簽署憑證),因此絕不會觸發複合型內容封鎖,也不接受 targetAddressSpace 選項。瀏覽器會自動驗證解析的 IP 位址空間,並在 local-network 或 loopback-network 權限提示後方,限制 transport.ready 承諾。
/**
 * Opens a WebSocket connection to a local network device (IP literal, .local, or localhost).
 */
export function openLocalWebSocket(wsUrl = 'ws://192.168.1.100:8080/stream') {
  // Pass only subprotocol string(s) as the second argument (never an options object).
  // Browsers exempt ws:// from Mixed Content when the host is a local/loopback IP literal,
  // .local, or localhost, and gate the connection on 'local-network' or 'loopback-network'.
  const socket = new WebSocket(wsUrl, ['v1.telemetry']);
  return socket;
}

/**
 * Opens a WebTransport session to a local network endpoint using a pinned self-signed certificate hash.
 */
export async function openLocalWebTransport(wtUrl = 'https://192.168.1.100:4433/wt', certSha256Bytes) {
  const transport = new WebTransport(wtUrl, {
    serverCertificateHashes: [
      {
        algorithm: 'sha-256',
        value: certSha256Bytes,
      },
    ],
  });

  // transport.ready resolves only after the user grants the 'local-network' permission
  // and rejects with WebTransportError if the permission is denied.
  await transport.ready;
  return transport;
}

4. 透過 Permissions-Policy 將存取權委派給跨源 iframe

根據預設,local-network 和 loopback-network 的預設許可清單為 'self',會封鎖跨源 <iframe> 嵌入,不允許其提出本機或回送要求。如要允許嵌入的跨源 <iframe> (例如硬體設定小工具或與本機代理程式通訊的 SSO 框架) 發出本機或回送要求,請在 allow 屬性和/或 HTTP Permissions-Policy 標頭中明確委派精細權限:

<!-- Delegate granular local-network and loopback-network permissions to the embedded iframe -->
<iframe
  src="https://setup.partner.example.com/device-bridge"
  allow="local-network; loopback-network"
  title="Local Device Bridge">
</iframe>

在頂層 (嵌入) 文件的回應中傳送 Permissions-Policy 標頭,而不是在 iframe 的回應中傳送:

Permissions-Policy: local-network=(self "https://setup.partner.example.com"), loopback-network=(self "https://setup.partner.example.com")

備用策略

如果基準目標不支援區域網路存取權限和 targetAddressSpace 選項,請實作功能偵測和漸進式備用機制:

  1. 權限查詢備用方案:將 navigator.permissions.query({ name: permissionName }) 包裝在 try / catch 中。如果瀏覽器因 'local-network' 或 'loopback-network' 是無法辨識的 PermissionName 而擲回 TypeError,請將狀態視為 'prompt',這樣要求仍會在使用者明確點選時執行 (連線嘗試本身會觸發支援 LNA 的瀏覽器提示)。
  2. 請勿查詢舊版合併權限名稱 { name: 'local-network-access' }。在舊版 Chrome 中,這項查詢會導致轉譯器程序停止運作,而 try / catch 無法防止這種情況。新版 Chrome 只會將 'local-network-access' 視為精細權限的舊版別名。
  3. 妥善處理拒絕連線:請一律將 fetch()、WebSocket 錯誤事件和 transport.ready 包裝在錯誤處理常式中,以便擷取 TypeError/連線失敗,並提供明確的修正步驟 (檢查本機裝置是否已開啟、連線至相同網路,以及是否已授予瀏覽器權限)。
/**
 * Cross-browser permission check for the granular LNA permissions.
 * Never falls back to querying 'local-network-access', which crashes older Chrome versions.
 * @param {'local-network' | 'loopback-network'} permissionName
 */
export async function queryLnaPermissionSafe(permissionName) {
  if (!navigator.permissions?.query) {
    return 'prompt';
  }
  try {
    const status = await navigator.permissions.query({ name: permissionName });
    return status.state;
  } catch {
    // Unrecognized permission name; proceed on user gesture
    return 'prompt';
  }
}