Доступ к локальной сети

Когда веб-приложение, размещенное в источнике public (или источнике интрасети local), подключается к частной локальной сети пользователя (например, к принтеру LAN, концентратору IoT или маршрутизатору по адресу 192.168.1.x или device.local) или к локальному сопутствующему сервису, запущенному на компьютере пользователя (127.0.0.1, [::1] или localhost), браузеры запрашивают разрешение на доступ к локальной сети (LNA).

Доступ к локальной сети разделяет доступ на два детализированных разрешения и Permissions-Policy токена на основе решетки пространства IP-адресов (public → local → loopback), обеспечивая проверку только тогда, когда запрос переходит из менее частного адресного пространства в более частное:

  • local-network – требуется при подключении из источника public к адресному пространству local (диапазоны частных адресов IPv4 RFC 1918 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 и имена хостов mDNS .local).
  • loopback-network – требуется при подключении из источника public или local к адресному пространству loopback (127.0.0.0/8, ::1/128 или localhost).

Поскольку localhost (127.0.0.1 и [::1]) уже находится в наиболее частном (loopback) адресном пространстве, обслуживание вашего веб-приложения из http://localhost во время локальной разработки по умолчанию не будет запускать проверки LNA. Чтобы протестировать запросы и статусы разрешений LNA локально, не развертывая их на общедоступном сервере, переопределите адресное пространство IPv4 и IPv6 локального сервера разработки на public с помощью флага Chromium --ip-address-space-overrides=127.0.0.1:8080=public,[::1]:8080=public или вручную переключите Локальная сеть и loopback-интерфейс между Ask, Allow и Block в настройках сайта в браузере.

ОБЯЗАТЕЛЬНО. Размещайте все веб-приложения, которые инициируют сетевые запросы к локальной сети или петлевому интерфейсу, в безопасном контексте (https://). В небезопасных контекстах верхнего уровня (http://) разрешения на доступ к локальной сети отклоняются автоматически.

НЕ полагайтесь на устаревшие заголовки предварительных запросов доступа к частной сети (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. Предварительная проверка состояния детализированного разрешения и ограничение на жест пользователя

Никогда не отправляйте запросы к локальной сети или loopback-адресам безусловно при загрузке страницы (DOMContentLoaded), если разрешение имеет статус 'prompt', и никогда не инициируйте первый запрос, требующий разрешения, из Service Worker или Shared Worker (у них нет прикрепленного Document, и они сразу же завершатся ошибкой, если разрешение ещё не имеет статус 'granted').

Вместо этого сначала запросите navigator.permissions.query() для 'local-network' или 'loopback-network':

  • Если state === 'granted', подключаться автоматически.
  • Если state === 'prompt', покажите в интерфейсе понятное объяснение, зачем приложению нужен доступ к локальному устройству или сопутствующему приложению, и активируйте запрос явным жестом пользователя (например, нажатием кнопки).
  • Если state === 'denied', покажите интерфейс с инструкциями о том, как снова включить доступ к локальной сети или loopback-интерфейсу в настройках сайта в браузере.
/**
 * 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. Заявить targetAddressSpace в fetch() и Request

Если страница https:// запрашивает конечную точку http:// в локальной сети или интерфейсе обратной связи, укажите targetAddressSpace: 'local' или targetAddressSpace: 'loopback' в параметрах fetch() или Request.

Явное указание targetAddressSpace выполняет две важные функции:

  1. Исключение смешанного контента. Проверка смешанного контента выполняется до DNS-преобразования. Объявление targetAddressSpace: 'local' (или 'loopback') сообщает браузеру до DNS-преобразования, что URL-адрес http:// (включая собственное имя хоста DNS, которое разрешается в локальный IP-адрес) указывает на локальную конечную точку или конечную точку обратной связи и должен быть исключен из блокировки смешанного контента, как только пользователь предоставит разрешение.
  2. Проверка адресного пространства. После DNS-преобразования браузер проверяет, действительно ли полученный IP-адрес принадлежит заявленному targetAddressSpace. Если имя хоста с targetAddressSpace: 'local' неожиданно разрешается в IP-адрес public, браузер немедленно прерывает запрос с ошибкой сети 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. Вместо этого браузер проверяет URL-адрес WebSocket перед DNS-преобразованием: ws://-подключения, инициированные со страницы https://, не блокируются как смешанный контент, если хост URL-адреса синтаксически распознается как локальный или loopback, в частности частный или loopback IP-литерал (ws://192.168.1.100:8080, ws://127.0.0.1:8080, ws://[::1]:8080), localhost (*.localhost) или имя хоста mDNS .local (ws://device.local:8080), и запрашивается разрешение local-network или loopback-network. Если локальная конечная точка адресуется через пользовательское имя хоста DNS, отличное от .local, подключитесь через wss://.
  • WebTransport. Поскольку для WebTransport строго требуется https:// (HTTP/3 через QUIC с TLS или serverCertificateHashes для локальных самозаверяющих сертификатов), он никогда не активирует блокировку смешанного контента и не принимает параметр targetAddressSpace. Вместо этого браузер автоматически проверяет разрешенное пространство IP-адресов и блокирует обещание transport.ready за запросом разрешения local-network или 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. Делегирование доступа к окнам iframe из разных источников через Permissions-Policy

По умолчанию для local-network и loopback-network используется белый список 'self', который запрещает встроенным элементам <iframe> из разных источников отправлять локальные запросы или запросы loopback. Чтобы разрешить встроенному перекрестному элементу <iframe> (например, виджету настройки оборудования или фрейму SSO, который взаимодействует с локальным агентом) инициировать локальные запросы или запросы loopback, явно делегируйте детализированные разрешения в атрибуте 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")

Резервные стратегии

Если целевая версия Baseline не поддерживает разрешения на доступ к локальной сети и параметры targetAddressSpace, реализуйте обнаружение функций с прогрессивным резервным вариантом.

  1. Резервный запрос разрешения. Оберните navigator.permissions.query({ name: permissionName }) в try / catch. Если браузер выдает ошибку TypeError, потому что 'local-network' или 'loopback-network' – это нераспознанный PermissionName, считайте, что статус – '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';
  }
}