Когда веб-приложение, размещенное в источнике 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 191810.0.0.0/8,172.16.0.0/12,192.168.0.0/16, локальные адреса канала169.254.0.0/16/fe80::/10, уникальные локальные адреса IPv6fc00::/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 выполняет две важные функции:
- Исключение смешанного контента. Проверка смешанного контента выполняется до DNS-преобразования. Объявление
targetAddressSpace: 'local'(или'loopback') сообщает браузеру до DNS-преобразования, что URL-адресhttp://(включая собственное имя хоста DNS, которое разрешается в локальный IP-адрес) указывает на локальную конечную точку или конечную точку обратной связи и должен быть исключен из блокировки смешанного контента, как только пользователь предоставит разрешение. - Проверка адресного пространства. После 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, реализуйте обнаружение функций с прогрессивным резервным вариантом.
- Резервный запрос разрешения. Оберните
navigator.permissions.query({ name: permissionName })вtry / catch. Если браузер выдает ошибкуTypeError, потому что'local-network'или'loopback-network'– это нераспознанныйPermissionName, считайте, что статус –'prompt', чтобы запрос по-прежнему выполнялся по явному клику пользователя (сама попытка подключения вызывает запрос браузера, если поддерживается LNA). - НЕ запрашивайте устаревшее комбинированное разрешение
{ name: 'local-network-access' }. В более старых версиях Chrome этот запрос приводит к сбою процесса визуализации, иtry / catchне может это предотвратить. В более новых версиях Chrome'local-network-access'считается устаревшим псевдонимом для детализированных разрешений. - Корректная обработка отказов. Всегда оборачивайте
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';
}
}