1
0
Fork 0
mirror of https://github.com/denoland/deno.git synced 2024-12-04 17:18:23 -05:00
denoland-deno/ext/node/polyfills/_tls_wrap.ts

458 lines
13 KiB
TypeScript
Raw Normal View History

// Copyright 2018-2024 the Deno authors. All rights reserved. MIT license.
// Copyright Joyent and Node contributors. All rights reserved. MIT license.
// TODO(petamoriken): enable prefer-primordials for node polyfills
// deno-lint-ignore-file no-explicit-any prefer-primordials
import {
ObjectAssign,
StringPrototypeReplace,
} from "ext:deno_node/internal/primordials.mjs";
import assert from "ext:deno_node/internal/assert.mjs";
import * as net from "node:net";
import { createSecureContext } from "ext:deno_node/_tls_common.ts";
import { kStreamBaseField } from "ext:deno_node/internal_binding/stream_wrap.ts";
import { connResetException } from "ext:deno_node/internal/errors.ts";
import { emitWarning } from "node:process";
import { debuglog } from "ext:deno_node/internal/util/debuglog.ts";
import {
constants as TCPConstants,
TCP,
} from "ext:deno_node/internal_binding/tcp_wrap.ts";
import {
constants as PipeConstants,
Pipe,
} from "ext:deno_node/internal_binding/pipe_wrap.ts";
import { EventEmitter } from "node:events";
import { kEmptyObject } from "ext:deno_node/internal/util.mjs";
import { nextTick } from "ext:deno_node/_next_tick.ts";
import { kHandle } from "ext:deno_node/internal/stream_base_commons.ts";
import {
isAnyArrayBuffer,
isArrayBufferView,
} from "ext:deno_node/internal/util/types.ts";
const kConnectOptions = Symbol("connect-options");
const kIsVerified = Symbol("verified");
const kPendingSession = Symbol("pendingSession");
const kRes = Symbol("res");
let debug = debuglog("tls", (fn) => {
debug = fn;
});
function onConnectEnd(this: any) {
// NOTE: This logic is shared with _http_client.js
if (!this._hadError) {
const options = this[kConnectOptions];
this._hadError = true;
const error: any = connResetException(
"Client network socket disconnected " +
"before secure TLS connection was " +
"established",
);
error.path = options.path;
error.host = options.host;
error.port = options.port;
error.localAddress = options.localAddress;
this.destroy(error);
}
}
export class TLSSocket extends net.Socket {
_tlsOptions: any;
_secureEstablished: boolean;
_securePending: boolean;
_newSessionPending: boolean;
_controlReleased: boolean;
secureConnecting: boolean;
_SNICallback: any;
servername: string | null;
alpnProtocols: string[] | null;
authorized: boolean;
authorizationError: any;
[kRes]: any;
[kIsVerified]: boolean;
[kPendingSession]: any;
[kConnectOptions]: any;
ssl: any;
_start() {
this[kHandle].afterConnect();
}
constructor(socket: any, opts: any = kEmptyObject) {
const tlsOptions = { ...opts };
fix(node/tls): fix NotValidForName for host set via socket / servername (#21441) This PR is an attempt to fix https://github.com/denoland/deno/issues/20293, in which node modules connecting to databases fail due to TLS errors. I ran into this attempting to use [node-postgres](https://github.com/brianc/node-postgres) to connect to a [Neon](https://neon.tech) database. Investigating via `--inspect-brk` led me to notice that the hostname eventually passed to `Deno.startTls` was null. The hostname is determined by the following code: https://github.com/denoland/deno/blob/f6b889b43219e3c9be770c8b2758bff3048ddcbd/ext/node/polyfills/_tls_wrap.ts#L87-L89 This logic doesn't appear to be correct. I couldn't find reference to `servername` existing on the `secureContext` in either Node's or Deno's docs. There's a lot of scope here, and it's my first time reading through this code, so I could be missing something! Node uses [the following logic](https://github.com/nodejs/node/blob/2e458d973638d01fcb6a0d7d611e0120a94f4d35/lib/_tls_wrap.js#L1679-L1682 ) to determine the hostname for certificate validation: ``` const hostname = options.servername || options.host || (options.socket && options.socket._host) || 'localhost'; ``` This PR updates the `TLSSocket` polyfill to use behave similarly (though I omitted the default to `localhost` at the end; I'm not sure if including it is necessary or correct). With this change, `node-postgres` connects to my TLS endpoint successfully (aside: Neon requires SNI, which also works as expected). --- I tried to update the tests in https://github.com/denoland/deno/blob/main/cli/tests/unit_node/tls_test.ts to exercise this change, but the test fails for me on `main` on Linux. I investigated briefly and noticed that the test fixture `cli/tests/testdata/tls/localhost.crt` doesn't appear to include the `subjectAltName` specified in `domains.txt`. I believe the certificate isn't matching `localhost`, but that's where I ended investigating.
2023-12-07 22:53:36 -05:00
const hostname = opts.servername ?? opts.host ?? socket._host;
tlsOptions.hostname = hostname;
const _cert = tlsOptions?.secureContext?.cert;
const _key = tlsOptions?.secureContext?.key;
let caCerts = tlsOptions?.secureContext?.ca;
if (typeof caCerts === "string") caCerts = [caCerts];
else if (isArrayBufferView(caCerts) || isAnyArrayBuffer(caCerts)) {
caCerts = [new TextDecoder().decode(caCerts)];
}
tlsOptions.caCerts = caCerts;
tlsOptions.alpnProtocols = ["h2", "http/1.1"];
super({
handle: _wrapHandle(tlsOptions, socket),
...opts,
manualStart: true, // This prevents premature reading from TLS handle
});
if (socket) {
this._parent = socket;
}
this._tlsOptions = tlsOptions;
this._secureEstablished = false;
this._securePending = false;
this._newSessionPending = false;
this._controlReleased = false;
this.secureConnecting = true;
this._SNICallback = null;
this.servername = null;
this.alpnProtocols = tlsOptions.alpnProtocols;
this.authorized = false;
this.authorizationError = null;
this[kRes] = null;
this[kIsVerified] = false;
this[kPendingSession] = null;
this.ssl = new class {
verifyError() {
return null; // Never fails, rejectUnauthorized is always true in Deno.
}
}();
// deno-lint-ignore no-this-alias
const tlssock = this;
/** Wraps the given socket and adds the tls capability to the underlying
* handle */
function _wrapHandle(tlsOptions: any, wrap: net.Socket | undefined) {
let handle: any;
if (wrap) {
handle = wrap._handle;
}
const options = tlsOptions;
if (!handle) {
handle = options.pipe
? new Pipe(PipeConstants.SOCKET)
: new TCP(TCPConstants.SOCKET);
}
// Patches `afterConnect` hook to replace TCP conn with TLS conn
const afterConnect = handle.afterConnect;
handle.afterConnect = async (req: any, status: number) => {
try {
const conn = await Deno.startTls(handle[kStreamBaseField], options);
handle[kStreamBaseField] = conn;
tlssock.emit("secure");
tlssock.removeListener("end", onConnectEnd);
} catch {
// TODO(kt3k): Handle this
}
return afterConnect.call(handle, req, status);
};
(handle as any).verifyError = function () {
return null; // Never fails, rejectUnauthorized is always true in Deno.
};
// Pretends `handle` is `tls_wrap.wrap(handle, ...)` to make some npm modules happy
// An example usage of `_parentWrap` in npm module:
// https://github.com/szmarczak/http2-wrapper/blob/51eeaf59ff9344fb192b092241bfda8506983620/source/utils/js-stream-socket.js#L6
handle._parent = handle;
handle._parentWrap = wrap;
return handle;
}
}
_tlsError(err: Error) {
this.emit("_tlsError", err);
if (this._controlReleased) {
return err;
}
return null;
}
_releaseControl() {
if (this._controlReleased) {
return false;
}
this._controlReleased = true;
this.removeListener("error", this._tlsError);
return true;
}
getEphemeralKeyInfo() {
return {};
}
isSessionReused() {
return false;
}
setSession(_session: any) {
// TODO(kt3k): implement this
}
setServername(_servername: any) {
// TODO(kt3k): implement this
}
getPeerCertificate(_detailed: boolean) {
// TODO(kt3k): implement this
return {
subject: "localhost",
subjectaltname: "IP Address:127.0.0.1, IP Address:::1",
};
}
}
function normalizeConnectArgs(listArgs: any) {
const args = net._normalizeArgs(listArgs);
const options = args[0];
const cb = args[1];
// If args[0] was options, then normalize dealt with it.
// If args[0] is port, or args[0], args[1] is host, port, we need to
// find the options and merge them in, normalize's options has only
// the host/port/path args that it knows about, not the tls options.
// This means that options.host overrides a host arg.
if (listArgs[1] !== null && typeof listArgs[1] === "object") {
ObjectAssign(options, listArgs[1]);
} else if (listArgs[2] !== null && typeof listArgs[2] === "object") {
ObjectAssign(options, listArgs[2]);
}
return cb ? [options, cb] : [options];
}
let ipServernameWarned = false;
export function Server(options: any, listener: any) {
return new ServerImpl(options, listener);
}
export class ServerImpl extends EventEmitter {
listener?: Deno.TlsListener;
#closed = false;
constructor(public options: any, listener: any) {
super();
if (listener) {
this.on("secureConnection", listener);
}
}
listen(port: any, callback: any): this {
const key = this.options.key?.toString();
const cert = this.options.cert?.toString();
// TODO(kt3k): The default host should be "localhost"
const hostname = this.options.host ?? "0.0.0.0";
this.listener = Deno.listenTls({ port, hostname, cert, key });
callback?.call(this);
this.#listen(this.listener);
return this;
}
async #listen(listener: Deno.TlsListener) {
while (!this.#closed) {
try {
// Creates TCP handle and socket directly from Deno.TlsConn.
// This works as TLS socket. We don't use TLSSocket class for doing
// this because Deno.startTls only supports client side tcp connection.
const handle = new TCP(TCPConstants.SOCKET, await listener.accept());
const socket = new net.Socket({ handle });
this.emit("secureConnection", socket);
} catch (e) {
if (e instanceof Deno.errors.BadResource) {
this.#closed = true;
}
// swallow
}
}
}
close(cb?: (err?: Error) => void): this {
if (this.listener) {
this.listener.close();
}
cb?.();
nextTick(() => {
this.emit("close");
});
return this;
}
address() {
const addr = this.listener!.addr as Deno.NetAddr;
return {
port: addr.port,
address: addr.hostname,
};
}
}
Server.prototype = ServerImpl.prototype;
export function createServer(options: any, listener: any) {
return new ServerImpl(options, listener);
}
function onConnectSecure(this: TLSSocket) {
this.authorized = true;
this.secureConnecting = false;
debug("client emit secureConnect. authorized:", this.authorized);
this.emit("secureConnect");
this.removeListener("end", onConnectEnd);
}
export function connect(...args: any[]) {
args = normalizeConnectArgs(args);
let options = args[0];
const cb = args[1];
const allowUnauthorized = getAllowUnauthorized();
options = {
rejectUnauthorized: !allowUnauthorized,
ciphers: DEFAULT_CIPHERS,
checkServerIdentity,
minDHSize: 1024,
...options,
};
if (!options.keepAlive) {
options.singleUse = true;
}
assert(typeof options.checkServerIdentity === "function");
assert(
typeof options.minDHSize === "number",
"options.minDHSize is not a number: " + options.minDHSize,
);
assert(
options.minDHSize > 0,
"options.minDHSize is not a positive number: " +
options.minDHSize,
);
const context = options.secureContext || createSecureContext(options);
const tlssock = new TLSSocket(options.socket, {
allowHalfOpen: options.allowHalfOpen,
pipe: !!options.path,
secureContext: context,
isServer: false,
requestCert: true,
rejectUnauthorized: options.rejectUnauthorized !== false,
session: options.session,
ALPNProtocols: options.ALPNProtocols,
requestOCSP: options.requestOCSP,
enableTrace: options.enableTrace,
pskCallback: options.pskCallback,
highWaterMark: options.highWaterMark,
onread: options.onread,
signal: options.signal,
...options, // Caveat emptor: Node does not do this.
});
// rejectUnauthorized property can be explicitly defined as `undefined`
// causing the assignment to default value (`true`) fail. Before assigning
// it to the tlssock connection options, explicitly check if it is false
// and update rejectUnauthorized property. The property gets used by TLSSocket
// connection handler to allow or reject connection if unauthorized
options.rejectUnauthorized = options.rejectUnauthorized !== false;
tlssock[kConnectOptions] = options;
if (cb) {
tlssock.once("secureConnect", cb);
}
if (!options.socket) {
// If user provided the socket, it's their responsibility to manage its
// connectivity. If we created one internally, we connect it.
if (options.timeout) {
tlssock.setTimeout(options.timeout);
}
tlssock.connect(options, tlssock._start);
}
tlssock._releaseControl();
if (options.session) {
tlssock.setSession(options.session);
}
if (options.servername) {
if (!ipServernameWarned && net.isIP(options.servername)) {
emitWarning(
"Setting the TLS ServerName to an IP address is not permitted by " +
"RFC 6066. This will be ignored in a future version.",
"DeprecationWarning",
"DEP0123",
);
ipServernameWarned = true;
}
tlssock.setServername(options.servername);
}
if (options.socket) {
tlssock._start();
}
tlssock.on("secure", onConnectSecure);
tlssock.prependListener("end", onConnectEnd);
return tlssock;
}
function getAllowUnauthorized() {
return false;
}
// TODO(kt3k): Implement this when Deno provides APIs for getting peer
// certificates.
export function checkServerIdentity(_hostname: string, _cert: any) {
}
function unfqdn(host: string): string {
return StringPrototypeReplace(host, /[.]$/, "");
}
// Order matters. Mirrors ALL_CIPHER_SUITES from rustls/src/suites.rs but
// using openssl cipher names instead. Mutable in Node but not (yet) in Deno.
export const DEFAULT_CIPHERS = [
// TLSv1.3 suites
"AES256-GCM-SHA384",
"AES128-GCM-SHA256",
"TLS_CHACHA20_POLY1305_SHA256",
// TLSv1.2 suites
"ECDHE-ECDSA-AES256-GCM-SHA384",
"ECDHE-ECDSA-AES128-GCM-SHA256",
"ECDHE-ECDSA-CHACHA20-POLY1305",
"ECDHE-RSA-AES256-GCM-SHA384",
"ECDHE-RSA-AES128-GCM-SHA256",
"ECDHE-RSA-CHACHA20-POLY1305",
].join(":");
export default {
TLSSocket,
connect,
createServer,
checkServerIdentity,
DEFAULT_CIPHERS,
Server,
unfqdn,
};