Restreindre les connexions réseau sortantes avec des listes d'autorisation de connexion

Fonctionnalités Web : Listes d'autorisation de connexion

L'en-tête de réponse HTTP Connection-Allowlist limite un document ou un worker à communiquer uniquement avec une liste explicite de points de terminaison réseau. Contrairement à la Content Security Policy (CSP), qui nécessite de gérer des directives distinctes par ressource (connect-src, img-src, script-src, font-src, form-action) et ne régit pas les canaux réseau spéculatifs ou non Fetch tels que <link rel="dns-prefetch">, <link rel="preconnect"> ou WebRTC, Connection-Allowlist applique une seule liste d'autorisation au niveau de la couche réseau à toutes les connexions sortantes initiées par le contexte (fetch(), XMLHttpRequest, sous-ressources, navigator.sendBeacon(), <a ping>, navigations de premier niveau et de frame, indices de ressources, règles de spéculation, WebSocket, WebTransport et WebRTC).

Mise en œuvre

  1. Sérialisez la liste d'autorisation en tant que liste interne RFC 9651 (...) : formatez Connection-Allowlist (ou Connection-Allowlist-Report-Only) en tant que liste unique d'éléments séparés par des espaces entre parenthèses. Incluez le jeton sans guillemets response-origin pour autoriser l'origine du document ou du nœud de calcul, suivi de chaînes URLPattern entre guillemets doubles pour chaque point de terminaison externe légitime auquel le contexte se connecte ou accède (API, CDN d'images/de polices et cibles de redirection <a href>, <form action>, OAuth ou de paiement sortantes). Lorsque vous créez des modèles de manière dynamique à partir d'objets de configuration ou URL, normalisez ws:/wss: en http:/https: et supprimez les barres obliques de fin sur les URL d'origine uniquement (car new URL('https://api.example.com').href ajoute /, ce qui limite la correspondance URLPattern au chemin racine / uniquement).
  2. Limiter le middleware par Sec-Fetch-Dest et couvrir les workers : les navigateurs ne respectent Connection-Allowlist que sur les réponses de document/navigation et les réponses de script de worker (valeurs Sec-Fetch-Dest document, iframe, worker, sharedworker et serviceworker). Lorsque vous variez les en-têtes par Sec-Fetch-Dest, incluez Vary: Sec-Fetch-Dest afin que les caches partagés ne diffusent pas de réponses sans en-tête. Diffusez une liste d'autorisation correspondante sur les scripts DedicatedWorker et SharedWorker afin que les scripts de page compromis ne puissent pas exfiltrer de données via worker.postMessage(). Étant donné qu'un service worker effectue ses propres récupérations réseau en cas d'échec de mise en cache et de préchargement de la navigation, configurez son Connection-Allowlist en tant que sur-ensemble des listes d'autorisation de tous les documents qu'il contrôle.
  3. Configurer le signalement des cas de non-respect et le déploiement progressif ou double : définissez un en-tête Reporting-Endpoints et ajoutez un paramètre ; report-to=<endpoint-name> sans guillemets après le ) de fermeture. Vous pouvez envoyer Connection-Allowlist-Report-Only seul lors du déploiement initial, ou envoyer à la fois Connection-Allowlist (en appliquant une liste d'autorisation de référence) et Connection-Allowlist-Report-Only (en auditant une liste d'autorisation candidate plus stricte) sur la même réponse. Les rapports d'infraction sont envoyés avec l'objet "type": "connection-allowlist" et le corps { url, connection, allowlist, disposition } (où connection est l'URL de destination bloquée ou "webrtc", et disposition est "enforce" ou "report").
  4. Prenez en compte le blocage par défaut des redirections et de WebRTC, et gérez les erreurs client : par défaut, Connection-Allowlist bloque toutes les redirections HTTP 3xx (redirects=block, même lors de la redirection entre des URL ajoutées à la liste d'autorisation ou des URL d'origine) et tout le trafic RTCPeerConnection (webrtc=block). Ajoutez ; redirects=allow uniquement lorsque les points de terminaison de confiance nécessitent des redirections côté serveur (la requête initiale doit toujours correspondre à la liste d'autorisation) ou ; webrtc=allow lorsque WebRTC est requis. Sur le client, encapsulez les appels fetch() sortants dans try...catch et gérez les événements WebSocket error afin que les requêtes rejetées avec TypeError (net::ERR_NETWORK_ACCESS_REVOKED) renvoient un état d'erreur géré et mettent à jour l'état de l'UI au lieu de provoquer des rejets non gérés.

Exemple de code

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 };
  }
}

