الوصول إلى الشبكة المحلية

عندما يتصل تطبيق ويب من مصدر 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 1918 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، وأسماء مضيفي 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 بشكل صريح إلى تحقيق أمرَين مهمَّين:

  1. استثناء المحتوى المختلط: يتم إجراء عمليات التحقّق من المحتوى المختلط قبل التحويل باستخدام نظام أسماء النطاقات (DNS). يؤدي الإعلان عن targetAddressSpace: 'local' (أو 'loopback') إلى إبلاغ المتصفّح قبل التحويل باستخدام نظام أسماء النطاقات (DNS) بأنّ عنوان URL لـ http:// (بما في ذلك اسم مضيف DNS مخصّص يتم تحويله إلى عنوان IP محلي) يستهدف نقطة نهاية محلية أو نقطة نهاية استرجاعية ويجب إعفاؤه من حظر المحتوى المختلط بعد أن يمنح المستخدم الإذن.
  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) سوى سلسلة أو مصفوفة بروتوكول فرعي كوسيطة ثانية، لذا لا تمرِّر كائن خيارات { 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، يمكنك تنفيذ رصد الميزات مع استخدام ميزة "الاحتياطي التدريجي":

  1. Permission Query Fallback: Wrap navigator.permissions.query({ name: permissionName }) in a try / catch. إذا عرض المتصفّح الخطأ TypeError لأنّ 'local-network' أو 'loopback-network' هو PermissionName غير معروف، يجب التعامل مع الحالة على أنّها 'prompt' لكي يستمر تنفيذ الطلب عند نقرة صريحة من المستخدم (تؤدي محاولة الاتصال نفسها إلى ظهور طلب المتصفّح في حال توفّر ميزة "الوصول إلى الشبكة المحلية").
  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';
  }
}