Quyền truy cập mạng cục bộ

Khi một ứng dụng web được phân phát từ nguồn gốc public (hoặc nguồn gốc mạng nội bộ local) kết nối với mạng cục bộ riêng tư của người dùng (chẳng hạn như máy in LAN, trung tâm IoT hoặc bộ định tuyến tại 192.168.1.x hoặc device.local) hoặc với một dịch vụ đồng hành cục bộ đang chạy trên máy của người dùng (127.0.0.1, [::1] hoặc localhost), trình duyệt sẽ kiểm soát kết nối bằng lời nhắc cấp quyền Truy cập mạng cục bộ (LNA).

Quyền truy cập vào mạng cục bộ chia quyền truy cập thành hai quyền chi tiết và mã thông báo Permissions-Policy dựa trên mạng lưới không gian địa chỉ IP (public → local → loopback), thực thi các bước kiểm tra chỉ khi một yêu cầu chuyển từ không gian địa chỉ ít riêng tư hơn sang không gian địa chỉ riêng tư hơn:

  • local-network: Bắt buộc khi kết nối từ một nguồn gốc public đến không gian địa chỉ local (dải IPv4 riêng tư RFC 1918 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, liên kết cục bộ 169.254.0.0/16 / fe80::/10, Địa chỉ cục bộ duy nhất IPv6 fc00::/7 và tên máy chủ mDNS .local).
  • loopback-network: Bắt buộc khi kết nối từ nguồn gốc public hoặc local với không gian địa chỉ loopback (127.0.0.0/8, ::1/128 hoặc localhost).

Vì localhost (127.0.0.1 và [::1]) đã nằm trong không gian địa chỉ riêng tư nhất (loopback), nên việc phân phát ứng dụng web từ http://localhost trong quá trình phát triển cục bộ sẽ không kích hoạt quy trình kiểm tra LNA theo mặc định. Để kiểm thử lời nhắc và trạng thái quyền của LNA trên máy mà không cần triển khai lên máy chủ công khai, hãy ghi đè không gian địa chỉ IPv4 và IPv6 của máy chủ phát triển cục bộ thành public bằng cờ Chromium --ip-address-space-overrides=127.0.0.1:8080=public,[::1]:8080=public hoặc bật/tắt thủ công Mạng cục bộ và Mạng loopback giữa Ask, Allow và Block trong phần Cài đặt trang web của trình duyệt.

