Análisis8 min de lectura

Por qué tu agente de programación recibe «CONNECT tunnel failed, response 403»

El curl de tu agente falla con CONNECT tunnel failed, response 403 mientras otros hosts funcionan. El proxy rechazó el túnel. Cómo saber quién lo bloqueó y qué arreglar.

En esta página
code
curl: (56) CONNECT tunnel failed, response 403

Eso es curl 8.15 a 8.17 pidiendo a un proxy que abra un túnel hacia un host https:// y recibiendo una negativa. curl 8.18.0 y 8.22.0 imprimen el mismo mensaje con código de salida 7 en lugar de 56. El mismo rechazo, registrado a través de un único proxy de prueba local, se ve así en Python Requests 2.34.2, que lanza requests.exceptions.ProxyError con este mensaje:

code
HTTPSConnectionPool(host='example.org', port=443): Max retries exceeded with url: / (Caused by ProxyError('Unable to connect to proxy', OSError('Tunnel connection failed: 403 Forbidden')))

Y en el fetch integrado de Node.js 24.20.0 con NODE_USE_ENV_PROXY=1, imprimiendo un nivel de err.cause por línea:

code
depth 0: TypeError: fetch failed
depth 1: DOMException: Request was cancelled.
depth 2: RequestAbortedError [UND_ERR_ABORTED]: Proxy response (403) !== 200 when HTTP Tunneling

En los tres casos, el proxy dijo que no antes de que tu petición saliera de él. A través del mismo proxy, https://example.com/ devolvió 200 en todos los clientes. Solo cambió el host.

Qué significa el 403

Para una URL https://, el cliente envía CONNECT example.org:443 al proxy y espera permiso para abrir un túnel. El RFC 9110 dice que cualquier respuesta 2xx pasa la conexión a modo túnel y que «cualquier respuesta que no sea satisfactoria indica que el túnel todavía no se ha formado». Un 403 es el proxy negándose a abrir ese túnel. RFC 9110

De ahí salen dos cosas:

  • No es un problema de credenciales. Un proxy que quiere credenciales responde 407 con un desafío Proxy-Authenticate; la guía del 407 cubre ese caso. Un 403 significa que el proxy decidió no reenviar esta petición, así que cambiar la contraseña no servirá.
  • El sitio de destino nunca se alcanzó. Sin túnel no hay handshake TLS ni petición a example.org. El write-out de curl lo muestra: connect=403 es la respuesta del proxy y http=000 significa que el sitio nunca respondió.

Por qué les pasa a los agentes de programación

Los sandboxes y los runners de CI suelen enviar todo el tráfico de salida a través de un proxy de egress con una lista de permitidos. La dirección del proxy llega en HTTPS_PROXY, así que curl, pip, npm y fetch pasan todos por él. Los hosts de la lista obtienen un túnel; todo lo demás recibe un 403. Por eso un host funciona y el siguiente falla desde la misma shell.

Dos informes públicos muestran este patrón:

  • En anthropics/claude-code#56959, Claude Code ejecutándose con Bedrock tenía HTTPS_PROXY apuntando al propio proxy del sandbox en localhost. Ese proxy permitía cuatro hosts (bedrock-runtime.us-east-1.amazonaws.com, api.anthropic.com y dos patrones de Sentry) y, en palabras de quien lo reportó, «cualquier otro host devuelve HTTP 403 desde el proxy local». curl https://github.com fallaba con curl: (56) CONNECT tunnel failed, response 403. Quien lo reportó dijo que las entradas de sandbox.network.allowedDomains en la configuración de usuario y en la gestionada se ignoraban en ese modo. La incidencia se cerró como duplicada de #37970.
  • En SocketDev/socket-sdk-js#659, un agente que ejecutaba una actualización semanal de dependencias en CI no pudo terminar. La herramienta de actualización, taze, buscaba versiones en npm.antfu.dev, que «bloquea el firewall del sandbox de CI (403 CONNECT tunnel failed)». curl a ese host devolvió HTTP 000 con el error 56 de curl, mientras que curl https://registry.npmjs.org/semver devolvió 200. Los mantenedores lo arreglaron manteniendo las búsquedas en registry.npmjs.org, que ya estaba en la lista de permitidos.

Cómo saber quién dijo que no

