Cuando una aplicación web que se entrega desde un origen public (o un origen de intranet local) se conecta a la red local privada de un usuario (como una impresora LAN, un concentrador de IoT o un router en 192.168.1.x o device.local) o a un servicio complementario local que se ejecuta en la máquina del usuario (127.0.0.1, [::1] o localhost), los navegadores bloquean la conexión detrás de un mensaje de permiso de acceso a la red local (LNA).
El Acceso a la red local divide el acceso en dos permisos detallados y tokens de Permissions-Policy según la cuadrícula del espacio de direcciones IP (public → local → loopback), lo que aplica verificaciones solo cuando una solicitud pasa de un espacio de direcciones menos privado a uno más privado:
local-network: Es obligatorio cuando se establece una conexión desde un origenpublical espacio de direccioneslocal(rangos de IPv4 privados RFC 191810.0.0.0/8,172.16.0.0/12,192.168.0.0/16, vínculo local169.254.0.0/16/fe80::/10, direcciones locales únicas IPv6fc00::/7y nombres de host mDNS.local).loopback-network: Es obligatorio cuando se establece una conexión desde un origenpublicolocalal espacio de direccionesloopback(127.0.0.0/8,::1/128olocalhost).
Como localhost (127.0.0.1 y [::1]) ya se encuentra en el espacio de direcciones más privado (loopback), entregar tu app web desde http://localhost durante el desarrollo local no activará las verificaciones de LNA de forma predeterminada. Para probar las solicitudes de LNA y los estados de permisos de forma local sin implementar en un servidor público, anula el espacio de direcciones IPv4 e IPv6 de tu servidor de desarrollo local a public con la marca --ip-address-space-overrides=127.0.0.1:8080=public,[::1]:8080=public de Chromium o activa o desactiva manualmente Red local y Red de bucle invertido entre Ask, Allow y Block en la Configuración del sitio del navegador.
OBLIGATORIO: Entrega cualquier aplicación web que inicie solicitudes de red locales o de bucle invertido desde un contexto seguro (https://). Los permisos de acceso a la red local se deniegan automáticamente en contextos de nivel superior no seguros (http://).
NO dependas de los encabezados de solicitud preliminar de acceso a redes privadas (PNA) obsoletos (Access-Control-Request-Private-Network: true/Access-Control-Allow-Private-Network: true). El acceso a la red local aplica el acceso a través de mensajes de permiso del usuario del cliente (local-network y loopback-network) combinados con encabezados de CORS estándar (Access-Control-Allow-Origin) cuando se leen respuestas de origen cruzado. Las comprobaciones preliminares de PNA nunca se aplicaron como un requisito de bloqueo y no son necesarias como resguardo. Los servidores existentes o el firmware del dispositivo que ya devuelven Access-Control-Allow-Private-Network: true pueden seguir haciéndolo sin problemas, pero no agregues el manejo de PNA al código nuevo.
Pasos de implementación
1. Verifica previamente el estado de los permisos detallados y controla el gesto del usuario
Nunca actives solicitudes de red local o de bucle invertido de forma incondicional cuando se cargue la página (DOMContentLoaded) si el estado del permiso es 'prompt', y nunca inicies la primera solicitud que active el permiso desde un Service Worker o un Shared Worker (que no tienen un Document adjunto y fallarán de inmediato si el permiso aún no es 'granted').
En su lugar, consulta navigator.permissions.query() para 'local-network' o 'loopback-network' primero:
- Si es
state === 'granted', se conecta automáticamente. - Si es
state === 'prompt', muestra una IU clara que explique por qué la app necesita acceso al dispositivo local o a la app complementaria, y activa la solicitud desde un gesto explícito del usuario (como hacer clic en un botón). - Si es
state === 'denied', renderiza una IU de corrección que se pueda usar para indicarle al usuario cómo volver a habilitar el acceso a la red local o al bucle invertido en la configuración del sitio del navegador.
/**
* 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. Declara targetAddressSpace en fetch() y Request
Cuando una página https:// solicita un extremo http:// en la red local o la interfaz de bucle invertido, especifica targetAddressSpace: 'local' o targetAddressSpace: 'loopback' en las opciones fetch() o Request.
Configurar targetAddressSpace de forma explícita hace dos cosas fundamentales:
- Exención de contenido mixto: Las verificaciones de contenido mixto se ejecutan antes de la resolución de DNS. Declarar
targetAddressSpace: 'local'(o'loopback') informa al navegador antes de la resolución de DNS que una URLhttp://(incluido un nombre de host de DNS personalizado que se resuelve en una IP local) se orienta a un extremo local o de bucle invertido y se debe eximir del bloqueo de contenido mixto una vez que el usuario otorgue el permiso. - Verificación del espacio de direcciones: Después de la resolución de DNS, el navegador verifica que la dirección IP resuelta pertenezca realmente al
targetAddressSpacedeclarado. Si un nombre de host contargetAddressSpace: 'local'se resuelve de forma inesperada en una dirección IPpublic, el navegador anula de inmediato la solicitud con un error de redTypeError.
/**
* 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. Conexiones en tiempo real con WebSocket y WebTransport
Las restricciones de acceso a la red local también rigen las conexiones de WebSocket y WebTransport:
WebSocket: El constructorWebSocket(url, protocols)solo acepta una cadena o un array de subprotocolo como segundo argumento. No pases un objeto de opciones{ targetAddressSpace }, ya que arroja unSyntaxError. En cambio, el navegador inspecciona la URL deWebSocketantes de la resolución de DNS: las conexiones dews://iniciadas desde una página dehttps://están exentas del bloqueo de contenido mixto cuando el host de la URL se puede reconocer sintácticamente como local o de bucle invertido, específicamente un literal de IP privado o de bucle invertido (ws://192.168.1.100:8080,ws://127.0.0.1:8080,ws://[::1]:8080),localhost(*.localhost) o un nombre de host de mDNS de.local(ws://device.local:8080), y se controlan detrás de la solicitud de permiso delocal-networkoloopback-network. Si se accede a un extremo local a través de un nombre de host de DNS personalizado que no sea.local, conéctate a través dewss://.WebTransport: Debido a queWebTransportrequiere estrictamentehttps://(HTTP/3 sobre QUIC con TLS oserverCertificateHashespara certificados locales autofirmados), nunca activa bloqueos de contenido mixto y no acepta una opcióntargetAddressSpace. En su lugar, el navegador verifica automáticamente el espacio de direcciones IP resuelto y controla la promesa detransport.readydetrás del mensaje de permiso delocal-networkoloopback-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. Delega el acceso a iframes de origen cruzado a través de Permissions-Policy
De forma predeterminada, local-network y loopback-network tienen una lista de entidades permitidas predeterminada de 'self', lo que impide que las incorporaciones de <iframe> de origen cruzado realicen solicitudes locales o de bucle invertido. Para permitir que un <iframe> de origen cruzado incorporado (como un widget de configuración de hardware o un marco de SSO que se comunica con un agente local) inicie solicitudes locales o de bucle invertido, delega de forma explícita los permisos detallados en el atributo allow o el encabezado 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>
Envía el encabezado Permissions-Policy en la respuesta del documento de nivel superior (incorporado), no en la respuesta del iframe:
Permissions-Policy: local-network=(self "https://setup.partner.example.com"), loopback-network=(self "https://setup.partner.example.com")
Estrategias de resguardo
Si tu objetivo de Baseline no admite los permisos de acceso a la red local ni las opciones de targetAddressSpace, implementa la detección de funciones con resguardo progresivo:
- Resguardo de consulta de permisos: Une
navigator.permissions.query({ name: permissionName })en untry / catch. Si el navegador arroja unTypeErrorporque'local-network'o'loopback-network'es unPermissionNameno reconocido, trata el estado como'prompt'para que la solicitud se siga ejecutando con un clic explícito del usuario (el intento de conexión en sí activa el mensaje del navegador donde se admite la LNA). - NO consultes el nombre del permiso combinado heredado
{ name: 'local-network-access' }. En versiones anteriores de Chrome, esta consulta hace que falle el proceso de renderización, ytry / catchno puede evitarlo. Las versiones más recientes de Chrome tratan a'local-network-access'solo como un alias heredado para los permisos detallados. - Manejo correcto de rechazos: Siempre une
fetch(), los eventos de error deWebSocketytransport.readyen controladores de errores que detecten fallas de conexión o deTypeErrory presenten pasos claros para solucionar el problema (verificar que el dispositivo local esté encendido, conectado a la misma red y permitido en los permisos del navegador).
/**
* 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';
}
}