เมื่อเว็บแอปพลิเคชันที่แสดงจากต้นทาง 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 191810.0.0.0/8,172.16.0.0/12,192.168.0.0/16, link-local169.254.0.0/16/fe80::/10, ที่อยู่ IPv6 Unique Localfc00::/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 ประการดังนี้
- การยกเว้นเนื้อหาผสม: การตรวจสอบเนื้อหาผสมจะทำงานก่อนการแปลง DNS การประกาศ
targetAddressSpace: 'local'(หรือ'loopback') จะแจ้งให้เบราว์เซอร์ทราบก่อนการแปลง DNS ว่า URLhttp://(รวมถึงชื่อโฮสต์ DNS ที่กำหนดเองซึ่งแปลงเป็น IP ในเครื่อง) กำหนดเป้าหมายเป็นปลายทางในเครื่อง/Loopback และควรได้รับการยกเว้นจากการบล็อกเนื้อหาผสมเมื่อผู้ใช้ให้สิทธิ์ - การยืนยันพื้นที่ที่อยู่: หลังจากการแปลง DNS เบราว์เซอร์จะยืนยันว่าที่อยู่ IP ที่แปลงแล้วเป็นของ
targetAddressSpaceที่ประกาศไว้จริง หากชื่อโฮสต์ที่มีtargetAddressSpace: 'local'แปลงเป็นที่อยู่ IPpublicโดยไม่คาดคิด เบราว์เซอร์จะยกเลิกคำขอทันทีพร้อมข้อผิดพลาดเกี่ยวกับเครือข่าย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แต่เบราว์เซอร์จะตรวจสอบ URLWebSocketก่อนการแปลง 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 ให้ใช้การตรวจหาฟีเจอร์ที่มีการสำรองแบบเป็นขั้นเป็นตอน ดังนี้
- Permission Query Fallback: รวม
navigator.permissions.query({ name: permissionName })ไว้ในtry / catchหากเบราว์เซอร์แสดงTypeErrorเนื่องจาก'local-network'หรือ'loopback-network'เป็นPermissionNameที่ไม่รู้จัก ให้ถือว่าสถานะเป็น'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';
}
}