Cómo detectar tráfico de iCloud Private Relay (sin bloquear a usuarios de Apple)

Identificá el relay de privacidad de dos saltos de Apple a partir de la IP de salida, mantenelo separado de las VPN y evitá dejar afuera a usuarios reales de Safari e iCloud+.

Registrarse Leer la documentación

Free: 1.000 req/mes · HTTPS · uso personal y no comercial

Qué es iCloud Private Relay

iCloud Private Relay es la función de privacidad de navegación de Apple (parte de iCloud+). Cuando está activa, Safari no se conecta a tu origen desde la IP de casa o de datos móviles. El tráfico da dos saltos:

  • Ingress ve la IP del usuario, pero no el sitio que visita.
  • Egress ve el destino, pero no la IP del usuario. Tu servidor solo ve este segundo salto.

Apple publica las direcciones de salida como un feed de geolocalización RFC 8805 (prefijo, país, región, ciudad) para que los operadores reconozcan el tráfico. La lista está en https://mask-api.icloud.com/egress-ip-ranges.csv. Network Data Labs mapea esos prefijos a traits.is_relay.

No es una VPN — y bloquearlo perjudica a usuarios reales

Private Relay no es una VPN de uso general. No tuneliza todas las apps del dispositivo, no deja elegir el país de salida y no está pensado para ocultar identidad en fraude. Es un camino de privacidad para Safari (y algunas otras funciones de internet de Apple) que oculta la IP del cliente al sitio.

Detrás de esas IPs de salida hay gente común con iPhone, iPad o Mac e iCloud+. Si tratás la IP como una VPN de datacenter y la bloqueás de una, perdés clientes reales en Safari. Probá cuál es mi IP desde un dispositivo con Private Relay activo: la dirección que ves es la de egress, no la del router de casa.

Quedate con la señal, cambiá la política: permití el relay por defecto y sumá fricción solo en acciones de alto riesgo (pago, cambio de contraseña, retiro) si además tenés otro motivo para endurecer.

La geolocalización del egress es a nivel de región

Apple asigna el egress en la región aproximada del usuario para que los sitios sigan pudiendo localizar de forma gruesa (país / región, idioma, moneda) sin ver la IP de casa. La ciudad, cuando viene, es una estimación. No trates las coordenadas como una ubicación a nivel de calle.

Cada respuesta geo de Network Data Labs incluye location.accuracy_radius_km (radio estimado en kilómetros) y location.confidence (high | medium | low | unknown). En tráfico de relay esperá un radio amplio y menor confianza — a menudo a escala de región (del orden de un par de cientos de kilómetros cuando solo se conoce la región). Usá país y región para los defaults; dejá la ciudad de lado si el radio es amplio o la confianza es low.

Detectalo con una sola consulta

Las señales de riesgo (is_relay, is_vpn, is_tor, is_hosting) están en los planes pagos. Las keys Free devuelven geolocalización + ASN, 1.000 requests por mes calendario UTC, 1 request por segundo, para uso personal y no comercial.

Llamá a GET https://api.networkdatalabs.com/v0/ip/{ip} con Authorization: Bearer. Los campos que importan acá son traits.is_relay, traits.is_vpn, traits.is_tor, location.accuracy_radius_km y location.confidence. Un egress de relay se reporta como is_relay: true e is_vpn: false — esa misma IP nunca se marca también como VPN.

GET /v0/ip/{ip}
$ curl -s https://api.networkdatalabs.com/v0/ip/$CLIENT_IP \
    -H "Authorization: Bearer YOUR_API_KEY" \
    | jq '{ip, location: {country_code: .location.country_code, region: .location.region, city: .location.city, accuracy_radius_km: .location.accuracy_radius_km, confidence: .location.confidence}, traits: {is_relay: .traits.is_relay, is_vpn: .traits.is_vpn, is_tor: .traits.is_tor}}'

{
  "ip": "<IP de egress del visitante>",
  "location": {
    "country_code": "US",
    "region": "California",
    "city": null,
    "accuracy_radius_km": 200.0,
    "confidence": "low"
  },
  "traits": {
    "is_relay": true,
    "is_vpn": false,
    "is_tor": false
  }
}

El JSON de arriba es la forma de una consulta en un plan pago cuando la IP es un egress de relay de privacidad con geo a nivel de región. Poné la IP del visitante que realmente ves (la de req.ip, CF-Connecting-IP o la dirección de cliente de tu balanceador — ver cuál es mi IP). La ciudad puede venir o ser null; leé siempre accuracy_radius_km y confidence antes de usarla. El egress suele estar en una red de CDN o hosting, así que is_hosting también puede dar true — igual tratá is_relay como la señal de usuario de Apple, no como VPN.

Política recomendada