Ejecuta una vez la URL que falla con curl -v a través del mismo proxy. Las líneas que empiezan por > son lo que envió curl; las que empiezan por < antes de CONNECT tunnel failed son la respuesta del proxy. Esto es curl 8.16.0 contra el proxy de prueba:

code
*   Trying 127.0.0.1:41745...
* CONNECT: no ALPN negotiated
* allocate connect buffer
* Establish HTTP proxy tunnel to example.org:443
> CONNECT example.org:443 HTTP/1.1
> Host: example.org:443
> User-Agent: curl/8.16.0
> Proxy-Connection: Keep-Alive
> 
< HTTP/1.1 403 Forbidden
< Content-Type: text/plain
< Content-Length: 32
< Connection: close
< 
* CONNECT tunnel failed, response 403
* closing connection #0
curl: (56) CONNECT tunnel failed, response 403

La línea Trying nombra al proxy, no al sitio. El 403 llega como respuesta al CONNECT y no hay líneas de TLS después, así que el sitio nunca participó. Si en cambio un 403 aparece después de un CONNECT correcto y un handshake TLS, viene del sitio web y el proxy no es el problema.

Después compara un host permitido y uno bloqueado a través del mismo proxy, usando el write-out para separar los dos códigos de estado:

sh
curl --disable -sS -o /dev/null -x http://127.0.0.1:41745 -w 'http=%{http_code} connect=%{http_connect} exit=%{exitcode}\n' https://example.com/
curl --disable -sS -o /dev/null -x http://127.0.0.1:41745 -w 'http=%{http_code} connect=%{http_connect} exit=%{exitcode}\n' https://example.org/

Con curl 8.18.0 el primero imprimió http=200 connect=200 exit=0. El segundo imprimió http=000 connect=403 exit=7 y curl: (7) CONNECT tunnel failed, response 403. Mismo cliente, mismo proxy, distinto host: el proxy tiene una regla por host. Repite la comparación con tus propios hosts permitido y bloqueado. En el sandbox de un agente, usa un host que sepas que funciona, como el registro de paquetes al que el agente ya llegó.

Por último, comprueba qué proxy usa realmente el proceso. Imprime HTTPS_PROXY, HTTP_PROXY, NO_PROXY y sus variantes en minúsculas en la misma shell o el mismo paso del job que falla. Un host incluido en NO_PROXY se salta el proxy y conecta directamente, algo que un sandbox puede bloquear de otra manera. La guía de variables de entorno de proxy explica cómo eligen los clientes entre estas variables, y NO_PROXY matching, tested muestra que los clientes no coinciden en qué encaja con una entrada de NO_PROXY. El fetch integrado de Node ignora estas variables salvo que se defina NODE_USE_ENV_PROXY=1 o --use-env-proxy, así que un script de Node y un comando curl en la misma shell pueden tomar rutas distintas. Documentación de la CLI de Node.js

Arréglalo sin rodear el proxy

El 403 es una decisión de política, así que el arreglo está en el lado de la política o en el de la fuente:

  • Permite el host. Añade el dominio exacto que necesita la herramienta a la lista de permitidos de egress del sandbox o del CI. Toma el dominio de la línea CONNECT de curl -v o del propio error de la herramienta, no de su configuración: en el informe de Socket, taze buscaba versiones en npm.antfu.dev, no en el registro de npm.
  • Usa una fuente que ya esté permitida. Un espejo, un registro interno o el registro oficial que la política ya permite suele evitar el cambio de política. Ese fue el arreglo de Socket.
  • Revisa el puerto. Algunos proxies solo abren túneles al puerto 443. La configuración sugerida de Squid incluye http_access deny CONNECT !SSL_ports, con SSL_ports definido como el puerto 443. Squid http_access En el proxy de prueba, que permitía example.com solo en el puerto 443, https://example.com:8443/ recibió el mismo CONNECT tunnel failed, response 403 que el host bloqueado.
  • Revisa las reglas de destino del proveedor. Los proveedores comerciales de proxies también pueden rechazar destinos. El catálogo de errores de Bright Data enumera errores de política bajo HTTP 403, por ejemplo «You tried to target %HOST% which is blocked by Bright Data policy». Catálogo de errores de Bright Data Decodo enumera categorías de destinos, como sitios bancarios y gubernamentales, que restringe por defecto. Destinos restringidos de Decodo

