Skip to content

Commit f437cde

Browse files
committed
Block H2 TLS coalescing
Due to wildcard certificates, browsers can reuse an open connection for a.testserver.host for b.testserver.host. On other servers that's fine, but in our case we use a/b to change actual behaviour at the TLS stage, so this isn't safe! We reject with 421 which should trigger automatic browser behaviour to use a separate connection instead. Critical case is things like http2.testserver.host (fine) then http2--expired.testserver.host (should get a different cert - can't reuse the same TLS connection).
1 parent f20635c commit f437cde

4 files changed

Lines changed: 75 additions & 1 deletion

File tree

src/http-handler.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { HttpRequest, HttpResponse } from './endpoints/http-index.js';
77
import { handleWebSocketUpgrade } from './ws-handler.js';
88
import { resolveEndpointChain } from './endpoint-chain.js';
99
import { getDocsHtml } from './docs-page.js';
10+
import { TLS_CLIENT_HELLO } from './tls-client-hello.js';
1011

1112
function stopRawDataCapture(req: HttpRequest): void {
1213
if (req.httpVersion === '2.0') {
@@ -68,6 +69,24 @@ function createHttpRequestHandler(options: {
6869
}
6970
}
7071

72+
// Reject H2 connection coalescing: if :authority doesn't match the TLS SNI,
73+
// return 421 so the browser opens a new connection with the correct SNI.
74+
if (isHttps && req.httpVersion === '2.0') {
75+
const authority = req.headers[':authority']?.toString();
76+
if (authority) {
77+
const hostWithoutPort = authority.replace(/:\d+$/, '').toLowerCase();
78+
const sni = socket.stream?.[TLS_CLIENT_HELLO]?.serverName?.toLowerCase();
79+
80+
if (sni && sni !== hostWithoutPort) {
81+
res.writeHead(421, { 'content-type': 'text/plain' });
82+
res.end(
83+
`Misdirected Request: TLS connection was established for ${sni} but got a request for ${hostWithoutPort}`
84+
);
85+
return;
86+
}
87+
}
88+
}
89+
7190
const url = new URL(req.url!, `${protocol}://${
7291
req.headers[':authority'] ?? req.headers['host']
7392
}`);

src/process-connection.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
parseProxyProtocol,
77
detectProxyProtocol,
88
} from './proxy-protocol.js';
9+
import { TLS_CLIENT_HELLO } from './tls-client-hello.js';
910

1011
const FRAME_HEADER_SIZE = 9;
1112

@@ -55,6 +56,7 @@ class DataCapturingStream extends stream.Duplex {
5556
this.localPort = (wrapped as any).localPort;
5657
this.encrypted = (wrapped as any).encrypted ?? false;
5758
this[PROXY_PROTOCOL] = wrapped[PROXY_PROTOCOL];
59+
this[TLS_CLIENT_HELLO] = wrapped[TLS_CLIENT_HELLO];
5860

5961
wrapped.on('error', (err) => this.emit('error', err));
6062
wrapped.on('close', () => this.emit('close'));

test/tls-coalescing.spec.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import * as net from 'net';
2+
import * as http2 from 'http2';
3+
import { expect } from 'chai';
4+
import { DestroyableServer, makeDestroyable } from 'destroyable-server';
5+
6+
import { createServer } from '../src/server.js';
7+
8+
describe("TLS connection coalescing prevention", () => {
9+
10+
let server: DestroyableServer;
11+
let serverPort: number;
12+
13+
beforeEach(async () => {
14+
server = makeDestroyable(await createServer());
15+
await new Promise<void>((resolve) => server.listen(resolve));
16+
serverPort = (server.address() as net.AddressInfo).port;
17+
});
18+
19+
afterEach(async () => {
20+
await server.destroy();
21+
});
22+
23+
async function h2Request(servername: string, authority: string): Promise<number | undefined> {
24+
const client = http2.connect(`https://localhost:${serverPort}`, {
25+
rejectUnauthorized: false,
26+
servername
27+
});
28+
29+
const status = await new Promise<number | undefined>((resolve, reject) => {
30+
const req = client.request({ ':authority': authority, ':path': '/status/200' });
31+
req.on('response', (headers) => resolve(headers[':status']));
32+
req.on('error', reject);
33+
req.end();
34+
});
35+
client.close();
36+
37+
return status;
38+
}
39+
40+
it("allows requests where :authority matches the SNI", async () => {
41+
expect(await h2Request('http2.localhost', 'http2.localhost')).to.equal(200);
42+
});
43+
44+
it("returns 421 when :authority does not match SNI", async () => {
45+
expect(await h2Request('http2.localhost', 'http2--expired.localhost')).to.equal(421);
46+
});
47+
48+
it("returns 421 when :authority includes a port but hostname doesn't match SNI", async () => {
49+
expect(await h2Request('http2.localhost', `localhost:${serverPort}`)).to.equal(421);
50+
});
51+
52+
});

test/tls-required.spec.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,8 @@ describe("TLS-required endpoints over plain HTTP", () => {
107107

108108
it("does not redirect HTTP/2+TLS requests for TLS subdomains", async () => {
109109
const client = http2.connect(`https://localhost:${serverPort}`, {
110-
rejectUnauthorized: false
110+
rejectUnauthorized: false,
111+
servername: 'http2.localhost'
111112
});
112113

113114
const response = await new Promise<{ status: number | undefined }>((resolve, reject) => {

0 commit comments

Comments
 (0)