Accès au réseau local

Fonctionnalités Web : Accès au réseau local

Lorsqu'une application Web diffusée depuis une origine public (ou une origine intranet local) se connecte au réseau local privé d'un utilisateur (comme une imprimante LAN, un hub IoT ou un routeur sur 192.168.1.x ou device.local) ou à un service associé local exécuté sur l'ordinateur de l'utilisateur (127.0.0.1, [::1] ou localhost), les navigateurs bloquent la connexion derrière une invite d'autorisation d'accès au réseau local (LNA).

L'accès au réseau local divise l'accès en deux autorisations précises et en jetons Permissions-Policy basés sur le réseau d'espace d'adresses IP (public → local → loopback), en appliquant des vérifications uniquement lorsqu'une requête passe d'un espace d'adresses moins privé à un espace d'adresses plus privé :

  • local-network : obligatoire lors de la connexion d'une origine public à l'espace d'adressage local (plages IPv4 privées RFC 1918 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, lien local 169.254.0.0/16 / fe80::/10, adresses locales uniques IPv6 fc00::/7 et noms d'hôte mDNS .local).
  • loopback-network : obligatoire lors de la connexion d'une origine public ou local à l'espace d'adressage loopback (127.0.0.0/8, ::1/128 ou localhost).

Étant donné que localhost (127.0.0.1 et [::1]) se trouve déjà dans l'espace d'adressage le plus privé (loopback), diffuser votre application Web à partir de http://localhost pendant le développement local ne déclenchera pas de vérifications LNA par défaut. Pour tester les invites et les états d'autorisation LNA localement sans déploiement sur un serveur public, remplacez l'espace d'adressage IPv4 et IPv6 de votre serveur de développement local par public à l'aide du flag Chromium --ip-address-space-overrides=127.0.0.1:8080=public,[::1]:8080=public, ou activez manuellement Réseau local et Réseau de bouclage entre Ask, Allow et Block dans les paramètres du site du navigateur.

OBLIGATOIRE : Diffusez toute application Web qui lance des requêtes réseau locales ou de bouclage à partir d'un contexte sécurisé (https://). Les autorisations d'accès au réseau local sont automatiquement refusées dans les contextes de premier niveau non sécurisés (http://).

NE VOUS FIEZ PAS aux en-têtes de requête préliminaire d'accès au réseau privé (PNA) obsolètes (Access-Control-Request-Private-Network: true/Access-Control-Allow-Private-Network: true). L'accès au réseau local applique l'accès via des invites d'autorisation de l'utilisateur côté client (local-network et loopback-network) combinées à des en-têtes CORS standards (Access-Control-Allow-Origin) lors de la lecture des réponses interorigines. Les vérifications préliminaires PNA n'ont jamais été appliquées en tant qu'exigence de blocage et ne sont pas nécessaires en tant que solution de secours. Les serveurs ou micrologiciels d'appareils existants qui renvoient déjà Access-Control-Allow-Private-Network: true peuvent continuer à le faire sans problème, mais n'ajoutez pas de gestion de la PNA au nouveau code.

Mise en œuvre, étape par étape

1. Vérifier l'état des autorisations précises et de la porte d'accès au geste de l'utilisateur

Ne déclenchez jamais de requêtes de réseau local ou de rebouclage de manière inconditionnelle au chargement de page (DOMContentLoaded) lorsque l'état de l'autorisation est 'prompt', et ne lancez jamais la première requête déclenchant l'autorisation à partir d'un Service Worker ou d'un Shared Worker (qui n'ont pas de Document associé et échoueront immédiatement si l'autorisation n'est pas déjà 'granted').

Interrogez plutôt navigator.permissions.query() pour 'local-network' ou 'loopback-network' d'abord :

  • Si state === 'granted', se connecter automatiquement.
  • Si state === 'prompt', affichez une UI claire expliquant pourquoi l'application doit accéder à l'appareil local ou à l'application associée, et déclenchez la demande à partir d'un geste explicite de l'utilisateur (par exemple, un clic sur un bouton).
  • Si la valeur est state === 'denied', affichez une UI de correction exploitable indiquant à l'utilisateur comment réactiver l'accès au réseau local ou au bouclage dans les paramètres du site du navigateur.
