محدود کردن اتصال‌های شبکه برون‌سو با «فهرست‌های مجاز اتصال»

ویژگی‌های وب: فهرست‌های مجاز اتصال

سرایند پاسخ HTTP Connection-Allowlist سند یا کارگر را محدود می‌کند تا فقط با فهرست صریحی از نقاط پایانی شبکه ارتباط برقرار کند. برخلاف «خط‌مشی امنیت محتوا» (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 با گیومه دوتایی را برای هر نقطه پایانی خارجی قانونی که زمینه به آن متصل می‌شود یا به آن پیمایش می‌کند (میاناهای برنامه‌سازی کاربردی، شبکه‌های توزیع محتوای تصویر/ قلم، و اهداف تغییرمسیر پرداخت، OAuth، <form action>، <a href> برون‌مرزی) اضافه کنید. هنگام ساختن الگوها به‌صورت پویا از پیکربندی یا اشیاء URL، ws:/wss: را به http:/https: نرمال‌سازی کنید و خط‌های مورب انتهایی را در نشانی‌های وب فقط مبدأ حذف کنید (زیرا 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 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 همه بازهدایت‌های HTTP 3xx (redirects=block، حتی هنگام بازهدایت بین نشانی‌های وب فهرست مجاز یا با مبدأ یکسان) و همه ترافیک RTCPeerConnection (webrtc=block) را مسدود می‌کند. فقط زمانی ; redirects=allow را اضافه کنید که نقاط پایانی مورداعتماد به بازهدایت‌های سمت سرور نیاز داشته باشند (درخواست اولیه همچنان باید با فهرست مجاز مطابقت داشته باشد)، یا زمانی‌که WebRTC لازم باشد ; webrtc=allow را اضافه کنید. در کارخواه، تماس‌های خروجی 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: همچنان مجاز باقی می‌مانند).
  • باید خط مورب پایانی را در الگوهای نشانی وب در سطح مبدأ ("https://api.example.com") حذف کنید یا از نویسه عام صریح ("https://api.example.com/*") استفاده کنید تا زیرمسیرهایی مانند /v1/users مجاز شوند. Strip trailing slashes added by new URL(origin).href before serializing patterns.
  • باید نقاط پایانی 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/*" در الگوهای نشانی وب استفاده نکنید؛ تجزیه‌کننده سرویس شبکه مرورگر گروه‌ها و الگوهای عبارت باقاعده بدون طرح و نام میزبان را رد می‌کند.
  • هنوز به مشخصه آزمایشی <iframe connectionAllowlist="..."> یا سرصفحه اجرایی جاسازی‌شده Allow-Connection-Allowlist-From در تولید اتکا نکنید، زیرا اجرای جاسازی‌شده به‌طور پیش‌فرض در مرورگرهای ارسال فعال نیست.

راهبرد جایگزین

چون Connection-Allowlist در لایه شبکه مرورگر ازطریق سرایند پاسخ‌های HTTP اعمال می‌شود، نمی‌توان آن را در جاوا اسکریپت سمت کارخواه شبیه‌سازی کرد. اگر هدف 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:) را اضافه کنید تا صفحه‌های محدودشده نتوانند SharedWorkers blob: ایجاد کنند و محدودیت‌های سطح سند را دور بزنند.
  • نگاشت مبدأهای مجاز به دستورالعمل‌های 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'"
/>