Detectar registros sospechosos por IP en Python: Tor, hosting y VPN conocidas
Una receta para FastAPI y Flask que convierte los traits de la IP en permitir, verificar o revisar, sin dejar afuera a usuarios reales.
Gratis: 1.000 req/día · HTTPS · uso comercial OK
Por qué bloquear de una a quien usa VPN te sale caro
Mucha gente legítima se registra desde una VPN, un relay de privacidad o el celular con datos móviles. Si los bloqueás de entrada perdés clientes reales, y el que quiere abusar simplemente cambia de IP. Funciona mejor tomar la IP como una señal de riesgo: sumar fricción (verificar el email, un CAPTCHA, revisión manual) cuando se juntan señales, y guardar los motivos en el usuario para ajustar después.
Obtené la IP real en FastAPI o Flask
Detrás de Nginx o un balanceador, la dirección del socket es la de tu proxy. Decile al framework en qué proxy confiar y nunca parsees X-Forwarded-For a mano: el cliente puede poner cualquier IP ahí.
# FastAPI / Starlette behind one trusted proxy (Uvicorn)
# uvicorn app:app --proxy-headers --forwarded-allow-ips="10.0.0.5"
from fastapi import Request
def client_ip(request: Request) -> str:
# With --proxy-headers, request.client.host is the real visitor IP
return request.client.host
# Flask behind one proxy from flask import Flask from werkzeug.middleware.proxy_fix import ProxyFix app = Flask(__name__) app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1) # trust exactly one hop # request.remote_addr is now the real visitor IP
Consultá la IP
Una llamada a GET /v0/ip/{ip} con tu key en Authorization: Bearer te devuelve ubicación, red y traits. Este cliente async usa httpx, timeout de un segundo y una caché chica por IP, y devuelve None si algo falla, para que el registro nunca se rompa:
# ndl.py
import os, time
import httpx
API = "https://api.networkdatalabs.com/v0/ip/"
HEADERS = {"Authorization": f"Bearer {os.environ['NDL_API_KEY']}"}
_client = httpx.AsyncClient(timeout=httpx.Timeout(1.0), headers=HEADERS)
_cache: dict[str, tuple[float, dict]] = {}
TTL = 6 * 3600
async def lookup(ip: str) -> dict | None:
hit = _cache.get(ip)
if hit and hit[0] > time.time():
return hit[1]
try:
r = await _client.get(API + ip)
r.raise_for_status()
except httpx.HTTPError:
return None # fail open
data = r.json()
_cache[ip] = (time.time() + TTL, data)
return data
Los traits que importan en un registro:
is_tor: la IP es un exit node de Tor actualis_vpn: redes VPN conocidas, incluidos los principales proveedores comerciales de VPNis_relay: salida de un relay de privacidadis_hosting: detección de hosting/datacenteris_mobile: redes de operadores móviles (estimación)
Una tabla de política: permitir, verificar o revisar
| Señal | Qué significa | Acción sugerida |
|---|---|---|
is_tor | Exit node de Tor actual | Revisión manual |
is_vpn | Redes VPN conocidas, incluidos los principales proveedores comerciales de VPN | Verificar email |
is_hosting | IP de hosting/datacenter: muchas veces scripts y bots | Verificar email (o CAPTCHA) |
is_relay | Relay de privacidad: suele ser un usuario común que cuida su privacidad | Permitir y registrar |
is_mobile | Redes de operadores móviles: muchos usuarios comparten IP | Permitir; no limitar solo por IP |
| País distinto + confianza high/medium | El país de la IP no coincide con el declarado | Verificar; revisión si se suma otra señal |
| Dos o más señales | Riesgo acumulado | Revisión manual |
La misma tabla en código, con un chequeo de país que solo cuenta cuando confidence es high o medium:
# risk.py
def assess(data: dict | None, declared_country: str | None = None) -> tuple[str, list[str]]:
"""Return ("allow" | "verify" | "review", reasons)."""
if data is None:
return "allow", ["lookup_unavailable"]
t = data.get("traits", {})
loc = data.get("location", {})
reasons = []
if t.get("is_tor"):
reasons.append("tor_exit")
if t.get("is_vpn"):
reasons.append("known_vpn_network")
if t.get("is_relay"):
reasons.append("privacy_relay")
if t.get("is_hosting"):
reasons.append("hosting_datacenter")
country = loc.get("country_code")
confident = loc.get("confidence") in ("high", "medium")
if declared_country and country and confident and country != declared_country:
reasons.append("country_mismatch")
if "tor_exit" in reasons or len(reasons) >= 2:
return "review", reasons
if reasons and reasons != ["privacy_relay"]:
return "verify", reasons
return "allow", reasons
Conectalo al endpoint de registro
# app.py
from fastapi import FastAPI, Request
from pydantic import BaseModel
from ndl import lookup
from risk import assess
app = FastAPI()
class Signup(BaseModel):
email: str
country: str | None = None # from the billing/profile form, e.g. "US"
@app.post("/signup")
async def signup(body: Signup, request: Request):
ip = request.client.host
decision, reasons = assess(await lookup(ip), body.country)
user = create_user(body.email, signup_ip=ip, risk=reasons)
if decision == "verify":
send_verification_email(user) # extra step, not a wall
elif decision == "review":
queue_for_review(user)
return {"status": "ok"}
En Flask es la misma idea: llamás a assess() con request.remote_addr dentro de la vista (con un httpx.Client sincrónico).
Probalo con una IP de ejemplo de EE. UU.
import asyncio
from ndl import lookup
from risk import assess
async def main():
data = await lookup("8.8.8.8")
print(data["location"]["country_code"], data["location"]["city"]) # US Mountain View
print(data["traits"]["is_hosting"]) # True
print(assess(data, "US")) # ('verify', ['hosting_datacenter'])
asyncio.run(main())
8.8.8.8 es un resolver DNS público en un datacenter, así que is_hosting da true y el registro cae en verify. El país (US, confianza medium) coincide con el declarado, así que no suma mismatch. Para el resto de la lógica, testeá assess() con diccionarios de ejemplo en vez de IPs reales:
def test_tor_goes_to_review():
data = {"location": {"country_code": "US", "confidence": "high"},
"traits": {"is_tor": True}}
assert assess(data, "US")[0] == "review"
def test_residential_us_is_allowed():
data = {"location": {"country_code": "US", "confidence": "high"},
"traits": {}}
assert assess(data, "US") == ("allow", [])
Puntuá a los usuarios que ya tenés, en bulk
Para calcular el riesgo de tu base actual, mandá las IPs de registro a POST /v0/bulk, hasta 100 por pedido. Cada IP cuenta para la cuota diaria, y si una IP está mal formada aparece en errors sin hacer fallar el lote.
import httpx, os
def enrich(ips: list[str]) -> dict:
out = {}
with httpx.Client(headers={"Authorization": f"Bearer {os.environ['NDL_API_KEY']}"}) as c:
for i in range(0, len(ips), 100): # max 100 IPs per request
r = c.post("https://api.networkdatalabs.com/v0/bulk",
json={"ips": ips[i:i + 100]}, timeout=10)
r.raise_for_status()
out.update(r.json()["results"]) # per-IP failures are in "errors"
return out
Checklist para producción
- La key queda en el servidor, en
NDL_API_KEY. - Fail-open ante timeouts y errores; registrá
lookup_unavailable. - Guardá los motivos en el usuario, no solo la decisión.
- Volvé a chequear en momentos sensibles (pago, retiro, cambio de contraseña), no solo al registrarse.
Relacionado
- API detección de VPN: flags de redes VPN conocidas, exit de Tor, relay de privacidad y hosting
- API detección de fraude IP: señales para registro, login y pagos
- Obtener la IP y el país del visitante en Express
- API Docs