การเข้าถึงเครือข่ายภายใน

เมื่อเว็บแอปพลิเคชันที่แสดงจากต้นทาง public (หรือต้นทางอินทราเน็ต local) เชื่อมต่อกับเครือข่ายภายในส่วนตัวของผู้ใช้ (เช่น เครื่องพิมพ์ LAN, ฮับ IoT หรือเราเตอร์ที่ 192.168.1.x หรือ device.local) หรือบริการที่ใช้ร่วมกันภายในซึ่งทำงานในเครื่องของผู้ใช้ (127.0.0.1, [::1] หรือ localhost) เบราว์เซอร์จะจำกัดการเชื่อมต่อไว้หลังข้อความแจ้งสิทธิ์การเข้าถึงเครือข่ายภายใน (LNA)

การเข้าถึงเครือข่ายภายในจะแยกการเข้าถึงออกเป็น 2 สิทธิ์แบบละเอียดและโทเค็น Permissions-Policy โดยอิงตามแลตทิซพื้นที่ที่อยู่ IP (public → local → loopback) ซึ่งจะบังคับใช้การตรวจสอบเฉพาะเมื่อคำขอข้ามจากพื้นที่ที่อยู่ที่มีความเป็นส่วนตัวน้อยกว่าไปยังพื้นที่ที่อยู่ที่มีความเป็นส่วนตัวมากกว่า ดังนี้

  • local-network: ต้องระบุเมื่อเชื่อมต่อจากต้นทาง public ไปยังพื้นที่ที่อยู่ local (ช่วง IPv4 ส่วนตัว 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, ที่อยู่ IPv6 Unique Local fc00::/7 และชื่อโฮสต์ mDNS .local)
  • loopback-network: ต้องระบุเมื่อเชื่อมต่อจากต้นทาง public หรือ local ไปยังพื้นที่ที่อยู่ loopback (127.0.0.0/8, ::1/128 หรือ localhost)

เนื่องจาก localhost (127.0.0.1 และ [::1]) อยู่ในพื้นที่ที่อยู่ที่เป็นส่วนตัวที่สุด (loopback) อยู่แล้ว การแสดงเว็บแอปจาก http://localhost ในระหว่างการพัฒนาในเครื่องจึงไม่ทริกเกอร์การตรวจสอบ LNA โดยค่าเริ่มต้น หากต้องการทดสอบข้อความแจ้ง LNA และสถานะสิทธิ์ในเครื่องโดยไม่ต้องติดตั้งใช้งานในเซิร์ฟเวอร์สาธารณะ ให้ลบล้างพื้นที่ที่อยู่ IPv4 และ IPv6 ของเซิร์ฟเวอร์การพัฒนาในเครื่องเป็น public โดยใช้แฟล็ก Chromium --ip-address-space-overrides=127.0.0.1:8080=public,[::1]:8080=public หรือสลับเครือข่าย LAN และเครือข่าย Loopback ด้วยตนเองระหว่าง Ask, Allow และ Block ในการตั้งค่าเว็บไซต์ของเบราว์เซอร์

