Man in the middle proxying

Nearly a decade ago, I’ve written a blog on creating a debugging proxy server. Even though I could get away with a simple HTTP setup back in the day, web has changed a lot and almost every web page has to run on HTTPS to take full advantage of the latest web APIs. With the further addition of Strict-Transport-Security, browsers prevent sites from being accessed on HTTP once they are upgraded to HTTPS by this response header.

It is not possible to eavesdrop to HTTPS communication as a man-in-the-middle, b/c the transport is protected end to end by public key cryptography, thus the name Transport Layer Security (TLS). (In fact, only the key exchange is asymmetric and once the key is shared, the actual communication happens with symmetric encryption using that exchanged key) Then, how is it possible to see the contents of the secure stream? What we will be doing will be creating SSL certificates on behalf of the original domain owners. These certificates are protected by a chain of trust, which depends back to a single “root” Certificate Authority (CA) that’s trusted by your browser and operating system.

In our case, we’ll create our own CA certificate and basically make the OS/browser trust it, which is pretty common for HTTPS proxying tools. We’ll first generate a private key for our root CA.

You’ll need openssl cli for this to work. The first command will ask you a passphrase to keep it secure. Provide it and make sure no one can get a hold of this private key because once you trust it, anyone with that key can issue SSL certificates that your system now trusts. The second command creates the root certificate in pem format that you will later install and trust on your computer. It will ask you the passphrase. (Check your OS’s documentation on how to trust the root CA. e.g this explains it for MacOS, and here for Windows)

openssl genrsa -des3 -out rootCA.key 2048
openssl req -x509 -new -nodes -key rootCA.key -sha256 -days 30 -out rootCA.pem

-days parameter will set how long it will be valid, and you can provide a common name when asked to recognize the certificate easier. You can leave other inputs empty. Put this rootCA.pem file somewhere you know on your computer, we will reference it from our code.

To do the actual proxying using node.js, let’s first import all the dependencies and create an HTTP server running at port 8888. This will work both as an HTTP and HTTPS proxy. In case of HTTP, the requests will be sent to the proxy with the path set to the target URL and the requestHandler will directly handle them. Notice we are completely free on what we do in it. request is the original request, which is not sent to the original recipient yet and response is the proxy server’s response that we’ll be crafting. With the following code any HTTP request arriving at our proxy will go through this handler.

const http = require('node:http');
const tls = require('node:tls');
const fs = require('node:fs');
const net = require('node:net');
// This 3rd party library is used to issue SSL certificates on behalf of original request domains
const pem = require('pem');
const url = require("url");

// This is the main proxy handler we'll be using. This is the "working end" of the whole
// proxy and where we will be doing our request/response manipulation.
function requestHandler(serverTLSSocket, request, response) {
  const parsedUrl = url.parse(request.url);
  // Create a new request. We could also target it for a different host, or read it from a file etc.
  const req = http.request({
    // This will be clear with TLS proxying and is only relevant for HTTPS proxying
    // It simply means that node is using this socket instead of creating a new one.
    createConnection: serverTLSSocket ? () => serverTLSSocket : undefined,
    host: request.headers.host,
    // We can include anything else in here, this is just an example
    path: (parsedUrl.pathname ?? "") + (parsedUrl.search ?? ""),
    method: request.method,
    headers: request.headers
  }, async (res) => {

    response.writeHead(res.statusCode, res.headers);
    response.flushHeaders();

    // We could delay the remainder of the response here, if we wanted to:
    // await new Promise((resolve) => setTimeout(resolve, 1000));
    res.on('data', (chunk) => {
      // We have full access to the buffering behaviour of the response here
      response.write(chunk);
    });
    res.on('end', () => {
      response.end();
    });
  });

  req.on('error', (e) => {
    console.error(`problem with request: ${e.message}`);
    response.writeHead(502, "Bad Gateway");
    response.end();
  });

  // Write original data to request body
  request.body && req.write(request.body);
  req.end();
}

const server = http.createServer(requestHandler.bind(null, null)).listen(8888);

For HTTPS proxying, things get a little more involved. What happens under the hood is that the client sends a CONNECT request to the proxy via regular HTTP. This is specifically handled by node via the connect event. The simplest way to respond to a CONNECT is literally connecting the socket to the target computer and relaying the remaining TCP communication back and forth. We do this when we simply don’t want to eavesdrop on the communication based on a simple host check via skipHttps. If we were to directly try to read this stream, we would only see a lot of encrypted garbage.

function skipHttps(req) {
  const [host, port] = req.url.split(":");
  // As an example only proxy https requests to Google
  return host !== "google.com" && host !== "www.google.com";
}