/**
 * 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. Déclarer targetAddressSpace dans fetch() et Request

Lorsqu'une page https:// demande un point de terminaison http:// sur le réseau local ou l'interface de rebouclage, spécifiez targetAddressSpace: 'local' ou targetAddressSpace: 'loopback' dans les options fetch() ou Request.

La définition explicite de targetAddressSpace a deux conséquences importantes :

  1. Exemption de contenu mixte : les vérifications de contenu mixte sont exécutées avant la résolution DNS. La déclaration de targetAddressSpace: 'local' (ou 'loopback') informe le navigateur avant la résolution DNS qu'une URL http:// (y compris un nom d'hôte DNS personnalisé qui se résout en une adresse IP locale) cible un point de terminaison local/de bouclage et doit être exemptée du blocage du contenu mixte une fois que l'utilisateur a accordé l'autorisation.
  2. Validation de l'espace d'adressage : après la résolution DNS, le navigateur vérifie que l'adresse IP résolue appartient bien au targetAddressSpace déclaré. Si un nom d'hôte avec targetAddressSpace: 'local' est résolu de manière inattendue en une adresse IP public, le navigateur annule immédiatement la requête avec une erreur réseau 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. Connexions en temps réel avec WebSocket et WebTransport

Les restrictions d'accès au réseau local régissent également les connexions WebSocket et WebTransport :

  • WebSocket : le constructeur WebSocket(url, protocols) n'accepte qu'une chaîne ou un tableau de sous-protocoles comme deuxième argument. Ne transmettez pas d'objet d'options { targetAddressSpace }, car cela génère une erreur SyntaxError. À la place, le navigateur inspecte l'URL WebSocket avant la résolution DNS : les connexions ws:// initiées à partir d'une page https:// sont exemptées du blocage du contenu mixte lorsque l'hôte de l'URL est syntaxiquement reconnaissable comme local ou de bouclage (en particulier une adresse IP littérale privée ou de bouclage (ws://192.168.1.100:8080, ws://127.0.0.1:8080, ws://[::1]:8080), localhost (*.localhost) ou un nom d'hôte mDNS .local (ws://device.local:8080)) et qu'il est protégé par l'invite d'autorisation local-network ou loopback-network. Si un point de terminaison local est adressé via un nom d'hôte DNS personnalisé non-.local, connectez-vous via wss://.
  • WebTransport : Comme WebTransport nécessite strictement https:// (HTTP/3 sur QUIC avec TLS ou serverCertificateHashes pour les certificats autosignés locaux), il ne déclenche jamais de blocage de contenu mixte et n'accepte pas d'option targetAddressSpace. À la place, le navigateur vérifie automatiquement l'espace d'adressage IP résolu et bloque la promesse transport.ready derrière l'invite d'autorisation local-network ou 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. Déléguer l'accès aux iFrames interorigines via Permissions-Policy

Par défaut, local-network et loopback-network ont une liste d'autorisation par défaut de 'self', ce qui empêche les intégrations <iframe> interorigines d'effectuer des requêtes locales ou de rebouclage. Pour autoriser un <iframe> interorigine intégré (comme un widget de configuration matérielle ou un frame SSO qui communique avec un agent local) à lancer des requêtes locales ou de bouclage, déléguez explicitement les autorisations précises dans l'attribut allow et/ou l'en-tête 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>

Envoyez l'en-tête Permissions-Policy dans la réponse du document de premier niveau (intégré), et non dans la réponse de l'iframe :

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

Stratégies de remplacement

Si votre cible Baseline n'est pas compatible avec les autorisations d'accès au réseau local et les options targetAddressSpace, implémentez la détection de fonctionnalités avec remplacement progressif :

  1. Permission Query Fallback : encapsulez navigator.permissions.query({ name: permissionName }) dans un try / catch. Si le navigateur renvoie une erreur TypeError, car 'local-network' ou 'loopback-network' est un PermissionName non reconnu, traitez l'état comme 'prompt' afin que la requête s'exécute toujours sur un clic explicite de l'utilisateur (la tentative de connexion elle-même déclenche l'invite du navigateur lorsque l'accès au réseau local est accepté).
  2. NE RECHERCHEZ PAS l'ancien nom d'autorisation combiné { name: 'local-network-access' }. Dans les anciennes versions de Chrome, cette requête fait planter le processus de rendu, et try / catch ne peut pas l'empêcher. Les versions plus récentes de Chrome traitent 'local-network-access' uniquement comme un ancien alias pour les autorisations précises.
  3. Gestion appropriée des rejets : encapsulez toujours les événements d'erreur fetch(), WebSocket et transport.ready dans des gestionnaires d'erreurs qui interceptent les échecs de connexion/TypeError et présentent des étapes de correction claires (vérifier que l'appareil local est allumé, connecté au même réseau et autorisé dans les autorisations du navigateur).
/**
 * 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';
  }
}