Akses Jaringan Lokal

Saat aplikasi web yang disajikan dari origin public (atau origin local intranet) terhubung ke jaringan lokal pribadi pengguna (seperti printer LAN, hub IoT, atau router di 192.168.1.x atau device.local) atau ke layanan pendamping lokal yang berjalan di komputer pengguna (127.0.0.1, [::1], atau localhost), browser akan membatasi koneksi di balik dialog izin Akses Jaringan Lokal (LNA).

Local Network Access membagi akses menjadi dua izin terperinci dan token Permissions-Policy berdasarkan kisi ruang alamat IP (public → local → loopback), yang menerapkan pemeriksaan hanya saat permintaan berpindah dari ruang alamat yang kurang pribadi ke ruang alamat yang lebih pribadi:

  • local-network: Diperlukan saat menghubungkan dari origin public ke ruang alamat local (rentang IPv4 pribadi RFC 1918 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, link-local 169.254.0.0/16 / fe80::/10, Alamat Lokal Unik IPv6 fc00::/7, dan nama host mDNS .local).
  • loopback-network: Diperlukan saat menghubungkan dari origin public atau local ke ruang alamat loopback (127.0.0.0/8, ::1/128, atau localhost).

Karena localhost (127.0.0.1 dan [::1]) sudah berada di ruang alamat paling pribadi (loopback), menyajikan aplikasi web Anda dari http://localhost selama pengembangan lokal tidak akan memicu pemeriksaan LNA secara default. Untuk menguji dialog LNA dan status izin secara lokal tanpa men-deploy ke server publik, ganti ruang alamat IPv4 dan IPv6 server pengembangan lokal Anda ke public menggunakan flag Chromium --ip-address-space-overrides=127.0.0.1:8080=public,[::1]:8080=public, atau alihkan Jaringan lokal dan Jaringan loopback secara manual antara Ask, Allow, dan Block di Setelan situs browser.