function handlePassthrough(req, clientSocket, bodyhead) {
  const [host, port] = req.url.split(":");
  console.log("Passthrough to", host);

  let connEstablished = false;

  // Create a socket to the actual target computer
  var proxySocket = new net.Socket();
  proxySocket.connect(port, host, function () {
    proxySocket.write(bodyhead);

    // Let the client know the CONNECT request was successful
    clientSocket.write("HTTP/" + req.httpVersion + " 200 Connection established\r\n\r\n");
    connEstablished = true;
  });

  // Relay two streams to each other
  proxySocket.pipe(clientSocket);
  proxySocket.on('error', function () {
    if (!connEstablished) {
      // Let the client know CONNECT has failed before any transmission happened
      clientSocket.write("HTTP/" + req.httpVersion + " 500 Connection error\r\n\r\n");
    }
    clientSocket.end();
  });

  clientSocket.pipe(proxySocket);
  clientSocket.on('error', function () {
    proxySocket.end();
  });
}

server.on('connect', function(req, clientSocket, bodyhead) {
  if (skipHttps(req)) {
    handlePassthrough(req, clientSocket, bodyhead);
  } else {
    handleHTTPS(req, clientSocket, bodyhead);
  }
});

Finally, the part where we actually eavesdrop on the communication. After the CONNECT is handled, the client will start the TLS handshake as if the connection was directly made to the target computer. In reality it will be talking to our TLS server with a certificate that’s signed by our root CA. We do two important things here. First; we actually connect to the target computer via a regular TLS connections (serverTLSSocket via tls.connect) and second; wrap the original socket (clientSocket) with a TLS socket (clientTLSSocket via tls.TLSSocket). This new TLSSocket will do the handshake over the original socket (clientSocket) with our client (and thus it is configured as isServer) and whenever we read or write to this stream, it will be decrypted/encrypted with the freshly created certificate. From our client’s viewpoint all communication is secure via the trusted root CA certificate and we talk to the actual target server via regular TLS, doing a fresh request in our requestHandler.

function handleHTTPS(req, clientSocket, bodyhead) {
  console.log("HTTPS proxying to", host, port);

  // TODO: cache these certificates and re-use
  pem.createCertificate({
    days: 1,
    serviceKey: fs.readFileSync('<path to rootCA.key>'),
    serviceCertificate: fs.readFileSync('<path to rootCA.pem>'),
    // DO NOT LEAK THIS
    clientKeyPassword: '<rootCA password>',
    altNames: [host],
    minVersion: 'TLSv1.2',
  }, function (err, keys) {
    let connEstablished = false;
    let clientTLSSocket;

    function handleConnError() {
      if (!connEstablished) {
        // Notice how we write to the clientSocket, not the clientTLSSocket
        clientTLSSocket.write("HTTP/" + req.httpVersion + " 500 Connection error\r\n\r\n");
      }
    }

    if (err) {
      console.log("Error creating certificate", err);
      handleConnError();
    }

    const serverTLSSocket = tls.connect({
      port,
      host,
      servername: host,
      enableTrace:false
    }, function () {
      console.log("Connected to target, initiate tunnel");

      // Upgrade to TLS
      clientTLSSocket = new tls.TLSSocket(clientSocket, {
        isServer: true,
        key: keys.clientKey.toString(),
        cert: keys.certificate,
        minVersion: 'TLSv1.2',
      });

      // Notice how we don't pipe anything here, it will be the decision of our handler
      // including closing of the sockets, which is not implemented here
      // OTOH, if there is an error on client connection, we end the server connection here.
      clientTLSSocket.on('error', function (err) {
        handleConnError();
        serverTLSSocket.end();
      });

      // This is where we create a dummy server as an adapter to our `requestHandler`
      const dummyHttpServer = http.createServer(requestHandler.bind(null, serverTLSSocket));
      dummyHttpServer.emit('connection', clientTLSSocket);

      // Let the client know it can write to the connection now.
      clientSocket.write("HTTP/" + req.httpVersion + " 200 Connection established\r\n\r\n");
      serverTLSSocket.write(bodyhead);
      connEstablished = true;
    });

    serverTLSSocket.on('error', function (err) {
      console.log("Error connecting to target", err);
      handleConnError();
      clientTLSSocket?.end();
    });
  })
}

The final trick is creating a node HTTP server on this new transparent clientTLSSocket. Notice how we emit a connection event to it instead of making it listen. This effectively makes the server read from this socket -which will have the decrypted raw request- as usual and call our requestHandler. If that handler now writes to the response stream, it will be written to this socket (clientTLSSocket) where it will get encrypted to be sent to the client. To enable the handler talk to the original target server, the serverTLSSocket is also passed in to our handler. When doing the proxied request, it is possible to use the serverTLSSocket by providing it at the createConnection option so that we can actually do the actual request and manipulate it as we like.

This wraps up the HTTPS functionality for the debugging proxy even if a little late 🙂. It is not complete nor perfect and some of the missing things are; handling socket closure and potentially respecting Keep-Alive headers in the handler. Also for simplicity I’ve used the deprecated url implementation. The modern way of doing it is the web compliant URL constructor, but it doesn’t parse properly unless it is a fully qualified URL and that makes the code much more complicated. I’m pretty sure I miss some other stuff in this code as well. Feel free to reach me out on Twitter for your comments and corrections.

© Ali Naci Erdem 2025