عندما يتصل تطبيق ويب من مصدر public (أو مصدر شبكة داخلية local) بشبكة محلية خاصة بالمستخدم (مثل طابعة LAN أو مركز إنترنت الأشياء أو جهاز توجيه على 192.168.1.x أو device.local) أو بخدمة مصاحبة محلية تعمل على جهاز المستخدم (127.0.0.1 أو [::1] أو localhost)، تحظر المتصفحات الاتصال من خلال طلب إذن الوصول إلى الشبكة المحلية (LNA).
تقسّم ميزة "الوصول إلى الشبكة المحلية" إمكانية الوصول إلى إذنَين دقيقَين ورمزَين مميزَين من النوع 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، والارتباط المحلي169.254.0.0/16/fe80::/10، وعناوين IPv6 المحلية الفريدة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 أثناء عملية التطوير المحلية لن يؤدي إلى تفعيل عمليات التحقّق من الوصول إلى الشبكة المحلية تلقائيًا. لاختبار طلبات الوصول إلى الشبكة المحلية وحالات الأذونات محليًا بدون نشرها على خادم متاح للجميع، يمكنك إما إلغاء مساحة عناوين IPv4 وIPv6 الخاصة بخادم التطوير المحلي إلى public باستخدام علامة Chromium --ip-address-space-overrides=127.0.0.1:8080=public,[::1]:8080=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. التحقّق مسبقًا من حالة الأذونات الدقيقة والتحكّم فيها من خلال الإجراءات التفاعلية من المستخدم
لا تفعِّل مطلقًا طلبات الشبكة المحلية أو طلبات الاسترجاع بدون شروط عند تحميل الصفحة (DOMContentLoaded) عندما تكون حالة الإذن 'prompt'، ولا تبدأ مطلقًا الطلب الأول الذي يؤدي إلى تفعيل الإذن من مشغّل خدمات أو مشغّل مشترك (لا يتضمّن Document مرفقًا وسيتعذّر تنفيذه على الفور إذا لم يكن الإذن 'granted').
بدلاً من ذلك، يمكنك الاستعلام عن navigator.permissions.query() للحصول على 'local-network' أو 'loopback-network' أولاً:
- إذا كان
state === 'granted'، يتم الاتصال تلقائيًا. - إذا كان
state === 'prompt'، اعرض واجهة مستخدم واضحة تشرح سبب حاجة التطبيق إلى الوصول إلى الجهاز المحلي أو التطبيق المصاحب، وأرسِل الطلب من خلال إجراء تفاعلي من المستخدم صريح (مثل النقر على زر). - إذا كانت القيمة
state === 'denied'، يتم عرض واجهة مستخدم قابلة للتنفيذ لإصلاح المشكلة، وتوجيه المستخدم إلى كيفية إعادة تفعيل الوصول إلى الشبكة المحلية أو شبكة الاسترجاع في إعدادات الموقع الإلكتروني في المتصفّح.
/**
* 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:// على الشبكة المحلية أو واجهة الاسترجاع، حدِّد targetAddressSpace: 'local' أو targetAddressSpace: 'loopback' في الخيارَين fetch() أو Request.
يؤدي ضبط السمة targetAddressSpace بشكل صريح إلى تحقيق أمرَين مهمَّين:
- استثناء المحتوى المختلط: يتم إجراء عمليات التحقّق من المحتوى المختلط قبل التحويل باستخدام نظام أسماء النطاقات (DNS). يؤدي الإعلان عن
targetAddressSpace: 'local'(أو'loopback') إلى إبلاغ المتصفّح قبل التحويل باستخدام نظام أسماء النطاقات (DNS) بأنّ عنوان URL لـhttp://(بما في ذلك اسم مضيف DNS مخصّص يتم تحويله إلى عنوان IP محلي) يستهدف نقطة نهاية محلية أو نقطة نهاية استرجاعية ويجب إعفاؤه من حظر المحتوى المختلط بعد أن يمنح المستخدم الإذن. - التحقّق من مساحة العناوين: بعد التحويل باستخدام نظام أسماء النطاقات (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)سوى سلسلة أو مصفوفة بروتوكول فرعي كوسيطة ثانية، لذا لا تمرِّر كائن خيارات{ targetAddressSpace }، لأنّ ذلك يؤدي إلى ظهور الخطأSyntaxError. بدلاً من ذلك، يفحص المتصفّح عنوان URL الخاص بـWebSocketقبل التحويل باستخدام نظام أسماء النطاقات (DNS): يتم إعفاء اتصالاتws://التي يتم بدؤها من صفحةhttps://من حظر المحتوى المختلط عندما يكون مضيف عنوان URL قابلاً للتعرّف عليه من الناحية النحوية على أنّه محلي أو استرجاعي، وتحديدًا عنوان IP حرفي خاص أو استرجاعي (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. إذا تم توجيه نقطة نهاية محلية عبر اسم مضيف مخصّص لنظام أسماء النطاقات غير تابع لـ.local، يمكنك الاتصال عبرwss://.WebTransport: بما أنّWebTransportيتطلّب بشكل صارم استخدامhttps://(HTTP/3 عبر QUIC مع بروتوكول أمان طبقة النقل (TLS) أوserverCertificateHashesللشهادات المحلية الموقَّعة ذاتيًا)، لا يؤدي أبدًا إلى تفعيل عمليات حظر المحتوى المختلط ولا يقبل خيار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> المتعددة المصادر من إرسال طلبات محلية أو طلبات استرجاع. للسماح لـ <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")
الاستراتيجيات الاحتياطية
إذا كان هدف Baseline لا يتيح أذونات الوصول إلى الشبكة المحلية وخيارات targetAddressSpace، يمكنك تنفيذ رصد الميزات مع استخدام ميزة "الاحتياطي التدريجي":
- Permission Query Fallback: Wrap
navigator.permissions.query({ name: permissionName })in atry / catch. إذا عرض المتصفّح الخطأTypeErrorلأنّ'local-network'أو'loopback-network'هوPermissionNameغير معروف، يجب التعامل مع الحالة على أنّها'prompt'لكي يستمر تنفيذ الطلب عند نقرة صريحة من المستخدم (تؤدي محاولة الاتصال نفسها إلى ظهور طلب المتصفّح في حال توفّر ميزة "الوصول إلى الشبكة المحلية"). - لا تستعلم عن اسم الإذن المجمّع القديم
{ 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';
}
}