บังคับ: แสดงเว็บแอปพลิเคชันที่เริ่มต้นคำขอเครือข่ายในเครื่องหรือเครือข่าย Loopback จากบริบทที่ปลอดภัย (https://) ระบบจะปฏิเสธสิทธิ์การเข้าถึงเครือข่ายในเครื่องโดยอัตโนมัติในบริบทระดับบนสุดที่ไม่ปลอดภัย (http://)

อย่าใช้ส่วนหัวการตรวจสอบล่วงหน้าของ Private Network Access (PNA) ที่เลิกใช้งานแล้ว (Access-Control-Request-Private-Network: true / Access-Control-Allow-Private-Network: true) การเข้าถึงเครือข่ายภายในจะบังคับใช้การเข้าถึงผ่านข้อความแจ้งการให้สิทธิ์จากผู้ใช้ฝั่งไคลเอ็นต์ (local-network และ loopback-network) ร่วมกับส่วนหัว CORS มาตรฐาน (Access-Control-Allow-Origin) เมื่ออ่านการตอบกลับแบบข้ามต้นทาง ระบบไม่เคยบังคับใช้ Preflight ของ PNA เป็นข้อกำหนดการบล็อก และไม่จำเป็นต้องใช้เป็นทางเลือกสำรอง เซิร์ฟเวอร์หรือเฟิร์มแวร์ของอุปกรณ์ที่มีอยู่ซึ่งแสดงผล Access-Control-Allow-Private-Network: true อยู่แล้วจะยังคงทำเช่นนั้นต่อไปได้โดยไม่มีอันตราย แต่อย่าเพิ่มการจัดการ PNA ลงในโค้ดใหม่

ขั้นตอนการติดตั้งใช้งาน

1. ตรวจสอบสถานะสิทธิ์แบบละเอียดล่วงหน้าและควบคุมท่าทางผู้ใช้

อย่าเริ่มทำงานคำขอเครือข่ายภายในหรือ Loopback โดยไม่มีเงื่อนไขเมื่อการโหลดหน้าเว็บ (DOMContentLoaded) เมื่อสถานะสิทธิ์เป็น 'prompt' และอย่าเริ่มคำขอที่ทริกเกอร์สิทธิ์ครั้งแรกจาก Service Worker หรือ Shared Worker (ซึ่งไม่มี Document แนบมาด้วยและจะดำเนินการไม่สำเร็จทันทีหากสิทธิ์ไม่ใช่ 'granted' อยู่แล้ว)

แต่ให้ค้นหา navigator.permissions.query() สำหรับ 'local-network' หรือ 'loopback-network' ก่อน ดังนี้

  • หาก state === 'granted' ให้เชื่อมต่อโดยอัตโนมัติ
  • หากเป็น state === 'prompt' ให้แสดง UI ที่ชัดเจนซึ่งอธิบายสาเหตุที่แอปต้องการเข้าถึงอุปกรณ์ในเครื่องหรือแอปที่ใช้ร่วมกัน และทริกเกอร์คำขอจากท่าทางผู้ใช้ที่ชัดเจน (เช่น การคลิกปุ่ม)
  • หากเป็น state === 'denied' ให้แสดง UI การแก้ไขที่ดำเนินการได้ซึ่งจะแนะนำผู้ใช้เกี่ยวกับวิธีเปิดใช้การเข้าถึงเครือข่าย LAN หรือ Loopback อีกครั้งในการตั้งค่าเว็บไซต์ของเบราว์เซอร์
/**
 * 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 ใน fetch() และ Request

เมื่อหน้า https:// ขอปลายทาง http:// ในเครือข่าย LAN หรืออินเทอร์เฟซ Loopback ให้ระบุ targetAddressSpace: 'local' หรือ targetAddressSpace: 'loopback' ในตัวเลือก fetch() หรือ Request

การตั้งค่า targetAddressSpace อย่างชัดเจนจะช่วยให้เกิดผลลัพธ์ที่สำคัญ 2 ประการดังนี้

  1. การยกเว้นเนื้อหาผสม: การตรวจสอบเนื้อหาผสมจะทำงานก่อนการแปลง DNS การประกาศ targetAddressSpace: 'local' (หรือ 'loopback') จะแจ้งให้เบราว์เซอร์ทราบก่อนการแปลง DNS ว่า URL http:// (รวมถึงชื่อโฮสต์ DNS ที่กำหนดเองซึ่งแปลงเป็น IP ในเครื่อง) กำหนดเป้าหมายเป็นปลายทางในเครื่อง/Loopback และควรได้รับการยกเว้นจากการบล็อกเนื้อหาผสมเมื่อผู้ใช้ให้สิทธิ์
  2. การยืนยันพื้นที่ที่อยู่: หลังจากการแปลง DNS เบราว์เซอร์จะยืนยันว่าที่อยู่ IP ที่แปลงแล้วเป็นของ targetAddressSpace ที่ประกาศไว้จริง หากชื่อโฮสต์ที่มี targetAddressSpace: 'local' แปลงเป็นที่อยู่ IP public โดยไม่คาดคิด เบราว์เซอร์จะยกเลิกคำขอทันทีพร้อมข้อผิดพลาดเกี่ยวกับเครือข่าย 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 แต่เบราว์เซอร์จะตรวจสอบ URL WebSocket ก่อนการแปลง DNS: การเชื่อมต่อ ws:// ที่เริ่มต้นจากหน้า https:// จะได้รับการยกเว้นจากการบล็อกเนื้อหาผสมเมื่อโฮสต์ URL สามารถจดจำตามไวยากรณ์ได้ว่าเป็นแบบในเครื่องหรือแบบ Loopback โดยเฉพาะอย่างยิ่ง IP แบบ Private หรือ Loopback (ws://192.168.1.100:8080, ws://127.0.0.1:8080, ws://[::1]:8080), localhost (*.localhost) หรือชื่อโฮสต์ mDNS .local (ws://device.local:8080) และถูกจำกัดไว้หลังข้อความแจ้งสิทธิ์ local-network หรือ loopback-network หากมีการระบุปลายทางในเครื่องผ่านชื่อโฮสต์ DNS ที่ไม่ใช่ .local ที่กำหนดเอง ให้เชื่อมต่อผ่าน wss://
  • WebTransport: เนื่องจาก WebTransport กำหนดให้ใช้ https:// อย่างเคร่งครัด (HTTP/3 ผ่าน QUIC ด้วย TLS หรือ serverCertificateHashes สำหรับใบรับรองแบบ Self-signed ในเครื่อง) จึงไม่ทริกเกอร์การบล็อกเนื้อหาผสมและไม่ยอมรับตัวเลือก targetAddressSpace แต่เบราว์เซอร์จะยืนยันพื้นที่ที่อยู่ IP ที่แปลงแล้วโดยอัตโนมัติ และควบคุมสัญญา transport.ready ไว้หลังข้อความแจ้งสิทธิ์ local-network หรือ 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. มอบสิทธิ์เข้าถึง iframe แบบข้ามต้นทางผ่าน Permissions-Policy

โดยค่าเริ่มต้น local-network และ loopback-network จะมีรายการที่อนุญาตเริ่มต้นเป็น 'self' ซึ่งจะบล็อกการฝัง <iframe> แบบข้ามต้นทางไม่ให้ส่งคำขอภายในหรือคำขอ Loopback หากต้องการอนุญาตให้ <iframe> แบบข้ามต้นทางที่ฝังไว้ (เช่น วิดเจ็ตการตั้งค่าฮาร์ดแวร์หรือเฟรม SSO ที่สื่อสารกับ Agent ในเครื่อง) เริ่มคำขอในเครื่องหรือคำขอ Loopback ให้มอบสิทธิ์แบบละเอียดอย่างชัดเจนในแอตทริบิวต์ 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")

กลยุทธ์สำรอง

หากเป้าหมาย Baseline ไม่รองรับสิทธิ์เข้าถึงเครือข่ายภายในและตัวเลือก targetAddressSpace ให้ใช้การตรวจหาฟีเจอร์ที่มีการสำรองแบบเป็นขั้นเป็นตอน ดังนี้

  1. Permission Query Fallback: รวม navigator.permissions.query({ name: permissionName }) ไว้ใน try / catch หากเบราว์เซอร์แสดง TypeError เนื่องจาก 'local-network' หรือ 'loopback-network' เป็น PermissionName ที่ไม่รู้จัก ให้ถือว่าสถานะเป็น 'prompt' เพื่อให้คำขอยังคงทำงานเมื่อผู้ใช้คลิกอย่างชัดแจ้ง (การพยายามเชื่อมต่อจะทริกเกอร์ข้อความแจ้งของเบราว์เซอร์ที่รองรับ LNA)
  2. อย่าค้นหาชื่อสิทธิ์แบบรวมเดิม { name: 'local-network-access' } ใน Chrome เวอร์ชันเก่า การค้นหานี้จะทำให้กระบวนการแสดงผลขัดข้อง และ try / catch ไม่สามารถป้องกันได้ Chrome เวอร์ชันใหม่จะถือว่า 'local-network-access' เป็นเพียงชื่อแทนเดิมสำหรับสิทธิ์แบบละเอียด
  3. การจัดการการปฏิเสธอย่างเหมาะสม: รวม 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';
  }
}