จำกัดการเชื่อมต่อเครือข่ายขาออกด้วยรายการที่อนุญาตสำหรับการเชื่อมต่อ

ส่วนหัวการตอบกลับ HTTP Connection-Allowlist จำกัดเอกสารหรือเวิร์กเกอร์ให้สื่อสารกับรายการปลายทางเครือข่ายที่ชัดเจนเท่านั้น Connection-Allowlist บังคับใช้รายการที่อนุญาตระดับเลเยอร์เครือข่ายรายการเดียวกับการเชื่อมต่อขาออกทั้งหมดที่เริ่มต้นโดยบริบท (fetch(), XMLHttpRequest, ทรัพยากรย่อย, navigator.sendBeacon(), <a ping>, การนำทางระดับบนสุดและเฟรม, คำแนะนำทรัพยากร, กฎการคาดเดา, WebSocket, WebTransport และ WebRTC) ซึ่งแตกต่างจากนโยบายรักษาความปลอดภัยเนื้อหา (CSP) ที่ต้องรักษาคำสั่งต่อทรัพยากรแยกต่างหาก (connect-src, img-src, script-src, font-src, form-action) และไม่ได้ควบคุมช่องทางเครือข่ายแบบคาดเดาหรือไม่ใช่ Fetch เช่น <link rel="dns-prefetch">, <link rel="preconnect"> หรือ WebRTC

วิธีการนำไปใช้งาน

  1. จัดเรียงรายการที่อนุญาตเป็นรายการภายใน RFC 9651 (...): จัดรูปแบบ Connection-Allowlist (หรือ Connection-Allowlist-Report-Only) เป็นรายการเดียวที่อยู่ในวงเล็บของรายการที่คั่นด้วยเว้นวรรค ใส่โทเค็น response-origin ที่ไม่มีเครื่องหมายคำพูดเพื่ออนุญาตต้นทางของเอกสารหรือผู้ปฏิบัติงาน ตามด้วยสตริง URLPattern ที่มีเครื่องหมายคำพูดคู่สำหรับปลายทางภายนอกที่ถูกต้องตามกฎหมายทุกรายการที่บริบทเชื่อมต่อหรือไปยัง (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 และครอบคลุมเวิร์กเกอร์: เบราว์เซอร์จะใช้ 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 คือ 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) แสดงสถานะข้อผิดพลาดที่จัดการแล้ว และอัปเดตสถานะ UI แทนที่จะทำให้เกิดการปฏิเสธที่ไม่ได้จัดการ

โค้ดตัวอย่าง

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 ที่ไม่ว่างเปล่าแยกวิเคราะห์ Structured Field ระดับบนสุดไม่สำเร็จ (เช่น ละเว้น (...) หรือใช้เครื่องหมายคำพูดเดี่ยว '...') เบราว์เซอร์จะปิดไม่สำเร็จโดยมีรายการที่อนุญาตว่างเปล่าและบล็อกการเชื่อมต่อเครือข่ายขาออกทั้งหมด ใช้ 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+) หรือ Relative Path เช่น "/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 ได้)

  • การป้องกันหลายชั้นแบบเสริม: เบราว์เซอร์ที่รองรับจะบังคับใช้นโยบายทั้ง 2 อย่างพร้อมกัน โดย 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 ต่อทรัพยากร: default-src 'self' ใน CSP จะจำกัดทรัพยากรแต่ละประเภท (connect-src, img-src, style-src, font-src, script-src) และบล็อกบล็อก <style> และ <script> ในบรรทัด เว้นแต่จะได้รับอนุญาตผ่านแฮชหรือ Nonce ซึ่งต่างจาก Connection-Allowlist (ซึ่งใช้รายการปลายทางเดียวและไม่ได้ควบคุมมาร์กอัปในบรรทัด)
  • รวม wss:// ไว้ใน CSP connect-src: แม้ว่า Connection-Allowlist จะจับคู่การเชื่อมต่อ wss:// กับรูปแบบ "https://..." แต่ CSP connect-src ในสภาพแวดล้อมแบบข้ามเบราว์เซอร์ควรระบุต้นทาง wss:// อย่างชัดเจนเมื่อใช้ WebSocket
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'"
/>