Fix IPv6 literal addresses bypassing no_proxy when outbound proxy is set

What does this MR do and why?

Fix IPv6 literal addresses bypassing no_proxy when outbound proxy is set

Two bugs caused webhook test to return 500 for IPv6 targets when an outbound proxy was configured:

  1. URI::Generic.use_proxy? uses a regex that splits on colons, so it cannot parse IPv6 literal addresses in no_proxy (e.g. 2001:db8::1 gets split and only matches "1").

    Fix: detect IPv6 hostnames via Resolv::IPv6::Regex and route them through a new ipv6_excluded_by_no_proxy? helper that parses all three valid no_proxy formats (bare, bracketed, bracketed+port) and uses IPAddr for comparison so compressed and expanded representations match.

  2. connect_patch.rb sent unbracketed IPv6 in the CONNECT tunnel request (CONNECT 2001:db8::1:8080), which is malformed per RFC 7230 and causes real proxies (Squid, Nginx) to respond with 400.

    Fix: bracket the address when it matches Resolv::IPv6::Regex.

References

#593733 (closed)

How to set up and validate locally

This section describes how to manually test the IPv6 proxy fix using a minimal Node.js proxy server.

Step 1: Create the proxy server

Save the following Node.js script as test-proxy.js:

NodeJS Proxy Server Code
#!/usr/bin/env node
// Minimal HTTP/HTTPS proxy for manual testing.
// No dependencies — requires only Node.js.
//
// Usage:
//   node mr-test-proxy.js           # listens on port 3128
//   PORT=3128 node mr-test-proxy.js

const http = require('http');
const net  = require('net');
const { URL } = require('url');

const PORT = parseInt(process.env.PORT || '3128', 10);

const server = http.createServer((req, res) => {
  console.log(`>> ${req.method} ${req.url}`);
  let target;
  try { target = new URL(req.url); } catch {
    res.writeHead(400); res.end('Bad Request'); return;
  }
  const hostname = target.hostname.replace(/^\[|\]$/g, '');
  const upstream = http.request(
    { hostname, port: target.port || 80, path: target.pathname + target.search, method: req.method, headers: req.headers },
    (upstream_res) => { res.writeHead(upstream_res.statusCode, upstream_res.headers); upstream_res.pipe(res); }
  );
  upstream.on('error', () => { res.writeHead(502); res.end('Bad Gateway'); });
  req.pipe(upstream);
});

server.on('connect', (req, client, head) => {
  console.log(`>> CONNECT ${req.url}`);
  // Parse host:port — IPv6 must be bracketed per RFC 7230: [::1]:443
  const m = req.url.match(/^\[([^\]]+)\]:(\d+)$/) || req.url.match(/^([^:]+):(\d+)$/);
  if (!m) {
    console.log(`!! CONNECT ${req.url} — 400 Bad Request (malformed, unbracketed IPv6?)`);
    client.write('HTTP/1.1 400 Bad Request\r\n\r\n'); client.destroy(); return;
  }
  const [, host, port] = m;
  const upstream = net.connect(parseInt(port), host, () => {
    client.write('HTTP/1.1 200 Connection Established\r\n\r\n');
    if (head?.length) upstream.write(head);
    upstream.pipe(client); client.pipe(upstream);
    console.log(`<< CONNECT ${req.url} 200 OK`);
  });
  upstream.on('error', (e) => { console.log(`!! ${e.message}`); client.write('HTTP/1.1 502 Bad Gateway\r\n\r\n'); client.destroy(); });
  client.on('error', () => upstream.destroy());
});

server.listen(PORT, () => console.log(`Proxy listening on http://[::1]:${PORT}\n`));

Step 2: Start the proxy server

node test-proxy.js

The proxy will listen on http://[::1]:3128.

Step 3: Test with proxy (request should go through proxy)

Start a Rails console with the proxy configured:

http_proxy=http://[::1]:3128 https_proxy=http://[::1]:3128 bin/rails c

Make a request to an IPv6 address:

Gitlab::HTTP.get('https://[2607:f8b0:400a:800::200e]', verify: false).body

Expected behavior:

  • With fix: Request goes through the proxy and returns the response body
  • Without fix: Request returns 400 "Bad Request" (malformed IPv6 in CONNECT request)

Step 4: Test with no_proxy (request should bypass proxy)

Start a Rails console with both proxy and no_proxy configured:

http_proxy=http://[::1]:3128 https_proxy=http://[::1]:3128 no_proxy=[2607:f8b0:400a:800::200e] bin/rails c

Make the same request:

Gitlab::HTTP.get('https://[2607:f8b0:400a:800::200e]', verify: false).body

Expected behavior:

  • With fix: Request bypasses the proxy and returns the response body
  • Without fix: Request goes through the proxy and returns 400 "Bad Request"

Note

The proxy server simulates real proxy behavior (Nginx, Squid) by returning 400 Bad Request when it receives a malformed IPv6 address in the CONNECT tunnel request.

MR acceptance checklist

Evaluate this MR against the MR acceptance checklist. It helps you analyze changes to reduce risks in quality, performance, reliability, security, and maintainability.

Edited by Rodrigo Tomonari

Merge request reports

Loading