Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How do you send HTTP/2 requests via a proxy using Node.js?

I'd like to send HTTP/2 requests to a server via a proxy using Node.js's http2 library.

I'm using Charles v4.2.7 as a proxy, for testing purposes, but Charles is not able to proxy the request. Charles is showing Malformed request URL "*" errors, as the request it receives is PRI * HTTP/2.0 (the HTTP/2 Connection Preface). I can successfully sent HTTP/2 requests via my Charles proxy using cURL (e.g. curl --http2 -x localhost:8888 https://cypher.codes), so I don't think this is an issue with Charles, but instead an issue with my Node.js implementation.

Here's my Node.js HTTP/2 client implementation which tries to send a GET request to https://cypher.codes via my Charles proxy listening at http://localhost:8888:

const http2 = require('http2');

const client = http2.connect('http://localhost:8888');
client.on('error', (err) => console.error(err));

const req = client.request({
  ':scheme': 'https',
  ':method': 'GET',
  ':authority': 'cypher.codes',
  ':path': '/',
});
req.on('response', (headers, flags) => {
  for (const name in headers) {
    console.log(`${name}: ${headers[name]}`);
  }
});

req.setEncoding('utf8');
let data = '';
req.on('data', (chunk) => { data += chunk; });
req.on('end', () => {
  console.log(`\n${data}`);
  client.close();
});
req.end();

Here's the Node.js error I get when running node proxy.js (proxy.js is the file containing the above code):

events.js:200
      throw er; // Unhandled 'error' event
      ^

Error [ERR_HTTP2_ERROR]: Protocol error
    at Http2Session.onSessionInternalError (internal/http2/core.js:746:26)
Emitted 'error' event on ClientHttp2Stream instance at:
    at emitErrorNT (internal/streams/destroy.js:92:8)
    at emitErrorAndCloseNT (internal/streams/destroy.js:60:3)
    at processTicksAndRejections (internal/process/task_queues.js:81:21) {
  code: 'ERR_HTTP2_ERROR',
  errno: -505
}

I reran the above cURL request with verbose output and it looks like cURL first sends a CONNECT to the proxy using HTTP/1, before sending the GET request using HTTP/2.

