Als een web-app die wordt geleverd vanuit een public-oorsprong (of een intranet-local-oorsprong) verbinding maakt met het privé lokale netwerk van een gebruiker (zoals een LAN-printer, IoT-hub of router op 192.168.1.x of device.local) of met een lokale begeleidende service die wordt uitgevoerd op de machine van de gebruiker (127.0.0.1, [::1] of localhost), plaatsen browsers de verbinding achter een prompt voor rechten voor lokale netwerktoegang (Local Network Access, LNA).
Lokale netwerktoegang splitst de toegang op in 2 gedetailleerde rechten en Permissions-Policy tokens op basis van de IP-adresruimtelattice (public → local → loopback). Controles worden alleen afgedwongen als een verzoek van een minder privé adresruimte naar een meer privé adresruimte gaat:
local-network: Vereist bij verbinding vanuit eenpublic-oorsprong met delocal-adresruimte (RFC 1918 privé IPv4-bereiken10.0.0.0/8,172.16.0.0/12,192.168.0.0/16, link-local169.254.0.0/16/fe80::/10, IPv6 Unique Local Addressesfc00::/7en.localmDNS-hostnamen).loopback-network: Vereist als u verbinding maakt vanuit eenpublic- oflocal-oorsprong met deloopback-adresruimte (127.0.0.0/8,::1/128oflocalhost).
Omdat localhost (127.0.0.1 en [::1]) zich al in de meest privé (loopback) adresruimte bevindt, worden er standaard geen LNA-controles geactiveerd als je je web-app weergeeft vanuit http://localhost tijdens lokale ontwikkeling. Als je LNA-prompts en rechtenstatussen lokaal wilt testen zonder te implementeren op een openbare server, overschrijf je de IPv4- en IPv6-adresruimte van je lokale dev-server naar public met de Chromium-vlag --ip-address-space-overrides=127.0.0.1:8080=public,[::1]:8080=public of schakel je handmatig tussen Lokaal netwerk en Loopback-netwerk tussen Ask, Allow en Block in de Site-instellingen van de browser.
VERPLICHT: Lever elke web-app die lokale of loopback-netwerkverzoeken start vanuit een beveiligde context (https://). Rechten voor lokale netwerktoegang worden automatisch geweigerd in onbeveiligde (http://) contexten op het hoogste niveau.
Vertrouw NIET op beëindigde pre-flightheaders voor toegang tot privénetwerken (PNA) (Access-Control-Request-Private-Network: true / Access-Control-Allow-Private-Network: true). Lokale netwerktoegang dwingt toegang af via prompts voor gebruikerstoestemming aan de clientzijde (local-network en loopback-network) in combinatie met standaard CORS-headers (Access-Control-Allow-Origin) bij het lezen van cross-origin-reacties. PNA-preflights zijn nooit afgedwongen als blokkeringsvereiste en zijn niet nodig als fallback. Bestaande servers of apparaatfirmware die al Access-Control-Allow-Private-Network: true retourneren, kunnen dit zonder problemen blijven doen, maar voeg geen PNA-verwerking toe aan nieuwe code.
Stapsgewijze implementatie
1. Gedetailleerde rechtenstatus vooraf controleren en blokkeren bij gebruikersactivatie
Activeer nooit onvoorwaardelijk lokale netwerk- of loopback-verzoeken bij het laden van de pagina (DOMContentLoaded) als de rechtenstatus 'prompt' is en start nooit het eerste verzoek dat rechten activeert vanuit een Service Worker of Shared Worker (die geen gekoppelde Document hebben en onmiddellijk mislukken als de rechten niet al 'granted' zijn).
Voer in plaats daarvan eerst een query uit op navigator.permissions.query() voor 'local-network' of 'loopback-network':
- Als
state === 'granted', maak dan automatisch verbinding. - Als
state === 'prompt', toon je een duidelijke UI waarin wordt uitgelegd waarom de app toegang nodig heeft tot het lokale apparaat of de bijbehorende app en activeer je het verzoek via een expliciete gebruikersactivatie (zoals een klik op een knop). - Als
state === 'denied', geef je een UI voor herstelacties weer waarin de gebruiker wordt uitgelegd hoe die de toegang tot het lokale netwerk of loopback opnieuw aanzet in de site-instellingen van de browser.
/**
* 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 aangeven in fetch() en Request
Als een https://-pagina een http://-eindpunt aanvraagt op het lokale netwerk of de loopback-interface, voer je targetAddressSpace: 'local' of targetAddressSpace: 'loopback' in bij de opties fetch() of Request.
Als u targetAddressSpace expliciet instelt, gebeuren er 2 belangrijke dingen:
- Uitzondering voor gemengde content: Controles op gemengde content worden vóór DNS-omzetting uitgevoerd. Als je
targetAddressSpace: 'local'(of'loopback') declareert, laat je de browser vóór de DNS-omzetting weten dat eenhttp://-URL (waaronder een aangepaste DNS-hostnaam die wordt omgezet in een lokaal IP-adres) een lokaal/loopback-eindpunt target en moet worden vrijgesteld van de blokkering van gemengde content nadat de gebruiker toestemming heeft gegeven. - Verificatie van adresruimte: Na de DNS-omzetting verifieert de browser of het omgezette IP-adres daadwerkelijk bij de gedeclareerde
targetAddressSpacehoort. Als een hostnaam mettargetAddressSpace: 'local'onverwacht wordt omgezet in eenpublicIP-adres, breekt de browser het verzoek onmiddellijk af met eenTypeErrornetwerkfout.
/**
* 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. Realtime verbindingen met WebSocket en WebTransport
Beperkingen voor toegang tot lokaal netwerk gelden ook voor WebSocket- en WebTransport-verbindingen:
WebSocket: DeWebSocket(url, protocols)-constructor accepteert alleen een subprotocoltekenreeks of -matrix als tweede argument. Geef geen{ targetAddressSpace }-optieobject door, omdat dit eenSyntaxErrorveroorzaakt. In plaats daarvan inspecteert de browser deWebSocket-URL vóór de DNS-omzetting:ws://-verbindingen die worden gestart vanaf eenhttps://-pagina, zijn vrijgesteld van de blokkering van gemengde content als de URL-host syntactisch herkenbaar is als lokaal of loopback, met name een letterlijk privé- of loopback-IP-adres (ws://192.168.1.100:8080,ws://127.0.0.1:8080,ws://[::1]:8080),localhost(*.localhost) of een.localmDNS-hostnaam (ws://device.local:8080), en worden afgeschermd achter delocal-network- ofloopback-network-toestemmingsprompt. Als een lokaal eindpunt wordt geadresseerd via een aangepaste niet-.localDNS-hostnaam, maak je verbinding viawss://.WebTransport: OmdatWebTransportstrikthttps://vereist (HTTP/3 via QUIC met TLS ofserverCertificateHashesvoor lokale zelfondertekende certificaten), worden er nooit blokkeringen voor gemengde content geactiveerd en wordt de optietargetAddressSpaceniet geaccepteerd. In plaats daarvan verifieert de browser automatisch de opgeloste IP-adresruimte en blokkeert detransport.ready-promise achter de prompt voorlocal-network- ofloopback-network-rechten.
/**
* 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. Toegang machtigen tot cross-origin iframes via Permissions-Policy
Standaard hebben local-network en loopback-network een standaard toelatingslijst van 'self', waardoor cross-origin <iframe>-insluitingen geen lokale of loopback-verzoeken kunnen doen. Als je een ingesloten cross-origin <iframe> (zoals een widget voor hardware-instellingen of een SSO-frame dat communiceert met een lokale agent) wilt toestaan om lokale of loopback-verzoeken te starten, delegeer je de gedetailleerde rechten expliciet in het kenmerk allow en/of de HTTP-header 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>
Stuur de Permissions-Policy-header in de reactie van het document op het hoogste niveau (insluiting), niet in de reactie van het iframe:
Permissions-Policy: local-network=(self "https://setup.partner.example.com"), loopback-network=(self "https://setup.partner.example.com")
Reservestrategieën
Als je Baseline-doel geen rechten voor lokale netwerktoegang en targetAddressSpace-opties ondersteunt, implementeer je functiedetectie met progressieve fallback:
- Permission Query Fallback: Verpak
navigator.permissions.query({ name: permissionName })in eentry / catch. Als de browser eenTypeErrorgenereert omdat'local-network'of'loopback-network'een niet-herkendePermissionNameis, behandel je de status als'prompt', zodat het verzoek nog steeds wordt uitgevoerd bij een expliciete gebruikersklik (de verbindingspoging zelf activeert de browserprompt waar LNA wordt ondersteund). - Voer GEEN query uit voor de verouderde gecombineerde rechtennaam
{ name: 'local-network-access' }. In oudere Chrome-versies crasht deze query het weergaveproces en kantry / catchdit niet voorkomen. Nieuwere Chrome-versies behandelen'local-network-access'alleen als een verouderde alias voor de gedetailleerde rechten. - Correcte afhandeling van afwijzingen: Verpak
fetch(),WebSocket-foutgebeurtenissen entransport.readyaltijd in fouthandlers dieTypeError/verbindingsfouten opvangen en duidelijke herstelstappen bieden (controleren of het lokale apparaat aanstaat, verbonden is met hetzelfde netwerk en is toegestaan in de browserrechten).
/**
* 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';
}
}