تقييد الاتصالات الصادرة بالشبكة باستخدام قوائم الاتصالات المسموح بها

يفرض عنوان الاستجابة Connection-Allowlist HTTP قيودًا على المستند أو العامل، بحيث لا يمكنه التواصل إلا مع قائمة صريحة من نقاط نهاية الشبكة. على عكس "سياسة أمان المحتوى" (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 بدون علامات اقتباس للسماح بمصدر المستند أو العامل، متبوعًا بسلاسل URLPattern بين علامتَي اقتباس مزدوجتَين لكل نقطة نهاية خارجية شرعية يتصل بها السياق أو ينتقل إليها (واجهات برمجة التطبيقات أو شبكات توصيل المحتوى (CDN) للصور/الخطوط وأهداف إعادة التوجيه الصادرة لـ <a href> أو <form action> أو OAuth أو الدفع). عند إنشاء الأنماط ديناميكيًا من الإعدادات أو عناصر URL، يجب تسوية ws:/wss: إلى http:/https: وإزالة الشرطات المائلة اللاحقة في عناوين URL التي تحتوي على المصدر فقط (لأنّ new URL('https://api.example.com').href يضيف /، ما يقيّد مطابقة URLPattern بمسار الجذر / فقط).
  2. تحديد نطاق البرمجيات الوسيطة حسب Sec-Fetch-Dest وتغطية العاملين: تلتزم المتصفحات فقط بـ Connection-Allowlist في ردود المستندات/التنقّل وردود النصوص البرمجية للعاملين (قيم Sec-Fetch-Dest document وiframe وworker وsharedworker وserviceworker). عند تغيير العناوين حسب Sec-Fetch-Dest، يجب تضمين Vary: Sec-Fetch-Dest حتى لا تعرض ذاكرات التخزين المؤقت المشتركة ردودًا بدون عناوين. يجب عرض قائمة مسموح بها مطابقة على نصوص Dedicated و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 هو عنوان URL المقصود المحظور أو "webrtc"، وdisposition هو "enforce" أو "report").
  4. مراعاة الإعدادات التلقائية لإعادة التوجيه وحظر WebRTC ومعالجة أخطاء العميل: يحظر Connection-Allowlist تلقائيًا جميع عمليات إعادة التوجيه عبر بروتوكول HTTP في 3xx (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/الدخول الموحّد وعمليات الدفع) في القائمة المسموح بها للمستندات المحظورة، مع العلم أنّ السمة 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 قنوات استخراج البيانات غير المتوافقة مع "سياسة أمان المحتوى" (dns-prefetch وpreconnect وWebRTC وعمليات إعادة التوجيه)، بينما تفرض "سياسة أمان المحتوى" قيودًا على fetch() والموارد الفرعية وعمليات التنقّل في <form action> في المتصفّحات التي لا تتضمّن Connection-Allowlist.
  • تغطية form-action وworker-src بشكل صريح: في سياسة أمان المحتوى، لا يتم استخدام 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> المضمّنة ما لم يتم السماح بها من خلال تجزئات أو أرقام عشوائية.
  • تضمين 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'"
/>