Skip to content

Commit 7d39e30

Browse files
author
Adrian Gracia
committed
SQC-813 support hyperdrive local dev sslmodes verify-ca / verify-full
for postgres/mysql - adds extra integration tests that verify sslmodes work as expected
1 parent 2c3258d commit 7d39e30

File tree

4 files changed

+807
-25
lines changed

4 files changed

+807
-25
lines changed

.changeset/chilly-trams-flow.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"miniflare": minor
3+
---
4+
5+
Add extended sslmode support for Hyperdrive local dev. This adds support for sslmodes verify-full / verify-ca for Postgres and VERIFY_IDENTITY / VERIFY_CA for MySQL

packages/miniflare/src/plugins/hyperdrive/hyperdrive-proxy.ts

Lines changed: 117 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import fs from "node:fs";
12
import net from "node:net";
23
import tls from "node:tls";
34

@@ -12,6 +13,8 @@ export interface HyperdriveProxyConfig {
1213
scheme: string;
1314
// The sslmode of the target database
1415
sslmode: string;
16+
// The path to the SSL root certificate, or "system" to use the system CA store
17+
sslrootcert?: string;
1518
}
1619

1720
const schemes = {
@@ -25,11 +28,85 @@ export const POSTGRES_SSL_REQUEST_PACKET = Buffer.from([
2528
0x00, 0x00, 0x00, 0x08, 0x04, 0xd2, 0x16, 0x2f,
2629
]);
2730

31+
interface TlsConfig {
32+
rejectUnauthorized: boolean;
33+
ca?: Buffer<ArrayBuffer>;
34+
checkServerIdentity?: () => undefined;
35+
}
36+
37+
/**
38+
* Builds TLS configuration based on sslmode and sslrootcert settings.
39+
*
40+
* - verify-full: validates the server certificate against CA and checks hostname
41+
* - verify-ca: validates the server certificate against CA but skips hostname check
42+
* - require/prefer: connects with TLS but does not validate the server certificate
43+
* - sslrootcert=system: uses Node.js default system CA store
44+
* - sslrootcert=<path>: reads the CA certificate from the specified file path
45+
*/
46+
function buildTlsConfig(
47+
sslmodeVerifyFull: boolean,
48+
sslmodeVerifyCa: boolean,
49+
sslrootcert?: string
50+
): TlsConfig {
51+
if (!sslmodeVerifyFull && !sslmodeVerifyCa) {
52+
return { rejectUnauthorized: false };
53+
}
54+
55+
const config: TlsConfig = { rejectUnauthorized: true };
56+
57+
// Load custom CA certificate if sslrootcert is a file path (not "system")
58+
if (sslrootcert && sslrootcert !== "system") {
59+
config.ca = fs.readFileSync(sslrootcert);
60+
}
61+
// When sslrootcert=system (or omitted for verify modes), Node.js uses its
62+
// default system CA store automatically, so no explicit ca is needed.
63+
64+
if (sslmodeVerifyCa) {
65+
// verify-ca validates the certificate chain but does not check the hostname
66+
config.checkServerIdentity = () => undefined;
67+
}
68+
69+
return config;
70+
}
71+
72+
/**
73+
* Assembles a complete `tls.ConnectionOptions` from the base socket/host info
74+
* and the verification policy captured in `TlsConfig`.
75+
*
76+
* Keeping this separate from `buildTlsConfig` lets each protocol handler
77+
* (Postgres, MySQL) call it with their own socket without duplicating the
78+
* CA / hostname-verification logic inline.
79+
*/
80+
function buildTlsConnectionOptions(
81+
dbSocket: net.Socket,
82+
targetHost: string,
83+
tlsConfig: TlsConfig,
84+
extra?: Partial<tls.ConnectionOptions>
85+
): tls.ConnectionOptions {
86+
const options: tls.ConnectionOptions = {
87+
socket: dbSocket,
88+
host: targetHost,
89+
servername: targetHost,
90+
rejectUnauthorized: tlsConfig.rejectUnauthorized,
91+
...extra,
92+
};
93+
94+
if (tlsConfig.ca) {
95+
options.ca = tlsConfig.ca;
96+
}
97+
98+
if (tlsConfig.checkServerIdentity) {
99+
options.checkServerIdentity = tlsConfig.checkServerIdentity;
100+
}
101+
102+
return options;
103+
}
104+
28105
/**
29106
* HyperdriveProxyController establishes TLS-enabled connections between workerd
30107
* and external Postgres/MySQL databases. Supports PostgreSQL sslmode options
31-
* ('require', 'prefer', 'disable') by proxying each Hyperdrive binding through
32-
* a randomly assigned local port.
108+
* ('require', 'prefer', 'disable', 'verify-full', 'verify-ca') by proxying
109+
* each Hyperdrive binding through a randomly assigned local port.
33110
*/
34111
export class HyperdriveProxyController {
35112
// Map hyperdrive binding name to proxy server
@@ -42,14 +119,16 @@ export class HyperdriveProxyController {
42119
* @returns A promise that resolves to the port number of the proxy server.
43120
*/
44121
async createProxyServer(config: HyperdriveProxyConfig): Promise<number> {
45-
const { name, targetHost, targetPort, scheme, sslmode } = config;
122+
const { name, targetHost, targetPort, scheme, sslmode, sslrootcert } =
123+
config;
46124
const server = net.createServer((clientSocket) => {
47125
this.#handleConnection(
48126
clientSocket,
49127
targetHost,
50128
Number.parseInt(targetPort),
51129
scheme,
52-
sslmode
130+
sslmode,
131+
sslrootcert
53132
);
54133
});
55134
const port = await new Promise<number>((resolve, reject) => {
@@ -74,39 +153,53 @@ export class HyperdriveProxyController {
74153
* @param targetPort - The port of the target database.
75154
* @param scheme - The scheme of the target database.
76155
* @param sslmode - The sslmode of the target database.
156+
* @param sslrootcert - Path to the SSL root certificate, or "system" to use the system CA store.
77157
*/
78158
async #handleConnection(
79159
clientSocket: net.Socket,
80160
targetHost: string,
81161
targetPort: number,
82162
scheme: string,
83-
sslmode: string
163+
sslmode: string,
164+
sslrootcert?: string
84165
) {
85166
// Connect to real database
86167
const dbSocket = net.connect({ host: targetHost, port: targetPort });
87168
const sslmodeRequire = sslmode === "require";
88169
const sslmodePrefer = sslmode === "prefer";
89-
if (sslmodePrefer || sslmodeRequire) {
170+
const sslmodeVerifyFull = sslmode === "verify-full";
171+
const sslmodeVerifyCa = sslmode === "verify-ca";
172+
// verify-full and verify-ca are strict modes that must always attempt TLS
173+
const sslmodeStrict = sslmodeVerifyFull || sslmodeVerifyCa;
174+
175+
if (sslmodePrefer || sslmodeRequire || sslmodeStrict) {
176+
const tlsConfig = buildTlsConfig(
177+
sslmodeVerifyFull,
178+
sslmodeVerifyCa,
179+
sslrootcert
180+
);
90181
try {
91182
if (scheme === schemes.postgres || scheme === schemes.postgresql) {
92183
return await handlePostgresTlsConnection(
93184
dbSocket,
94185
clientSocket,
95186
targetHost,
96187
targetPort,
97-
sslmodeRequire
188+
sslmodeRequire || sslmodeStrict,
189+
tlsConfig
98190
);
99191
} else if (scheme === schemes.mysql) {
100192
return await handleMySQLTlsConnection(
101193
dbSocket,
102194
clientSocket,
103195
targetHost,
104196
targetPort,
105-
sslmodeRequire
197+
sslmodeRequire || sslmodeStrict,
198+
tlsConfig
106199
);
107200
}
108201
} catch (e) {
109-
if (sslmodeRequire) {
202+
if (sslmodeRequire || sslmodeStrict) {
110203
// Write error to client so worker can read it
111204
clientSocket.write(`${e}\n`);
112205
clientSocket.end();
@@ -139,7 +232,8 @@ async function handlePostgresTlsConnection(
139232
clientSocket: net.Socket,
140233
targetHost: string,
141234
targetPort: number,
142-
sslmodeRequire: boolean
235+
sslmodeRequire: boolean,
236+
tlsConfig: TlsConfig
143237
) {
144238
// Send Postgres sslrequest bytes
145239
await writeAsync(dbSocket, POSTGRES_SSL_REQUEST_PACKET);
@@ -148,12 +242,11 @@ async function handlePostgresTlsConnection(
148242
// Read first byte ssl flag
149243
const sslResponseFlag = response.toString("utf8", 0, 1);
150244
if (sslResponseFlag === "S") {
151-
const tlsOptions: tls.ConnectionOptions = {
152-
socket: dbSocket,
153-
host: targetHost,
154-
servername: targetHost,
155-
rejectUnauthorized: false,
156-
};
245+
const tlsOptions = buildTlsConnectionOptions(
246+
dbSocket,
247+
targetHost,
248+
tlsConfig
249+
);
157250
try {
158251
const tlsSocket = await tlsConnect(tlsOptions);
159252
setupTLSConnection(clientSocket, tlsSocket);
@@ -187,7 +280,8 @@ async function handleMySQLTlsConnection(
187280
clientSocket: net.Socket,
188281
targetHost: string,
189282
targetPort: number,
190-
sslmodeRequire: boolean
283+
sslmodeRequire: boolean,
284+
tlsConfig: TlsConfig
191285
) {
192286
const initPacketChunk = await readAsync(dbSocket);
193287
// Little-endian parse payload header length
@@ -238,13 +332,12 @@ async function handleMySQLTlsConnection(
238332
await writeAsync(dbSocket, sslRequestPacket);
239333

240334
// Upgrade server connection to TLS
241-
const tlsOptions: tls.ConnectionOptions = {
242-
socket: dbSocket,
243-
host: targetHost,
244-
servername: targetHost,
245-
minVersion: "TLSv1.2",
246-
rejectUnauthorized: false,
247-
};
335+
const tlsOptions = buildTlsConnectionOptions(
336+
dbSocket,
337+
targetHost,
338+
tlsConfig,
339+
{ minVersion: "TLSv1.2" }
340+
);
248341

249342
try {
250343
const tlsSocket = await tlsConnect(tlsOptions);

packages/miniflare/src/plugins/hyperdrive/index.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,7 @@ export const HYPERDRIVE_PLUGIN: Plugin<typeof HyperdriveInputOptionsSchema> = {
127127
targetPort: getPort(url),
128128
scheme,
129129
sslmode: parseSslMode(url, scheme),
130+
sslrootcert: parseSslRootCert(url),
130131
});
131132
services.push({
132133
name: `${HYPERDRIVE_PLUGIN_NAME}:${name}`,
@@ -157,8 +158,12 @@ function parseSslMode(url: URL, scheme: string): string {
157158
// Parse different variations for mysql sslmode
158159
const sslmode = params["ssl-mode"] || params["ssl"] || params["sslmode"];
159160

160-
// Normalize to postgres values
161+
// Normalize to postgres-equivalent values
161162
switch (sslmode) {
163+
case "verify_identity":
164+
return "verify-full";
165+
case "verify_ca":
166+
return "verify-ca";
162167
case "required":
163168
case "true":
164169
case "1":
@@ -176,6 +181,13 @@ function parseSslMode(url: URL, scheme: string): string {
176181
return "disable";
177182
}
178183

184+
function parseSslRootCert(url: URL): string | undefined {
185+
const params = Object.fromEntries(
186+
Array.from(url.searchParams.entries()).map(([k, v]) => [k.toLowerCase(), v])
187+
);
188+
return params["sslrootcert"] || undefined;
189+
}
190+
179191
export type {
180192
HyperdriveProxyController,
181193
HyperdriveProxyConfig,

0 commit comments

Comments
 (0)