public オリジン(またはイントラネットの local オリジン)から提供されるウェブ アプリケーションが、ユーザーのプライベート ローカル ネットワーク(192.168.1.x または device.local の LAN プリンタ、IoT ハブ、ルーターなど)またはユーザーのマシンで実行されているローカル コンパニオン サービス(127.0.0.1、[::1]、localhost)に接続すると、ブラウザはローカル ネットワーク アクセス(LNA)権限プロンプトの背後で接続をゲートします。
ローカル ネットワーク アクセスは、IP アドレス空間のラティス(public → local → loopback)に基づいて、アクセスを 2 つのきめ細かい権限と Permissions-Policy トークンに分割し、リクエストがプライベート性の低いアドレス空間からプライベート性の高いアドレス空間に移行する場合にのみチェックを適用します。
local-network:publicオリジンからlocalアドレス空間(RFC 1918 プライベート IPv4 範囲10.0.0.0/8、172.16.0.0/12、192.168.0.0/16、リンクローカル169.254.0.0/16/fe80::/10、IPv6 一意ローカル アドレスfc00::/7、.localmDNS ホスト名)に接続する場合に必要です。loopback-network:publicまたはlocalの送信元からloopbackアドレス空間(127.0.0.0/8、::1/128、localhost)に接続する場合に必要です。
localhost(127.0.0.1 と [::1])はすでに最もプライベートな(loopback)アドレス空間にあるため、ローカル開発中に http://localhost からウェブアプリを提供しても、デフォルトでは LNA チェックはトリガーされません。公開サーバーにデプロイせずに、LNA プロンプトと権限の状態をローカルでテストするには、--ip-address-space-overrides=127.0.0.1:8080=public,[::1]:8080=public Chromium フラグを使用してローカル開発サーバーの IPv4 アドレス空間と IPv6 アドレス空間を public にオーバーライドするか、ブラウザの [サイトの設定] で [ローカル ネットワーク] と [ループバック ネットワーク] を Ask、Allow、Block の間で手動で切り替えます。
必須: ローカルまたはループバック ネットワーク リクエストを開始するウェブ アプリケーションは、セキュア コンテキスト(https://)から提供する必要があります。安全でない(http://)最上位コンテキストでは、ローカル ネットワーク アクセス権限は自動的に拒否されます。
非推奨のプライベート ネットワーク アクセス(PNA)プリフライト ヘッダー(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. きめ細かい権限の状態を事前に確認し、ユーザー ジェスチャーをゲートする
権限の状態が 'prompt' の場合、ページ読み込み(DOMContentLoaded)時にローカル ネットワーク リクエストまたはループバック リクエストを無条件に実行しないでください。また、Service Worker または Shared Worker(Document がアタッチされておらず、権限がまだ 'granted' でない場合はすぐに失敗する)から、権限をトリガーする最初のリクエストを開始しないでください。
代わりに、まず navigator.permissions.query() で 'local-network' または 'loopback-network' をクエリします。
state === 'granted'の場合は、自動的に接続します。state === 'prompt'の場合は、アプリがローカル デバイスまたはコンパニオン アプリへのアクセスを必要とする理由を説明する明確な UI を表示し、明示的なユーザー ジェスチャー(ボタンのクリックなど)からリクエストをトリガーします。state === 'denied'の場合、ブラウザのサイト設定でローカル ネットワークまたはループバック アクセスを再度有効にする方法をユーザーに指示する、操作可能な修正 UI をレンダリングします。
/**
* 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. fetch() と Request で targetAddressSpace を宣言する
https:// ページがローカル ネットワークまたはループバック インターフェースの http:// エンドポイントをリクエストする場合は、fetch() オプションまたは Request オプションで targetAddressSpace: 'local' または targetAddressSpace: 'loopback' を指定します。
targetAddressSpace を明示的に設定すると、次の 2 つの重要な処理が行われます。
- 混合コンテンツの除外: 混合コンテンツのチェックは、DNS 解決の前に実行されます。
targetAddressSpace: 'local'(または'loopback')を宣言すると、DNS の解決の前に、http://URL(ローカル IP に解決されるカスタム DNS ホスト名を含む)がローカル / ループバック エンドポイントをターゲットとしていること、およびユーザーが権限を付与した後は混合コンテンツのブロックから除外する必要があることがブラウザに通知されます。 - アドレス空間の検証: DNS 解決後、ブラウザは解決された IP アドレスが実際に宣言された
targetAddressSpaceに属していることを確認します。targetAddressSpace: 'local'を含むホスト名が予期せずpublicIP アドレスに解決された場合、ブラウザは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)コンストラクタは、2 番目の引数としてサブプロトコル文字列または配列のみを受け入れます。{ targetAddressSpace }オプション オブジェクトを渡さないでください。SyntaxErrorがスローされます。代わりに、ブラウザは DNS 解決の前にWebSocketURL を検査します。https://ページから開始されたws://接続は、URL ホストが構文的にローカルまたはループバックとして認識可能な場合(具体的には、プライベートまたはループバック IP リテラル(ws://192.168.1.100:8080、ws://127.0.0.1:8080、ws://[::1]:8080)、localhost(*.localhost)、.localmDNS ホスト名(ws://device.local:8080))に、混合コンテンツのブロックから除外され、local-networkまたはloopback-network権限プロンプトの背後にゲートされます。ローカル エンドポイントがカスタムの非.localDNS ホスト名でアドレス指定されている場合は、wss://経由で接続します。WebTransport:WebTransportではhttps://(TLS を使用した HTTP/3 over QUIC、またはローカルの自己署名証明書の場合はserverCertificateHashes)が厳密に必要となるため、混合コンテンツのブロックがトリガーされることはなく、targetAddressSpaceオプションは受け入れられません。代わりに、ブラウザが解決された IP アドレス空間を自動的に検証し、local-networkまたはloopback-networkの権限プロンプトの背後でtransport.readyプロミスをゲートします。
/**
* 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. Permissions-Policy を介してクロスオリジン iframe へのアクセスを委任する
デフォルトでは、local-network と loopback-network のデフォルトの許可リストは 'self' で、クロスオリジンの <iframe> 埋め込みがローカルまたはループバック リクエストを行うことをブロックします。埋め込まれたクロスオリジン <iframe>(ハードウェア設定ウィジェットや、ローカル エージェントと通信する SSO フレームなど)がローカル リクエストまたはループバック リクエストを開始できるようにするには、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")
代替戦略
ベースライン ターゲットがローカル ネットワーク アクセス権限と targetAddressSpace オプションをサポートしていない場合は、プログレッシブ フォールバックによる機能検出を実装します。
- 権限クエリの代替:
navigator.permissions.query({ name: permissionName })をtry / catchでラップします。'local-network'または'loopback-network'が認識されないPermissionNameであるためにブラウザがTypeErrorをスローした場合は、状態を'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';
}
}