SeñalQué significaAcción sugerida
is_relayEgress de relay de privacidad (iCloud Private Relay y similares). Usuarios comunes de Safari / iCloud+.Permitir. Registralo. Sumá fricción solo en acciones de alto riesgo si tenés otro motivo.
is_vpnRed VPN conocida, incluidos los proveedores comerciales de VPN grandes. Nunca junto con is_relay.Permitir con un flag; verificación extra en pago o retiro.
is_torExit node de Tor actual.Tratalo con más riesgo que relay o VPN: verificar o revisar; no copies la política del relay.
accuracy_radius_km amplio / confianza lowLa ubicación es gruesa (típico del egress de relay).Usá solo país/región; no gates por ciudad.

Middleware de Express

Confiá un salto de proxy, consultá la IP y adjuntá una decisión. Fail-open para que una consulta lenta nunca devuelva 500:

relay.js
// relay.js (Node 18+, global fetch)
const API = "https://api.networkdatalabs.com/v0/ip/";
const KEY = process.env.NDL_API_KEY;
const cache = new Map();
const TTL_MS = 6 * 60 * 60 * 1000;

function normalize(ip) {
  return ip && ip.startsWith("::ffff:") ? ip.slice(7) : ip;
}

async function lookup(ip) {
  const hit = cache.get(ip);
  if (hit && hit.exp > Date.now()) return hit.data;
  const res = await fetch(API + encodeURIComponent(ip), {
    headers: { Authorization: `Bearer ${KEY}` },
    signal: AbortSignal.timeout(800),
  });
  if (!res.ok) throw new Error(`lookup ${res.status}`);
  const data = await res.json();
  cache.set(ip, { data, exp: Date.now() + TTL_MS });
  return data;
}

function policy(data) {
  const t = data?.traits || {};
  const loc = data?.location || {};
  if (t.is_tor) return { action: "review", reason: "tor_exit" };
  if (t.is_relay) {
    return {
      action: "allow",
      reason: "privacy_relay",
      coarseGeo: loc.confidence === "low" || (loc.accuracy_radius_km ?? 0) >= 200,
    };
  }
  if (t.is_vpn) return { action: "step_up", reason: "known_vpn_network" };
  return { action: "allow", reason: null };
}

function ipPolicy() {
  return async (req, res, next) => {
    try {
      const data = await lookup(normalize(req.ip));
      req.ipPolicy = policy(data);
      req.geo = data.location;
    } catch (err) {
      req.ipPolicy = { action: "allow", reason: "lookup_unavailable" };
    }
    next();
  };
}

module.exports = { ipPolicy };
app.js
const express = require("express");
const { ipPolicy } = require("./relay");

const app = express();
app.set("trust proxy", 1);   // un reverse proxy delante de Node
app.use(ipPolicy());

app.post("/checkout", (req, res) => {
  const { action, reason } = req.ipPolicy;
  if (action === "review") return res.status(403).json({ error: "review_required" });
  if (action === "step_up") return res.status(401).json({ error: "step_up", reason });
  // is_relay cae acá: permitir. Opcionalmente pedí 3DS / auth extra solo
  // cuando esto es un retiro de alto riesgo *y* tenés otra señal.
  res.json({ ok: true });
});

Python

La misma política en un helper chico que podés llamar desde FastAPI o Flask. La key queda en el servidor, en NDL_API_KEY:

relay.py
import os
import httpx

API = "https://api.networkdatalabs.com/v0/ip/"
HEADERS = {"Authorization": f"Bearer {os.environ['NDL_API_KEY']}"}

def policy_for_ip(ip: str) -> dict:
    try:
        r = httpx.get(API + ip, headers=HEADERS, timeout=1.0)
        r.raise_for_status()
        data = r.json()
    except httpx.HTTPError:
        return {"action": "allow", "reason": "lookup_unavailable"}

    t = data.get("traits") or {}
    loc = data.get("location") or {}
    if t.get("is_tor"):
        return {"action": "review", "reason": "tor_exit"}
    if t.get("is_relay"):
        coarse = loc.get("confidence") == "low" or (loc.get("accuracy_radius_km") or 0) >= 200
        return {"action": "allow", "reason": "privacy_relay", "coarse_geo": coarse}
    if t.get("is_vpn"):
        return {"action": "step_up", "reason": "known_vpn_network"}
    return {"action": "allow", "reason": None}

Preguntas frecuentes

¿Puedo bloquear yo el CSV que publica Apple?

Podés bajar la misma lista RFC 8805, pero bloquear esos prefijos es cómo los sitios dejan afuera a Safari por accidente. Detectá el rango y permitilo. Usar la API mantiene is_relay separado de is_vpn para que no heredes una blocklist de VPN que también contiene el egress de relay.

¿Funciona con IPv6?

Sí. Private Relay usa egress IPv4 e IPv6. GET /v0/ip/{ip} acepta las dos.

¿is_relay está en el plan Free?

No. Free incluye 1.000 requests por mes calendario UTC con geolocalización + ASN, 1 request por segundo, para uso personal y no comercial. Las señales de riesgo y el uso comercial están en los planes pagos. Ver la API de detección de VPN para la lista completa de traits.

Relacionado

Registrarse Leer la documentación