Bonnes pratiques

  • À FAIRE : Formatez Connection-Allowlist sous la forme d'une seule liste interne RFC 9651 (...) d'éléments séparés par des espaces. Si un en-tête Connection-Allowlist non vide échoue à l'analyse des champs structurés de premier niveau (par exemple, en omettant (...) ou en utilisant des guillemets simples '...'), le navigateur échoue avec une liste d'autorisation vide et bloque toutes les connexions réseau sortantes. Utilisez Connection-Allowlist: () lorsque vous souhaitez intentionnellement révoquer tout accès au réseau après le chargement (les schémas locaux data:, blob:, about: et filesystem: restent autorisés).
  • À FAIRE : Omettez la barre oblique finale dans les formats d'URL à l'échelle de l'origine ("https://api.example.com") ou utilisez un caractère générique explicite ("https://api.example.com/*") pour que les sous-chemins tels que /v1/users soient autorisés. Supprimez les barres obliques de fin ajoutées par new URL(origin).href avant de sérialiser les modèles.
  • AUTORISEZ les points de terminaison WebSocket (ws:// et wss://) ajoutés à la liste d'autorisation à l'aide des modèles "http://..." et "https://..." dans Connection-Allowlist, car le navigateur normalise les schémas WebSocket en HTTP/HTTPS avant la mise en correspondance.
  • À FAIRE : Incluez les cibles de navigation externes attendues (<a href>, <form action>, fournisseurs OAuth/SSO, paiements) dans la liste d'autorisation d'un document restreint, et n'oubliez pas que la valeur par défaut redirects=block bloque même les redirections 3xx de même origine, sauf si ; redirects=allow est spécifié.
  • N'ajoutez PAS de guillemets autour des valeurs des paramètres response-origin ou de la liste interne (; report-to=conn-reports, ; redirects=allow, ; webrtc=allow), et ne séparez pas les éléments de (...) par des virgules.
  • N'UTILISEZ PAS de groupes d'expressions régulières personnalisés tels que (\d+) ni de chemins relatifs tels que "/api/*" dans les formats d'URL. L'analyseur de service réseau du navigateur rejette les groupes et les formats d'expressions régulières sans schéma ni nom d'hôte.
  • NE VOUS FIEZ PAS encore à l'attribut expérimental <iframe connectionAllowlist="..."> ni à l'en-tête d'application intégré Allow-Connection-Allowlist-From en production, car l'application intégrée n'est pas activée par défaut dans les navigateurs de livraison.

Stratégie de remplacement

Étant donné que Connection-Allowlist est appliqué au niveau de la couche réseau du navigateur via un en-tête de réponse HTTP, il ne peut pas être polyfillé en JavaScript côté client. Si votre cible Baseline inclut des navigateurs qui ne sont pas encore compatibles avec Connection-Allowlist, associez Connection-Allowlist à un en-tête Content-Security-Policy (CSP) (et à un élément <meta http-equiv="Content-Security-Policy"> pour les restrictions de récupération et de sous-ressources au niveau du document lorsque les en-têtes de réponse HTTP ne peuvent pas être configurés) :

  • Défense en profondeur additive : les navigateurs compatibles appliquent les deux règles simultanément. Connection-Allowlist bloque les canaux d'exfiltration non CSP (dns-prefetch, preconnect, WebRTC et redirections), tandis que CSP limite les navigations fetch(), les sous-ressources et les navigations <form action> dans les navigateurs sans Connection-Allowlist.
  • Couvrez explicitement form-action et worker-src : dans la CSP, default-src ne sert pas de solution de secours pour les envois <form action>. Incluez toujours explicitement form-action. Incluez worker-src 'self' (à l'exclusion de blob:) afin que les pages restreintes ne puissent pas générer de SharedWorkers blob: pour contourner les restrictions au niveau du document.
  • Mappez les origines autorisées aux directives CSP par ressource : contrairement à Connection-Allowlist (qui utilise une seule liste de points de terminaison et ne régit pas le balisage intégré), default-src 'self' dans CSP limite chaque type de ressource (connect-src, img-src, style-src, font-src, script-src) et bloque les blocs <style> et <script> intégrés, sauf s'ils sont autorisés via des hachages ou des nonces.
  • Inclure wss:// dans le CSP connect-src : bien que Connection-Allowlist mette en correspondance les connexions wss:// avec les modèles "https://...", le CSP connect-src dans les environnements multi-navigateurs doit lister explicitement les origines wss:// lorsque des WebSockets sont utilisés.
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'"
/>