הגבלת החיבורים לרשתות יוצאות באמצעות רשימות היתרים של חיבורים

תכונות באינטרנט: רשימות היתרים לחיבור

כותרת תגובת HTTP של Connection-Allowlist מגבילה מסמך או worker לתקשורת רק עם רשימה מפורשת של נקודות קצה ברשת. בניגוד למדיניות אבטחת תוכן (CSP) – שבה צריך לנהל הוראות נפרדות לכל משאב (connect-src,‏ img-src,‏ script-src,‏ font-src,‏ form-action) והיא לא חלה על ערוצי רשת ספקולטיביים או ערוצים שלא קשורים ל-Fetch, כמו <link rel="dns-prefetch">,‏ <link rel="preconnect"> או WebRTC – ‏Connection-Allowlist אוכפת רשימת היתרים אחת בשכבת הרשת בכל החיבורים היוצאים שנוצרו על ידי ההקשר (fetch(),‏ XMLHttpRequest, משאבי משנה, navigator.sendBeacon(),‏ <a ping>, ניווטים ברמה העליונה ובמסגרת, רמזים למשאבים, כללי ספקולציה, WebSocket,‏ WebTransport ו-WebRTC).

איך מטמיעים

  1. הופכים את רשימת ההיתרים לרשימה פנימית לפי RFC 9651 (...): הפורמט של Connection-Allowlist (או Connection-Allowlist-Report-Only) צריך להיות רשימה אחת בסוגריים של פריטים שמופרדים ברווחים. צריך לכלול את האסימון response-origin ללא מירכאות כדי לאפשר את המקור של המסמך או ה-worker, ואחריו מחרוזות URLPattern במירכאות כפולות לכל נקודת קצה (endpoint) חיצונית לגיטימית שההקשר מתחבר או מנווט אליה (ממשקי API, רשתות CDN של תמונות/גופנים ויעדי הפניה לכתובת אחרת יוצאים של <a href>, <form action>, OAuth או תשלום). כשיוצרים תבניות באופן דינמי מאובייקטים של הגדרה או של URL, צריך לנרמל את ws:/wss: ל-http:/https: ולהסיר לוכסנים בסוף כתובות URL שמכילות רק מקור (כי new URL('https://api.example.com').href מוסיף /, מה שמגביל את ההתאמה של URLPattern לנתיב הבסיס / בלבד).
  2. הגדרת היקף של תוכנת ביניים לפי Sec-Fetch-Dest וכיסוי של קובצי Worker: דפדפנים מכבדים את Connection-Allowlist רק בתגובות של מסמכים/ניווט ובתגובות של סקריפטים של קובצי Worker (ערכי Sec-Fetch-Dest document,‏ iframe,‏ worker,‏ sharedworker ו-serviceworker). כשמשנים כותרות לפי Sec-Fetch-Dest, צריך לכלול את Vary: Sec-Fetch-Dest כדי שמטמונים משותפים לא יציגו תגובות ללא כותרות. צריך להציג רשימת היתרים תואמת בסקריפטים של Dedicated Worker ו-Shared Worker, כדי שסקריפטים של דפים שנפרצו לא יוכלו לחלץ נתונים דרך worker.postMessage(). מכיוון ש-Service Worker מבצע אחזורים משלו ברשת במקרים של אי מציאה במטמון וטעינה מראש של ניווט, צריך להגדיר את ה-Connection-Allowlist שלו כקבוצת-על של רשימות ההיתרים של כל המסמכים שהוא שולט בהם.
  3. הגדרת דיווח על הפרות והשקה מדורגת או כפולה: מגדירים כותרת Reporting-Endpoints ומוסיפים פרמטר ; report-to=<endpoint-name> ללא מירכאות אחרי הסוגר ). אפשר לשלוח את Connection-Allowlist-Report-Only לבד במהלך ההשקה הראשונית, או לשלוח את שניהם Connection-Allowlist (אכיפה של רשימת היתרים בסיסית) ו-Connection-Allowlist-Report-Only (ביקורת של רשימת היתרים קפדנית יותר) באותה תגובה. דוחות על הפרות נשלחים עם "type": "connection-allowlist" וגוף { url, connection, allowlist, disposition } (כאשר connection הוא כתובת היעד החסומה או "webrtc", ו-disposition הוא "enforce" או "report").
  4. הביאו בחשבון את ברירת המחדל של חסימת הפניות אוטומטיות ו-WebRTC, וטפלו בשגיאות בצד הלקוח: כברירת מחדל, Connection-Allowlist חוסם את כל ההפניות האוטומטיות של 3xx HTTP (redirects=block, גם כשמפנים בין כתובות URL ברשימת ההיתרים או כתובות URL מאותו מקור) ואת כל התנועה של RTCPeerConnection (webrtc=block). הוסיפו את ; redirects=allow רק כשנקודות קצה מהימנות דורשות הפניות אוטומטיות בצד השרת (הבקשה הראשונית עדיין צריכה להתאים לרשימת ההיתרים), או את ; webrtc=allow כשנדרש WebRTC. בצד הלקוח, צריך לעטוף קריאות fetch() יוצאות ב-try...catch ולטפל באירועים של WebSocket error, כדי שבקשות שנדחו עם TypeError (net::ERR_NETWORK_ACCESS_REVOKED) יחזירו מצב שגיאה מטופל ויעדכנו את הסטטוס בממשק המשתמש, במקום לגרום לדחיות לא מטופלות.

