本地网络访问权限

Web 功能: 本地网络访问

当通过 public 源(或内网 local 源)提供的 Web 应用连接到用户的私有本地网络(例如 192.168.1.x 或 device.local 处的 LAN 打印机、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 提供 Web 应用默认不会触发 LNA 检查。如需在本地测试 LNA 提示和权限状态,而不部署到公共服务器,请使用 --ip-address-space-overrides=127.0.0.1:8080=public,[::1]:8080=public Chromium 标志将本地开发服务器的 IPv4 和 IPv6 地址空间替换为 public,或在浏览器网站设置中手动将本地网络和环回网络在 Ask、Allow 和 Block 之间切换。

强制性要求:从安全上下文 (https://) 中提供任何发起本地或环回网络请求的 Web 应用。在不安全 (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(缺少附加的 Document,如果权限尚未为 'granted',则会立即失败)发起第一个触发权限的请求。

请改为先查询 navigator.permissions.query(),以获取 'local-network' 或 'loopback-network':

  • 如果为 state === 'granted',则自动连接。
  • 如果 state === 'prompt',则显示清晰的界面,说明应用为何需要访问本地设备或配套应用,并通过明确的用户手势(例如点击按钮)触发请求。
  • 如果为 state === 'denied',则呈现可操作的修复界面,指导用户如何在浏览器网站设置中重新启用本地网络或环回访问权限。
/**
 * 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 promise。
/**
 * 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';
  }
}