WAJIB: Tayangkan aplikasi web apa pun yang memulai permintaan jaringan lokal atau jaringan loopback dari Konteks Aman (https://). Izin Akses Jaringan Lokal otomatis ditolak dalam konteks tingkat teratas yang tidak aman (http://).

JANGAN mengandalkan header preflight Akses Jaringan Pribadi (PNA) yang tidak digunakan lagi (Access-Control-Request-Private-Network: true / Access-Control-Allow-Private-Network: true). Akses Jaringan Lokal menerapkan akses melalui dialog izin pengguna sisi klien (local-network dan loopback-network) yang digabungkan dengan header CORS standar (Access-Control-Allow-Origin) saat membaca respons lintas origin. Preflight PNA tidak pernah diterapkan sebagai persyaratan pemblokiran dan tidak diperlukan sebagai penggantian. Server atau firmware perangkat yang sudah menampilkan Access-Control-Allow-Private-Network: true dapat terus melakukannya tanpa membahayakan, tetapi jangan menambahkan penanganan PNA ke kode baru.

Langkah-Langkah Penerapan

1. Pra-Periksa Status Izin Terperinci dan Gerbang pada Gestur Pengguna

Jangan pernah mengaktifkan permintaan jaringan lokal atau loopback tanpa syarat saat pemuatan halaman (DOMContentLoaded) jika status izinnya adalah 'prompt', dan jangan pernah memulai permintaan pemicu izin pertama dari Service Worker atau Shared Worker (yang tidak memiliki Document terlampir dan akan langsung gagal jika izin belum 'granted').

Sebagai gantinya, buat kueri navigator.permissions.query() untuk 'local-network' atau 'loopback-network' terlebih dahulu:

  • Jika state === 'granted', hubungkan secara otomatis.
  • Jika state === 'prompt', tampilkan UI yang jelas yang menjelaskan alasan aplikasi memerlukan akses ke perangkat lokal atau aplikasi pendamping, dan picu permintaan dari gestur pengguna yang eksplisit (seperti klik tombol).
  • Jika state === 'denied', render UI perbaikan yang dapat ditindaklanjuti yang menginstruksikan pengguna cara mengaktifkan kembali akses Jaringan Lokal atau Loopback di setelan situs 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. Mendeklarasikan targetAddressSpace di fetch() dan Request

Saat halaman https:// meminta endpoint http:// di jaringan lokal atau antarmuka loopback, tentukan targetAddressSpace: 'local' atau targetAddressSpace: 'loopback' di opsi fetch() atau Request.

Menetapkan targetAddressSpace secara eksplisit akan melakukan dua hal penting:

  1. Pengecualian Konten Campuran: Pemeriksaan Konten Campuran dijalankan sebelum resolusi DNS. Mendeklarasikan targetAddressSpace: 'local' (atau 'loopback') akan memberi tahu browser sebelum resolusi DNS bahwa URL http:// (termasuk nama host DNS kustom yang me-resolve ke IP lokal) menargetkan endpoint lokal/loopback dan harus dikecualikan dari pemblokiran Konten Campuran setelah pengguna memberikan izin.
  2. Verifikasi Ruang Alamat: Setelah resolusi DNS, browser akan memverifikasi bahwa alamat IP yang di-resolve benar-benar milik targetAddressSpace yang dideklarasikan. Jika nama host dengan targetAddressSpace: 'local' secara tidak terduga di-resolve ke alamat IP public, browser akan segera membatalkan permintaan dengan error jaringan 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. Koneksi Real-Time dengan WebSocket dan WebTransport

Pembatasan Akses Jaringan Lokal juga mengatur koneksi WebSocket dan WebTransport:

  • WebSocket: Konstruktor WebSocket(url, protocols) hanya menerima string atau array subprotokol sebagai argumen keduanya—jangan meneruskan objek opsi { targetAddressSpace }, yang akan menampilkan SyntaxError. Sebagai gantinya, browser akan memeriksa URL WebSocket sebelum resolusi DNS: Koneksi ws:// yang dimulai dari halaman https:// dikecualikan dari pemblokiran Konten Campuran jika host URL dapat dikenali secara sintaksis sebagai lokal atau loopback—khususnya literal IP pribadi atau loopback (ws://192.168.1.100:8080, ws://127.0.0.1:8080, ws://[::1]:8080), localhost (*.localhost), atau nama host mDNS .local (ws://device.local:8080)—dan dibatasi di balik dialog izin local-network atau loopback-network. Jika endpoint lokal ditangani melalui nama host DNS non-.local kustom, hubungkan melalui wss://.
  • WebTransport: Karena WebTransport sangat memerlukan https:// (HTTP/3 melalui QUIC dengan TLS atau serverCertificateHashes untuk sertifikat yang ditandatangani sendiri secara lokal), protokol ini tidak pernah memicu pemblokiran Konten Campuran dan tidak menerima opsi targetAddressSpace. Sebagai gantinya, browser akan otomatis memverifikasi ruang alamat IP yang di-resolve dan membatasi promise transport.ready di balik dialog izin local-network atau 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. Mendelegasikan Akses ke Iframe Lintas Origin melalui Permissions-Policy

Secara default, local-network dan loopback-network memiliki daftar yang diizinkan default 'self', yang memblokir sematan <iframe> lintas origin agar tidak membuat permintaan lokal atau loopback. Untuk mengizinkan <iframe> lintas origin yang disematkan (seperti widget penyiapan hardware atau frame SSO yang berkomunikasi dengan agen lokal) memulai permintaan lokal atau loopback, delegasikan izin terperinci secara eksplisit di atribut allow dan/atau header Permissions-Policy HTTP:

<!-- 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>

Kirim header Permissions-Policy pada respons dokumen tingkat teratas (penyematan), bukan pada respons iframe:

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

Strategi Penggantian

Jika target Baseline Anda tidak mendukung izin Akses Jaringan Lokal dan opsi targetAddressSpace, terapkan deteksi fitur dengan penggantian progresif:

  1. Penggantian Kueri Izin: Gabungkan navigator.permissions.query({ name: permissionName }) dalam try / catch. Jika browser menampilkan TypeError karena 'local-network' atau 'loopback-network' adalah PermissionName yang tidak dikenali, anggap statusnya sebagai 'prompt' sehingga permintaan tetap berjalan pada klik pengguna eksplisit (upaya koneksi itu sendiri memicu dialog browser tempat LNA didukung).
  2. JANGAN mengkueri nama izin gabungan lama { name: 'local-network-access' }. Di Chrome versi lama, kueri ini akan membuat proses perender error, dan try / catch tidak dapat mencegahnya. Versi Chrome yang lebih baru memperlakukan 'local-network-access' hanya sebagai alias lama untuk izin terperinci.
  3. Penanganan Penolakan yang Tepat: Selalu gabungkan fetch(), peristiwa error WebSocket, dan transport.ready dalam pengendali error yang menangkap kegagalan koneksi/TypeError dan menyajikan langkah-langkah perbaikan yang jelas (memeriksa apakah perangkat lokal diaktifkan, terhubung ke jaringan yang sama, dan diizinkan dalam izin browser).
/**
 * 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';
  }
}