BẮT BUỘC: Phân phát mọi ứng dụng web khởi tạo yêu cầu mạng cục bộ hoặc mạng loopback từ một Bối cảnh bảo mật (https://). Quyền truy cập vào mạng cục bộ sẽ tự động bị từ chối trong các bối cảnh cấp cao nhất không an toàn (http://).

KHÔNG dựa vào các tiêu đề kiểm tra trước của tính năng Truy cập mạng riêng tư (PNA) không còn được dùng nữa (Access-Control-Request-Private-Network: true/Access-Control-Allow-Private-Network: true). Tính năng Truy cập mạng cục bộ thực thi quyền truy cập thông qua lời nhắc sự cho phép của người dùng phía máy khách (local-network và loopback-network) kết hợp với tiêu đề CORS tiêu chuẩn (Access-Control-Allow-Origin) khi đọc phản hồi khác nguồn gốc. Yêu cầu kiểm tra trước PNA chưa bao giờ được thực thi dưới dạng yêu cầu chặn và không cần thiết làm phương án dự phòng. Các máy chủ hoặc chương trình cơ sở thiết bị hiện có đã trả về Access-Control-Allow-Private-Network: true có thể tiếp tục làm như vậy mà không gây hại, nhưng không thêm tính năng xử lý PNA vào mã mới.

Các bước triển khai

1. Kiểm tra trước trạng thái quyền chi tiết và cổng trên cử chỉ của người dùng

Không bao giờ kích hoạt các yêu cầu mạng cục bộ hoặc yêu cầu lặp lại vô điều kiện khi tải trang (DOMContentLoaded) khi trạng thái quyền là 'prompt' và không bao giờ bắt đầu yêu cầu kích hoạt quyền đầu tiên từ trình chạy dịch vụ hoặc Shared Worker (thiếu Document đính kèm và sẽ không thành công ngay lập tức nếu quyền chưa được 'granted').

Thay vào đó, trước tiên hãy truy vấn navigator.permissions.query() cho 'local-network' hoặc 'loopback-network':

  • Nếu state === 'granted', hãy tự động kết nối.
  • Nếu state === 'prompt', hãy hiển thị giao diện người dùng rõ ràng giải thích lý do ứng dụng cần quyền truy cập vào thiết bị cục bộ hoặc ứng dụng đồng hành, đồng thời kích hoạt yêu cầu từ một cử chỉ rõ ràng của người dùng (chẳng hạn như nhấp vào nút).
  • Nếu state === 'denied', hãy hiển thị giao diện người dùng khắc phục có thể thực hiện được, hướng dẫn người dùng cách bật lại quyền truy cập vào Mạng cục bộ hoặc Loopback trong phần cài đặt trang web của trình duyệt.
/**
 * 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. Khai báo targetAddressSpace trong fetch() và Request

Khi một trang https:// yêu cầu một điểm cuối http:// trên mạng cục bộ hoặc giao diện lặp lại, hãy chỉ định targetAddressSpace: 'local' hoặc targetAddressSpace: 'loopback' trong các tuỳ chọn fetch() hoặc Request.

Việc đặt rõ ràng targetAddressSpace sẽ thực hiện hai việc quan trọng:

  1. Miễn kiểm tra nội dung hỗn hợp: Quy trình kiểm tra nội dung hỗn hợp chạy trước khi phân giải DNS. Việc khai báo targetAddressSpace: 'local' (hoặc 'loopback') sẽ thông báo cho trình duyệt trước khi phân giải DNS rằng một URL http:// (bao gồm cả tên máy chủ DNS tuỳ chỉnh phân giải thành IP cục bộ) nhắm đến một điểm cuối cục bộ/vòng lặp và sẽ được miễn chặn Nội dung hỗn hợp sau khi người dùng cấp quyền.
  2. Xác minh không gian địa chỉ: Sau khi phân giải DNS, trình duyệt sẽ xác minh rằng địa chỉ IP được phân giải thực sự thuộc về targetAddressSpace đã khai báo. Nếu một tên máy chủ có targetAddressSpace: 'local' bất ngờ phân giải thành địa chỉ IP public, thì trình duyệt sẽ ngay lập tức huỷ yêu cầu kèm theo lỗi mạng 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. Kết nối theo thời gian thực với WebSocket và WebTransport

Các quy định hạn chế về Quyền truy cập mạng cục bộ cũng chi phối các kết nối WebSocket và WebTransport:

  • WebSocket: Hàm khởi tạo WebSocket(url, protocols) chỉ chấp nhận một chuỗi hoặc mảng giao thức phụ làm đối số thứ hai. Đừng truyền đối tượng tuỳ chọn { targetAddressSpace }, vì việc này sẽ gửi một SyntaxError. Thay vào đó, trình duyệt sẽ kiểm tra URL WebSocket trước khi phân giải DNS: Các kết nối ws:// được bắt đầu từ một trang https:// sẽ được miễn chặn Nội dung hỗn hợp khi máy chủ lưu trữ URL có thể nhận dạng được về mặt cú pháp là cục bộ hoặc lặp lại – cụ thể là một giá trị cố định IP riêng tư hoặc lặp lại (ws://192.168.1.100:8080, ws://127.0.0.1:8080, ws://[::1]:8080), localhost (*.localhost) hoặc tên máy chủ mDNS .local (ws://device.local:8080) – và được kiểm soát sau lời nhắc cấp quyền local-network hoặc loopback-network. Nếu một điểm cuối cục bộ được xác định địa chỉ thông qua tên máy chủ DNS không phải .local tuỳ chỉnh, hãy kết nối qua wss://.
  • WebTransport: Vì WebTransport yêu cầu nghiêm ngặt https:// (HTTP/3 qua QUIC bằng TLS hoặc serverCertificateHashes cho chứng chỉ tự ký cục bộ), nên nó không bao giờ kích hoạt các khối Nội dung hỗn hợp và không chấp nhận tuỳ chọn targetAddressSpace. Thay vào đó, trình duyệt sẽ tự động xác minh không gian địa chỉ IP đã phân giải và kiểm soát lời hứa transport.ready sau lời nhắc cấp quyền local-network hoặc loopback-network.
/**
 * 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. Uỷ quyền truy cập vào iframe khác nguồn gốc thông qua Permissions-Policy

Theo mặc định, local-network và loopback-network có danh sách cho phép mặc định là 'self', chặn các nội dung nhúng <iframe> khác nguồn gốc gửi yêu cầu cục bộ hoặc yêu cầu lặp lại. Để cho phép một <iframe> khác nguồn gốc được nhúng (chẳng hạn như tiện ích thiết lập phần cứng hoặc khung SSO giao tiếp với tác nhân cục bộ) bắt đầu các yêu cầu cục bộ hoặc yêu cầu lặp lại, hãy uỷ quyền rõ ràng các quyền chi tiết trong thuộc tính allow và/hoặc tiêu đề 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>

Gửi tiêu đề Permissions-Policy trong phản hồi của tài liệu cấp cao nhất (nhúng), chứ không phải trong phản hồi của iframe:

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

Chiến lược dự phòng

Nếu mục tiêu Baseline của bạn không hỗ trợ quyền Truy cập vào mạng cục bộ và các tuỳ chọn targetAddressSpace, hãy triển khai tính năng phát hiện tính năng bằng tính năng dự phòng tăng dần:

  1. Dự phòng truy vấn quyền: Gói navigator.permissions.query({ name: permissionName }) trong try / catch. Nếu trình duyệt gửi TypeError vì 'local-network' hoặc 'loopback-network' là PermissionName không nhận dạng được, hãy coi trạng thái là 'prompt' để yêu cầu vẫn chạy khi người dùng nhấp rõ ràng (bản thân nỗ lực kết nối sẽ kích hoạt lời nhắc của trình duyệt khi LNA được hỗ trợ).
  2. KHÔNG truy vấn tên quyền kết hợp cũ { name: 'local-network-access' }. Trong các phiên bản Chrome cũ, truy vấn này làm hỏng quy trình kết xuất và try / catch không thể ngăn chặn điều đó. Các phiên bản Chrome mới hơn chỉ coi 'local-network-access' là một bí danh cũ cho các quyền chi tiết.
  3. Xử lý từ chối linh hoạt: Luôn gói các sự kiện lỗi fetch(), WebSocket và transport.ready trong trình xử lý lỗi để phát hiện lỗi TypeError/lỗi kết nối và đưa ra các bước khắc phục rõ ràng (kiểm tra để đảm bảo thiết bị cục bộ đã bật nguồn, kết nối với cùng một mạng và được cho phép trong quyền của trình duyệt).
/**
 * 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';
  }
}