Skip to content

Commit e9e77a9

Browse files
committed
Fix raw passthrough containing TLS
We now have a confusing scenario with TLS passthrough (non-intercepted) raw passthrough (fully intercepted) and raw-passthrough-with-tls (fully intercepted, but we unwrap and then recreate TLS so we can see everything). Previously the last case there didn't work: we unwrapped TLS, then forwarded the traffic it contained to the destination without TLS. Bad for security, but also in practice extremely likely to not work at all. We now recreate the TLS connection on the passthrough, so unknown-over-TLS should work as expected with TLS on both sides but traffic still inspectable (cool right). This allows us to do things like secure MQTT, at least in theory (in practice it's unclear how tricky cert trust will be in scenarios like that).
1 parent 28c63e2 commit e9e77a9

File tree

2 files changed

+172
-52
lines changed

2 files changed

+172
-52
lines changed

src/server/mockttp-server.ts

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import cors = require("cors");
1414
import now = require("performance-now");
1515
import WebSocket = require("ws");
1616
import { Mutex } from 'async-mutex';
17-
import { ErrorLike, isErrorLike } from '@httptoolkit/util';
17+
import { ErrorLike, isErrorLike, UnreachableCheck } from '@httptoolkit/util';
1818

1919
import {
2020
Destination,
@@ -1186,7 +1186,31 @@ ${await this.suggestRule(request)}`
11861186

11871187
setImmediate(() => this.eventEmitter.emit(`${type}-passthrough-opened`, eventData));
11881188

1189-
const upstreamSocket = net.connect({ host: hostname, port: targetPort });
1189+
let upstreamSocket;
1190+
if (type === 'raw' && socket[LastHopEncrypted]) {
1191+
// Awkward edge case. If we are passing through raw data, but we've already unwrapped TLS beforehand,
1192+
// we need to recreate the TLS for the passthrough. This is more art than science but we can
1193+
// get pretty close to simulating the original incoming configuration:
1194+
upstreamSocket = tls.connect({
1195+
host: hostname,
1196+
port: targetPort,
1197+
servername: socket[TlsMetadata]?.sniHostname,
1198+
// We have to mirror the ALPN protocols, which might be messy since we've actually already
1199+
// negotiated one. Here we blindly hope (!) that we end up on the same page:
1200+
ALPNProtocols: socket[TlsMetadata]?.clientAlpn,
1201+
1202+
// We have no way to know what certs the client trusts and no config options for this yet, so
1203+
// we just make do with default trust settings here in all cases.
1204+
});
1205+
} else if (type === 'tls' || type === 'raw') {
1206+
// For raw traffic we pass through raw - not surprising. For TLS traffic this might be surprising,
1207+
// but it's because a TLS tunnel is when we _don't_ terminate TLS ourselves, so we can't get inside
1208+
// the tunnel at all here.
1209+
upstreamSocket = net.connect({ host: hostname, port: targetPort });
1210+
} else {
1211+
throw new UnreachableCheck(type);
1212+
}
1213+
11901214
upstreamSocket.setNoDelay(true);
11911215

11921216
socket.pipe(upstreamSocket);

test/integration/proxying/unknown-protocol.spec.ts

Lines changed: 146 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import * as net from 'net';
2+
import * as tls from 'tls';
23
import * as http2 from 'http2';
34
import { expect } from "chai";
5+
import { TlsHelloData, trackClientHellos } from 'read-tls-client-hello';
46

57
import { getLocal } from "../../..";
68
import {
@@ -9,96 +11,190 @@ import {
911
makeDestroyable,
1012
nodeOnly,
1113
openRawSocket,
12-
delay,
1314
getHttp2Response,
14-
cleanup
15+
cleanup,
16+
openRawTlsSocket,
17+
DestroyableServer
1518
} from "../../test-utils";
19+
import { getCA } from '../../../src/util/certificates';
1620

1721
nodeOnly(() => {
1822
describe("Unknown protocol handling", () => {
1923

2024
describe("with SOCKS & unknown protocol passthrough enabled", () => {
2125

2226
let server = getLocal({
27+
https: {
28+
keyPath: './test/fixtures/test-ca.key',
29+
certPath: './test/fixtures/test-ca.pem',
30+
},
2331
socks: true,
2432
passthrough: ['unknown-protocol']
2533
});
2634

27-
// Simple TCP echo server:
28-
let remoteServer = makeDestroyable(net.createServer((socket) => {
29-
socket.on('data', (data) => {
30-
socket.end(data);
31-
});
32-
}));
33-
let remotePort!: number;
34-
3535
beforeEach(async () => {
3636
await server.start();
3737

38-
remoteServer.listen();
39-
await new Promise((resolve, reject) => {
40-
remoteServer.on('listening', resolve);
41-
remoteServer.on('error', reject);
42-
});
43-
remotePort = (remoteServer.address() as net.AddressInfo).port;
44-
4538
// No unexpected errors here please:
4639
await server.on('tls-client-error', (e) => expect.fail(`TLS error: ${e.failureCause}`));
4740
await server.on('client-error', (e) => expect.fail(`Client error: ${e.errorCode}`));
4841
});
4942

5043
afterEach(async () => {
5144
await server.stop();
52-
await remoteServer.destroy();
5345
});
5446

55-
it("can tunnel an unknown protocol over SOCKS, if enabled", async () => {
56-
const socksSocket = await openSocksSocket(server, 'localhost', remotePort);
57-
const response = await sendRawRequest(socksSocket, '123456789');
58-
expect(response).to.equal('123456789');
59-
});
47+
describe("to a raw TCP server", () => {
48+
49+
// Simple TCP echo server:
50+
let remoteServer = makeDestroyable(net.createServer((socket) => {
51+
socket.on('data', (data) => {
52+
socket.end(data);
53+
});
54+
}));
55+
let remotePort!: number;
56+
57+
beforeEach(async () => {
58+
remoteServer.listen();
59+
await new Promise((resolve, reject) => {
60+
remoteServer.on('listening', resolve);
61+
remoteServer.on('error', reject);
62+
});
63+
remotePort = (remoteServer.address() as net.AddressInfo).port;
64+
});
65+
66+
afterEach(async () => {
67+
await remoteServer.destroy();
68+
});
69+
70+
it("can tunnel an unknown protocol over SOCKS, if enabled", async () => {
71+
const socksSocket = await openSocksSocket(server, 'localhost', remotePort);
72+
const response = await sendRawRequest(socksSocket, '123456789');
73+
expect(response).to.equal('123456789');
74+
});
75+
76+
it("can tunnel an unknown protocol over HTTP, if enabled", async () => {
77+
const tunnel = await openRawSocket(server);
78+
79+
tunnel.write(`CONNECT localhost:${remotePort} HTTP/1.1\r\n\r\n`);
80+
const connectResponse = await new Promise<Buffer>((resolve, reject) => {
81+
tunnel.on('data', resolve);
82+
tunnel.on('error', reject);
83+
});
6084

61-
it("can tunnel an unknown protocol over HTTP, if enabled", async () => {
62-
const tunnel = await openRawSocket(server);
85+
expect(connectResponse.toString()).to.equal('HTTP/1.1 200 OK\r\n\r\n');
6386

64-
tunnel.write(`CONNECT localhost:${remotePort} HTTP/1.1\r\n\r\n`);
65-
const connectResponse = await new Promise<Buffer>((resolve, reject) => {
66-
tunnel.on('data', resolve);
67-
tunnel.on('error', reject);
87+
tunnel.write('hello world');
88+
const unknownProtocolResponse = await new Promise<Buffer>((resolve, reject) => {
89+
tunnel.on('data', resolve);
90+
tunnel.on('error', reject);
91+
});
92+
93+
expect(unknownProtocolResponse.toString()).to.equal('hello world');
94+
tunnel.end();
6895
});
6996

70-
expect(connectResponse.toString()).to.equal('HTTP/1.1 200 OK\r\n\r\n');
97+
it("can tunnel an unknown protocol over HTTP/2, if enabled", async () => {
98+
const proxyClient = http2.connect(server.url);
99+
100+
const tunnel = proxyClient.request({
101+
':method': 'CONNECT',
102+
':authority': `localhost:${remotePort}`
103+
});
104+
const proxyResponse = await getHttp2Response(tunnel);
105+
expect(proxyResponse[':status']).to.equal(200);
106+
107+
tunnel.write('hello world');
108+
const unknownProtocolResponse = await new Promise<Buffer>((resolve, reject) => {
109+
tunnel.on('data', resolve);
110+
tunnel.on('error', reject);
111+
});
71112

72-
tunnel.write('hello world');
73-
const unknownProtocolResponse = await new Promise<Buffer>((resolve, reject) => {
74-
tunnel.on('data', resolve);
75-
tunnel.on('error', reject);
113+
expect(unknownProtocolResponse.toString()).to.equal('hello world');
114+
tunnel.end();
115+
116+
await cleanup(tunnel, proxyClient);
76117
});
77118

78-
expect(unknownProtocolResponse.toString()).to.equal('hello world');
79-
tunnel.end();
80119
});
81120

82-
it("can tunnel an unknown protocol over HTTP/2, if enabled", async () => {
83-
const proxyClient = http2.connect(server.url);
121+
describe("to a TLS-but-not-HTTP server", () => {
122+
123+
// TLS echo server: unwraps TLS, then echos
124+
let remoteServer: DestroyableServer<tls.Server>;
125+
let remotePort!: number;
126+
127+
// Track client hellos on connections
128+
before(async () => {
129+
// Dynamically generate certs, just like Mockttp itself, but for raw 'echo' only. We use our
130+
// test CA which should be trusted by Node due to NODE_EXTRA_CA_CERTS settings in package.json.
131+
const ca = await getCA({
132+
keyPath: './test/fixtures/test-ca.key',
133+
certPath: './test/fixtures/test-ca.pem',
134+
});
135+
const defaultCert = await ca.generateCertificate('localhost.test');
136+
137+
remoteServer = makeDestroyable(tls.createServer({
138+
key: defaultCert.key,
139+
cert: defaultCert.cert,
140+
ca: [defaultCert.ca],
141+
SNICallback: async (domain: string, cb: Function) => {
142+
const generatedCert = await ca.generateCertificate(domain);
143+
cb(null, tls.createSecureContext({
144+
key: generatedCert.key,
145+
cert: generatedCert.cert,
146+
ca: generatedCert.ca
147+
}));
148+
},
149+
ALPNProtocols: ['echo']
150+
}, (socket) => {
151+
hellos.push(socket.tlsClientHello);
152+
socket.on('data', (data) => {
153+
socket.end(data);
154+
});
155+
}));
156+
157+
trackClientHellos(remoteServer);
158+
});
159+
160+
// Store the client hellos for reference
161+
let hellos: Array<TlsHelloData | undefined> = [];
84162

85-
const tunnel = proxyClient.request({
86-
':method': 'CONNECT',
87-
':authority': `localhost:${remotePort}`
163+
beforeEach(async () => {
164+
remoteServer.listen();
165+
await new Promise((resolve, reject) => {
166+
remoteServer.on('listening', resolve);
167+
remoteServer.on('error', reject);
168+
});
169+
remotePort = (remoteServer.address() as net.AddressInfo).port;
170+
171+
hellos = [];
88172
});
89-
const proxyResponse = await getHttp2Response(tunnel);
90-
expect(proxyResponse[':status']).to.equal(200);
91173

92-
tunnel.write('hello world');
93-
const unknownProtocolResponse = await new Promise<Buffer>((resolve, reject) => {
94-
tunnel.on('data', resolve);
95-
tunnel.on('error', reject);
174+
afterEach(async () => {
175+
await remoteServer.destroy();
96176
});
97177

98-
expect(unknownProtocolResponse.toString()).to.equal('hello world');
99-
tunnel.end();
178+
it("can tunnel an unknown protocol using TLS over SOCKS, if enabled", async () => {
179+
const socksSocket = await openSocksSocket(server, 'localhost', remotePort);
180+
181+
const tlsSocket = await openRawTlsSocket(socksSocket, {
182+
servername: 'server.test',
183+
ALPNProtocols: ['echo']
184+
});
185+
186+
const response = await sendRawRequest(tlsSocket, '123456789');
187+
expect(response).to.equal('123456789');
188+
189+
// We're terminating TLS, so we can't perfectly forward everything (client certs, really)
190+
// but we should be able to mirror all the common bits:
191+
expect(hellos.length).to.equal(1);
192+
const destinationTlsHello = hellos[0]!;
193+
194+
expect(destinationTlsHello.alpnProtocols).to.deep.equal(['echo']);
195+
expect(destinationTlsHello.serverName).to.equal('server.test');
196+
});
100197

101-
await cleanup(tunnel, proxyClient);
102198
});
103199

104200
});

0 commit comments

Comments
 (0)