No rodees las reglas de egress de un sandbox. Quitar HTTPS_PROXY, añadir el host a NO_PROXY o enrutar por otro proxy para saltarte una lista de permitidos anula el control que alguien puso a propósito, y puede que simplemente no funcione: quien reportó #56959 comprobó que el sandbox también bloqueaba el acceso directo a la red por debajo del proxy. Pide a quien gestiona el sandbox o el runner de CI que permita el host y dale la línea CONNECT exacta de curl -v. El agente del informe de Socket hizo justo eso: se detuvo, informó del host bloqueado y no intentó evadir el firewall.

Por qué curl informa distinto con http://

Para una URL http:// no hay CONNECT. curl envía la petición completa al proxy, y el 403 del proxy es la respuesta a esa petición. El proxy de prueba respondió 403 en ambos casos, pero curl los informó de forma distinta:

A través del mismo proxycurl 8.15.0 a 8.17.0curl 8.18.0 y 8.22.0
https://example.org/ (bloqueado)salida 56, curl: (56) CONNECT tunnel failed, response 403salida 7, curl: (7) CONNECT tunnel failed, response 403
http://example.org/ (bloqueado)salida 0, la página 403 del proxy impresa como cuerposalida 0, la página 403 del proxy impresa como cuerpo
http://example.org/ con --failsalida 22, curl: (22) The requested URL returned error: 403salida 22, curl: (22) The requested URL returned error: 403
https://example.com/ (permitido)salida 0, http=200 connect=200salida 0, http=200 connect=200

En el caso http://, curl -v muestra > GET http://example.org/ HTTP/1.1 seguido de < HTTP/1.1 403 Forbidden, y curl imprime el cuerpo. El mantenedor de curl Daniel Stenberg describió la diferencia en la discusión de curl #15718, que trata de un 407 pero se aplica a cualquier rechazo: no lograr establecer el túnel significa que la transferencia no se realiza, lo cual es un fallo, mientras que un GET a un proxy que devuelve una respuesta «es una transferencia correcta que contiene un código 4xx». Por eso una URL http:// en un script sin --fail puede parecer un éxito.

Los demás clientes se dividen igual, con una excepción. Python Requests devolvió un Response normal con estado 403 para http://example.org/. El fetch de Node.js 24.20.0 con NODE_USE_ENV_PROXY=1 abrió un túnel CONNECT example.org:80 incluso para la URL http://, así que falló con la misma causa Proxy response (403) !== 200 when HTTP Tunneling.

Un detalle más de versiones: la compilación estática de curl 8.22.0 que usamos incluye HTTP/3. Cuando el host anunciaba HTTP/3, primero intentó un túnel CONNECT-UDP, así que -v mostró dos respuestas 403 antes del curl: (7) CONNECT tunnel failed, response 403 final.

Reprodúcelo

Todo lo anterior se registró el 2 de octubre de 2026 con el laboratorio de la carpeta de descarga: un proxy de Node.js en 127.0.0.1 que solo abre túneles a example.com en el puerto 443 y responde 403 a cualquier otro CONNECT y a las peticiones HTTP normales, con curl 8.15.0, 8.16.0, 8.17.0, 8.18.0 y 8.22.0, Python 3.14.4 con Requests 2.34.2 y urllib3 2.8.0, y Node.js 24.20.0. La salida en bruto está en results.json. El cuerpo 403 del proxy de prueba (403 Forbidden: lab proxy policy) es suyo; el proxy real de un sandbox o de un proveedor enviará otro texto.

Relacionado

ipvolt está construyendo infraestructura de proxies para desarrolladores y agentes. Únete a la lista de espera para recibir un correo cuando se abra el acceso.

Fuentes

  1. RFC 9110: CONNECT
  2. anthropics/claude-code#56959: sandbox allowlist answering 403 to CONNECT
  3. SocketDev/socket-sdk-js#659: CI sandbox firewall blocking one host
  4. curl discussion #15718: proxy responses for http and https URLs
  5. Squid configuration: http_access suggested rules
  6. Bright Data: proxy error catalog
  7. Decodo: residential proxy restricted targets
  8. Node.js CLI: NODE_USE_ENV_PROXY

Etiquetas:ProxiesTroubleshooting