גישה לרשת המקומית

תכונות באינטרנט: גישה לרשת המקומית

כשאפליקציית אינטרנט שמוצגת ממקור public (או ממקור local באינטראנט) מתחברת לרשת המקומית הפרטית של המשתמש (כמו מדפסת LAN, רכזת IoT או נתב בכתובת 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, ‏ link-local‏ 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 במהלך פיתוח מקומי לא תפעיל בדיקות LNA כברירת מחדל. כדי לבדוק את ההודעות וההרשאות של LNA באופן מקומי בלי לפרוס אותן בשרת ציבורי, אפשר לשנות את מרחב הכתובות של IPv4 ו-IPv6 בשרת הפיתוח המקומי ל-public באמצעות הדגל --ip-address-space-overrides=127.0.0.1:8080=public,[::1]:8080=public של Chromium, או להחליף באופן ידני את הרשת המקומית ואת רשת הלולאה החוזרת בין 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. בדיקה מראש של מצב ההרשאה הספציפית והגבלת הגישה לתנועה של המשתמש

אף פעם אל תפעילו בקשות לרשת מקומית או ללולאה חוזרת (loopback) ללא תנאי בזמן טעינת הדף (DOMContentLoaded) כשמצב ההרשאה הוא 'prompt', ואף פעם אל תפעילו את הבקשה הראשונה שמפעילה הרשאה מ-Service Worker או מ-Shared Worker (שאין להם Document מצורף והם ייכשלו מיד אם ההרשאה היא לא 'granted').

במקום זאת, קודם צריך לשלוח שאילתה ל-navigator.permissions.query() לגבי 'local-network' או 'loopback-network':

  • אם state === 'granted', מתחברים אוטומטית.
  • אם state === 'prompt', צריך להציג ממשק משתמש ברור שמסביר למה האפליקציה צריכה גישה למכשיר המקומי או לאפליקציה הנלווית, ולהפעיל את הבקשה באמצעות תנועה מפורשת של המשתמש (כמו לחיצה על כפתור).
  • אם state === 'denied', צריך להציג ממשק משתמש עם הוראות לתיקון הבעיה, שמסביר למשתמש איך להפעיל מחדש את הגישה לרשת המקומית או ל-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:// מבקש נקודת קצה (endpoint) מסוג http:// בממשק הרשת המקומית או בממשק הלולאה החוזרת (loopback), צריך לציין את הערך targetAddressSpace: 'local' או targetAddressSpace: 'loopback' באפשרויות fetch() או Request.

הגדרה מפורשת של targetAddressSpace מבצעת שתי פעולות קריטיות:

  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: ה-constructor של WebSocket(url, protocols) מקבל רק מחרוזת או מערך של תת-פרוטוקול כארגומנט השני שלו – אל תעבירו אובייקט של אפשרויות { targetAddressSpace }, כי זה יגרום ל-SyntaxError. במקום זאת, הדפדפן בודק את כתובת ה-URL של WebSocket לפני פענוח ה-DNS: חיבורי ws:// שנוצרו מדף https:// פטורים מחסימת תוכן מעורב כשמארח כתובת ה-URL ניתן לזיהוי תחבירי כמקומי או כ-loopback – באופן ספציפי כתובת IP פרטית או כתובת IP של 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 over QUIC עם TLS או serverCertificateHashes לאישורים מקומיים בחתימה עצמית), הוא אף פעם לא מפעיל חסימות של תוכן מעורב ולא מקבל אפשרות של targetAddressSpace. במקום זאת, הדפדפן מאמת באופן אוטומטי את מרחב כתובות ה-IP שנפתרו, ומגביל את ה-promise של 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 שמתקשרת עם סוכן מקומי) לשלוח בקשות מקומיות או בקשות לולאה חוזרת (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: Wrap navigator.permissions.query({ name: permissionName }) in a 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 ב-error handlers שתופסים כשלים ב-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';
  }
}