Acceso a la red local

Funciones web: Acceso a la red local

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 origen public al espacio de direcciones local (rangos de IPv4 privados RFC 1918 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, vínculo local 169.254.0.0/16/fe80::/10, direcciones locales únicas IPv6 fc00::/7 y nombres de host mDNS .local).
  • loopback-network: Es obligatorio cuando se establece una conexión desde un origen public o local al espacio de direcciones loopback (127.0.0.0/8, ::1/128 o localhost).

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:

  1. 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 URL http:// (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.
  2. 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 targetAddressSpace declarado. Si un nombre de host con targetAddressSpace: 'local' se resuelve de forma inesperada en una dirección IP public, el navegador anula de inmediato la solicitud con un error de red 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. 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 constructor WebSocket(url, protocols) solo acepta una cadena o un array de subprotocolo como segundo argumento. No pases un objeto de opciones { targetAddressSpace }, ya que arroja un SyntaxError. En cambio, el navegador inspecciona la URL de WebSocket antes de la resolución de DNS: las conexiones de ws:// iniciadas desde una página de https:// 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 de local-network o loopback-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 de wss://.
  • WebTransport: Debido a que WebTransport requiere estrictamente https:// (HTTP/3 sobre QUIC con TLS o serverCertificateHashes para certificados locales autofirmados), nunca activa bloqueos de contenido mixto y no acepta una opción targetAddressSpace. En su lugar, el navegador verifica automáticamente el espacio de direcciones IP resuelto y controla la promesa de transport.ready detrás del mensaje de permiso de local-network o 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. 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:

  1. Resguardo de consulta de permisos: Une navigator.permissions.query({ name: permissionName }) en un try / catch. Si el navegador arroja un TypeError porque 'local-network' o 'loopback-network' es un PermissionName no 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).
  2. 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, y try / catch no puede evitarlo. Las versiones más recientes de Chrome tratan a 'local-network-access' solo como un alias heredado para los permisos detallados.
  3. Manejo correcto de rechazos: Siempre une fetch(), los eventos de error de WebSocket y transport.ready en controladores de errores que detecten fallas de conexión o de TypeError y 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';
  }
}