קוד לדוגמה

Vary: Sec-Fetch-Dest
Reporting-Endpoints: conn-reports="https://app.example.com/api/security-reports"
Connection-Allowlist: (response-origin "https://api.example.com" "https://realtime.example.com" "https://*.cdn.example.com/*"); report-to=conn-reports
Connection-Allowlist-Report-Only: (response-origin "https://api.example.com/v2/*"); report-to=conn-reports
const ALLOWLIST_DESTS = new Set(['document', 'iframe', 'worker', 'sharedworker', 'serviceworker']);

// Normalize a config URL or URLPattern string for Connection-Allowlist:
// - Rewrites ws:// and wss:// to http:// and https:// (WebSocket matching rule).
// - Strips a bare trailing slash on origin-only URLs (e.g. from new URL(...).href)
//   so all subpaths on the origin are permitted instead of only "/".
export function normalizeAllowlistPattern(rawPattern) {
  const trimmed = String(rawPattern).trim().replace(/^ws(s?):\/\//i, 'http$1://');
  if (!/^https?:\/\//i.test(trimmed)) {
    throw new Error(`Pattern must include an http:// or https:// scheme: ${rawPattern}`);
  }
  if (/[\s,]/.test(trimmed)) {
    throw new Error(`Pattern must be a single URLPattern without spaces or commas: ${rawPattern}`);
  }
  if (/[()]/.test(trimmed)) {
    throw new Error(`Custom regex groups are rejected by Connection-Allowlist: ${rawPattern}`);
  }
  return trimmed.replace(/^(https?:\/\/[^/?#]+)\/$/i, '$1');
}

// Build an RFC 9651 Inner List value for Connection-Allowlist or Connection-Allowlist-Report-Only.
export function buildConnectionAllowlistHeader({
  allowSelf = true,
  patterns = [],
  reportTo,
  redirects = 'block',
  webrtc = 'block',
} = {}) {
  const items = allowSelf ? ['response-origin'] : [];
  for (const pattern of patterns) {
    const raw = String(pattern).trim();
    if (raw === 'response-origin') {
      if (!items.includes('response-origin')) items.push('response-origin');
      continue;
    }
    const normalized = normalizeAllowlistPattern(raw);
    items.push(`"${normalized.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`);
  }
  let header = `(${items.join(' ')})`;
  if (redirects === 'allow') header += '; redirects=allow';
  if (webrtc === 'allow') header += '; webrtc=allow';
  if (reportTo) {
    const endpointToken = String(reportTo).trim().replace(/^"+|"+$/g, '');
    header += `; report-to=${endpointToken}`;
  }
  return header;
}

// Server or middleware helper applying Connection-Allowlist and Reporting-Endpoints
// on document and worker responses.
export function setConnectionAllowlistHeaders(res, options = {}) {
  const {
    secFetchDest = 'document',
    patterns = ['https://api.example.com', 'https://realtime.example.com', 'https://*.cdn.example.com/*'],
    reportOnlyPatterns,
    reportEndpointName = 'conn-reports',
    reportEndpointUrl = 'https://app.example.com/api/security-reports',
    redirects = 'block',
    webrtc = 'block',
    reportOnly = false,
  } = options;

  const setHeader = (name, value) =>
    typeof res.setHeader === 'function' ? res.setHeader(name, value) : res.headers.set(name, value);

  if (typeof res.appendHeader === 'function') {
    res.appendHeader('Vary', 'Sec-Fetch-Dest');
  } else if (typeof res.headers?.append === 'function') {
    res.headers.append('Vary', 'Sec-Fetch-Dest');
  } else {
    setHeader('Vary', 'Sec-Fetch-Dest');
  }
  if (secFetchDest && !ALLOWLIST_DESTS.has(String(secFetchDest).toLowerCase())) return;

  setHeader('Reporting-Endpoints', `${reportEndpointName}="${reportEndpointUrl}"`);
  setHeader(
    reportOnly ? 'Connection-Allowlist-Report-Only' : 'Connection-Allowlist',
    buildConnectionAllowlistHeader({ allowSelf: true, patterns, reportTo: reportEndpointName, redirects, webrtc }),
  );

  if (!reportOnly && Array.isArray(reportOnlyPatterns)) {
    setHeader(
      'Connection-Allowlist-Report-Only',
      buildConnectionAllowlistHeader({
        allowSelf: true,
        patterns: reportOnlyPatterns,
        reportTo: reportEndpointName,
        redirects,
        webrtc,
      }),
    );
  }
}

// Client-side helper that catches blocked outbound requests gracefully without unhandled rejections.
export async function fetchWithPolicyHandling(url, { statusElement, ...fetchOptions } = {}) {
  try {
    const response = await fetch(url, fetchOptions);
    if (!response.ok) throw new Error(`HTTP ${response.status}`);
    const body = await response.text();
    if (statusElement) {
      statusElement.textContent = `Allowed: Loaded ${url} (${body.length} bytes).`;
    }
    return { ok: true, body, error: null };
  } catch (error) {
    const message = error instanceof Error ? error.message : String(error);
    if (statusElement) {
      statusElement.textContent = `Blocked: Request to ${url} was rejected by the outbound connection policy (${message}).`;
    }
    return { ok: false, body: null, error };
  }
}

שיטות מומלצות

  • צריך לעצב את Connection-Allowlist כרשימה פנימית אחת של RFC 9651 (...) של פריטים שמופרדים ברווחים. אם כותרת Connection-Allowlist לא ריקה נכשלת בניתוח של שדה מובנה ברמה העליונה (לדוגמה, השמטת (...) או שימוש במירכאות בודדות '...'), הדפדפן נכשל ונסגר עם רשימת היתרים ריקה וחוסם את כל החיבורים לרשת היוצאת. משתמשים ב-Connection-Allowlist: () כשרוצים לבטל בכוונה את כל הגישה לרשת אחרי הטעינה (הסכימות המקומיות data:,‏ blob:,‏ about: ו-filesystem: נשארות מותרות).
  • מומלץ להשמיט את הלוכסן בסוף תבניות של כתובות URL ברמת המקור ("https://api.example.com") או להשתמש בתו כללי לחיפוש מפורש ("https://api.example.com/*") כדי לאפשר נתיבי משנה כמו /v1/users. צריך להסיר את הלוכסנים בסוף שנוספו על ידי new URL(origin).href לפני שיוצרים סדרות של תבניות.
  • כן כדאי להוסיף לרשימת ההיתרים את נקודות הקצה של WebSocket (ws:// ו-wss://) באמצעות התבניות "http://..." ו-"https://..." ב-Connection-Allowlist, כי הדפדפן מנרמל את הסכימות של WebSocket ל-HTTP/HTTPS לפני ההתאמה.
  • מומלץ לכלול יעדי ניווט חיצוניים צפויים (<a href>, <form action>, ספקי OAuth/SSO, קופות תשלום) ברשימת ההיתרים של מסמך מוגבל, ולזכור שברירת המחדל של redirects=block חוסמת אפילו הפניות אוטומטיות של 3xx מאותו מקור, אלא אם צוין ; redirects=allow.
  • אל תקיפו במירכאות את response-origin או את ערכי הפרמטרים של הרשימה הפנימית (; report-to=conn-reports, ; redirects=allow, ; webrtc=allow), ואל תפרידו פריטים בתוך (...) באמצעות פסיקים.
  • אל תשתמשו בקבוצות של ביטויים רגולריים בהתאמה אישית כמו (\d+) או בנתיבים יחסיים כמו "/api/*" בתוך תבניות URL. המנתח של שירות הרשת בדפדפן דוחה קבוצות ותבניות של ביטויים רגולריים בלי סכימה ושם מארח.
  • אל תסתמכו עדיין על המאפיין הניסיוני <iframe connectionAllowlist="..."> או על כותרת האכיפה המוטמעת Allow-Connection-Allowlist-From בסביבת הייצור, כי אכיפה מוטמעת לא מופעלת כברירת מחדל בדפדפנים שמופצים.

אסטרטגיית גיבוי

מכיוון ש-Connection-Allowlist נאכף בשכבת הרשת של הדפדפן באמצעות כותרת תגובת HTTP, אי אפשר להוסיף לו polyfill ב-JavaScript בצד הלקוח. אם יעד ה-Baseline שלכם כולל דפדפנים שעדיין לא תומכים ב-Connection-Allowlist, צריך לשלב את Connection-Allowlist עם כותרת Content-Security-Policy (CSP) (ואלמנט <meta http-equiv="Content-Security-Policy"> לאחזור ברמת המסמך ולהגבלות על משאבי משנה כשאי אפשר להגדיר כותרות של תגובות HTTP):

  • הגנה נוספת לעומק: דפדפנים תומכים אוכפים את שתי כללי המדיניות בו-זמנית – Connection-Allowlist חוסם ערוצי זליגת מידע שאינם CSP (dns-prefetch, preconnect, WebRTC והפניות אוטומטיות), בעוד ש-CSP מגביל את fetch(), משאבי משנה וניווטים של <form action> בדפדפנים ללא Connection-Allowlist.
  • צריך לציין במפורש את form-action וגם את worker-src: ב-CSP, אם לא מציינים את default-src, לא נעשה שימוש ב-<form action> כברירת מחדל. תמיד צריך לציין במפורש את form-action. צריך לכלול את worker-src 'self' (לא כולל את blob:) כדי שדפים מוגבלים לא יוכלו ליצור blob: SharedWorkers ולעקוף הגבלות ברמת המסמך.
  • מיפוי מקורות מורשים להוראות CSP לכל משאב: בניגוד ל-Connection-Allowlist (שמשתמש ברשימת נקודות קצה אחת ולא מנהל תגי עיצוב מוטבעים), default-src 'self' ב-CSP מגביל כל סוג משאב (connect-src, img-src, style-src, font-src, script-src) וחוסם בלוקים מוטבעים של <style> ו-<script>, אלא אם הם מורשים באמצעות גיבובים או מספרים חד-פעמיים.
  • Include in CSP (צריך לכלול את wss:// ב-CSP connect-src): Connection-Allowlist מתאים בין חיבורי wss:// לתבניות "https://...", אבל ב-CSP connect-src בסביבות של דפדפנים שונים צריך לציין במפורש את המקורות של wss:// כשמשתמשים ב-WebSockets.
Content-Security-Policy: default-src 'self'; connect-src 'self' https://api.example.com https://realtime.example.com wss://realtime.example.com; img-src 'self' https://*.cdn.example.com data: blob:; form-action 'self'; worker-src 'self'; object-src 'none'; base-uri 'self'; report-to conn-reports
<!-- Document-level fallback when HTTP response headers cannot be set on static hosting -->
<meta
  http-equiv="Content-Security-Policy"
  content="default-src 'self'; connect-src 'self' https://api.example.com https://realtime.example.com wss://realtime.example.com; img-src 'self' https://*.cdn.example.com data: blob:; form-action 'self'; worker-src 'self'; object-src 'none'; base-uri 'self'"
/>