$ curl -v --http2 -x localhost:8888 https://cypher.codes         
*   Trying ::1... 
* TCP_NODELAY set
* Connected to localhost (::1) port 8888 (#0)
* allocate connect buffer!
* Establish HTTP proxy tunnel to cypher.codes:443
> CONNECT cypher.codes:443 HTTP/1.1  
> Host: cypher.codes:443
> User-Agent: curl/7.64.1                                                                                              
> Proxy-Connection: Keep-Alive
> 
< HTTP/1.1 200 Connection established
<               
* Proxy replied 200 to CONNECT request
* CONNECT phase completed!                                                                                             
* ALPN, offering h2                                    
* ALPN, offering http/1.1                                
* successfully set certificate verify locations:                                                                       
*   CAfile: /etc/ssl/cert.pem    
  CApath: none                                                                                                         
* TLSv1.2 (OUT), TLS handshake, Client hello (1):
* CONNECT phase completed!
* CONNECT phase completed!   
* TLSv1.2 (IN), TLS handshake, Server hello (2):
* TLSv1.2 (IN), TLS handshake, Certificate (11):
* TLSv1.2 (IN), TLS handshake, Server key exchange (12):
* TLSv1.2 (IN), TLS handshake, Server finished (14):
* TLSv1.2 (OUT), TLS handshake, Client key exchange (16):                                                                                                                                                                                     
* TLSv1.2 (OUT), TLS change cipher, Change cipher spec (1): 
* TLSv1.2 (OUT), TLS handshake, Finished (20):                                                                                                                                                                                                
* TLSv1.2 (IN), TLS change cipher, Change cipher spec (1):
* TLSv1.2 (IN), TLS handshake, Finished (20):
* SSL connection using TLSv1.2 / ECDHE-RSA-AES128-GCM-SHA256
* ALPN, server accepted to use h2        
* Server certificate:                
*  subject: CN=cypher.codes
*  start date: Jun 21 04:38:35 2020 GMT
*  expire date: Sep 19 04:38:35 2020 GMT
*  subjectAltName: host "cypher.codes" matched cert's "cypher.codes"
*  issuer: CN=Charles Proxy CA (8 Oct 2018, mcypher-mbp.local); OU=https://charlesproxy.com/ssl; O=XK72 Ltd; L=Auckland; ST=Auckland; C=NZ
*  SSL certificate verify ok.
* Using HTTP2, server supports multi-use
* Connection state changed (HTTP/2 confirmed)
* Copying HTTP/2 data in stream buffer to connection buffer after upgrade: len=0
* Using Stream ID: 1 (easy handle 0x7ff50d00d600)
> GET / HTTP/2
> Host: cypher.codes
> User-Agent: curl/7.64.1
> Accept: */*
> 
...

I'd like to try doing the same via Node.js (first sending a HTTP/1 CONNECT request and then sending my HTTP/2 request on the same TCP connection), but I'm not sure how to do this. The very act of creating the HTTP/2 client (i.e. http2.connect('http://localhost:8888');) sends the HTTP/2 Connection Preface. I thought about first creating a connection using HTTP/1 (e.g. using the http library) and then upgrading this to HTTP/2, but I couldn't find any examples on how to do this.

Could someone help me send a HTTP/2 request via a proxy using Node.js?


Update (2020-07-13): I made more progress towards first creating a connection using HTTP/1, sending a CONNECT request, and then trying to send a GET request using HTTP/2 over the same socket. I can see the CONNECT request come through in Charles, but not the additional GET request, which indicates that I'm still doing something wrong when trying to use the same socket for HTTP/2 requests. Here's my updated code:

const http = require('http');
const http2 = require('http2');

const options = {
  hostname: 'localhost',
  port: 8888,
  method: 'CONNECT',
  path: 'cypher.codes:80',
  headers: {
    Host: 'cypher.codes:80',
    'Proxy-Connection': 'Keep-Alive',
    'Connection': 'Keep-Alive',
  },
};
const connReq = http.request(options);
connReq.end();

connReq.on('connect', (_, socket) => {
  const client = http2.connect('https://cypher.codes', {
    createConnection: () => { return socket },
  });
  client.on('connect', () => console.log('http2 client connect success'));
  client.on('error', (err) => console.error(`http2 client connect error: ${err}`));

  const req = client.request({
    ':path': '/',
  });
  req.setEncoding('utf8');
  req.on('response', (headers, flags) => {
    let data = '';
    req.on('data', (chunk) => { data += chunk; });
    req.on('end', () => {
      console.log(data);
      client.close();
    });
  });
  req.end();
});
like image 394
Michael C Avatar asked Dec 30 '22 23:12

Michael C


2 Answers

To tunnel HTTP/2 through a proxy that doesn't understand it, you need to use HTTP/1.1 for the initial connection, and then use HTTP/2 only in the tunnel. Your code uses HTTP/2 right from the start, which isn't going to work.

To actually make that tunnel, you first send an HTTP CONNECT request for the target host, and receive a 200 response, and then everything else on the connection in future is forwarded back and forth between you and the target host.

Once you have that tunnel working, you can send HTTP/2 (or anything else the target server understands) and it'll go straight to your target.

The code to do that in node looks like this:

const http = require('http');
const http2 = require('http2');

// Build a HTTP/1.1 CONNECT request for a tunnel:
const req = http.request({
  method: 'CONNECT',
  host: '127.0.0.1',
  port: 8888,
  path: 'cypher.codes'
});
req.end(); // Send it

req.on('connect', (res, socket) => {
  // When you get a successful response, use the tunnelled socket
  // to make your new request.
  const client = http2.connect('https://cypher.codes', {
    // Use your existing socket, wrapped with TLS for HTTPS:
    createConnection: () => tls.connect({
      socket: socket,
      ALPNProtocols: ['h2']
    })
  });

  // From here, use 'client' to do HTTP/2 as normal through the tunnel
});

I've been working on the internals of my own tool as well recently, to add full HTTP/2 support for proxying, and writing that up over here, which is probably super relevant for you. The tests for that in https://github.com/httptoolkit/mockttp/blob/h2/test/integration/http2.spec.ts have more & larger examples of tunnelling HTTP/2 in node like this, so those are definitely worth a look too. That's all still under development of course, so let me know if you have any questions or find any mistakes there.

like image 154
Tim Perry Avatar answered Jan 05 '23 15:01

Tim Perry


@TimPerry 's answer almost worked for me but it missed couple of things: authentication and how to avoid TLS certificate error.

So here is my updated version:

const http = require('http');
const http2 = require('http2');
const tls = require('tls');

// Build a HTTP/1.1 CONNECT request for a tunnel:
const username = '...';
const password = '...';
const req = http.request({
  method: 'CONNECT',
  host: '127.0.0.1',
  port: 8888,
  path: 'website.com', //the destination domain
  headers: { //this is how we authorize the proxy, skip it if you don't need it
    'Proxy-Authorization': 'Basic ' + Buffer.from(username + ':' + password).toString('base64')
  }
});
req.end(); // Send it

req.on('connect', (res, socket) => {
  // When you get a successful response, use the tunnelled socket to make your new request
  const client = http2.connect('https://website.com', {
    createConnection: () => tls.connect({
      host: 'website.com', //this is necessary to avoid certificate errors
      socket: socket,
      ALPNProtocols: ['h2']
    })
  });

  // From here, use 'client' to do HTTP/2 as normal through the tunnel
});
like image 34
Stalinko Avatar answered Jan 05 '23 16:01

Stalinko