current commit
261021.11 · tls: reject non-canonical IP addresses
// Copyright Joyent, Inc. and other Node contributors.
//
// Permission is hereby granted, free of charge, to any person obtaining a
// copy of this software and associated documentation files (the
// "Software"), to deal in the Software without restriction, including
// without limitation the rights to use, copy, modify, merge, publish,
// distribute, sublicense, and/or sell copies of the Software, and to permit
// persons to whom the Software is furnished to do so, subject to the
// following conditions:
//
// The above copyright notice and this permission notice shall be included
// in all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN
// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
// USE OR OTHER DEALINGS IN THE SOFTWARE.

'use strict';

const {
  Array,
  ArrayIsArray,
  // eslint-disable-next-line no-restricted-syntax
  ArrayPrototypePush,
  JSONParse,
  ObjectDefineProperty,
  ObjectFreeze,
  StringFromCharCode,
} = primordials;

const {
  ERR_TLS_CERT_ALTNAME_FORMAT,
  ERR_TLS_CERT_ALTNAME_INVALID,
  ERR_OUT_OF_RANGE,
  ERR_INVALID_ARG_VALUE,
  ERR_INVALID_ARG_TYPE,
} = require('internal/errors').codes;

const {
  getBundledRootCertificates,
  getExtraCACertificates,
  getSystemCACertificates,
  resetRootCertStore,
  getUserRootCertificates,
  getSSLCiphers,
  getCertificateCompressionAlgorithms,
  startLoadingCertificatesOffThread,
} = internalBinding('crypto');

// Start loading root certificates in a separate thread as early as possible
// once the tls module is loaded, so that by the time an actual TLS connection is
// made, the loading is done.
startLoadingCertificatesOffThread();

const internalUtil = require('internal/util');
internalUtil.assertCrypto();
const {
  isArrayBufferView,
  isUint8Array,
} = require('internal/util/types');

const net = require('net');
const { getOptionValue } = require('internal/options');
const { Buffer } = require('buffer');
const { canonicalizeIP } = internalBinding('cares_wrap');
const tlsCommon = require('internal/tls/common');
const tlsWrap = require('internal/tls/wrap');
const { domainToASCII } = require('internal/url');
const { validateArray, validateString } = require('internal/validators');

const {
  namespace: {
    addDeserializeCallback,
    addSerializeCallback,
    isBuildingSnapshot,
  },
} = require('internal/v8/startup_snapshot');

// Allow {CLIENT_RENEG_LIMIT} client-initiated session renegotiations
// every {CLIENT_RENEG_WINDOW} seconds. An error event is emitted if more
// renegotiations are seen. The settings are applied to all remote client
// connections.
exports.CLIENT_RENEG_LIMIT = 3;
exports.CLIENT_RENEG_WINDOW = 600;

exports.DEFAULT_CIPHERS = getOptionValue('--tls-cipher-list');

exports.DEFAULT_ECDH_CURVE = 'auto';

if (getOptionValue('--tls-min-v1.0'))
  exports.DEFAULT_MIN_VERSION = 'TLSv1';
else if (getOptionValue('--tls-min-v1.1'))
  exports.DEFAULT_MIN_VERSION = 'TLSv1.1';
else if (getOptionValue('--tls-min-v1.2'))
  exports.DEFAULT_MIN_VERSION = 'TLSv1.2';
else if (getOptionValue('--tls-min-v1.3'))
  exports.DEFAULT_MIN_VERSION = 'TLSv1.3';
else
  exports.DEFAULT_MIN_VERSION = 'TLSv1.2';

if (getOptionValue('--tls-max-v1.3'))
  exports.DEFAULT_MAX_VERSION = 'TLSv1.3';
else if (getOptionValue('--tls-max-v1.2'))
  exports.DEFAULT_MAX_VERSION = 'TLSv1.2';
else
  exports.DEFAULT_MAX_VERSION = 'TLSv1.3'; // Will depend on node version.


exports.getCiphers = internalUtil.cachedResult(
  () => internalUtil.filterDuplicateStrings(getSSLCiphers(), true),
);

exports.getCertificateCompressionAlgorithms = internalUtil.cachedResult(
  () => getCertificateCompressionAlgorithms(),
);

let bundledRootCertificates;
function cacheBundledRootCertificates() {
  bundledRootCertificates ||= ObjectFreeze(getBundledRootCertificates());

  return bundledRootCertificates;
}

ObjectDefineProperty(exports, 'rootCertificates', {
  __proto__: null,
  configurable: false,
  enumerable: true,
  get: cacheBundledRootCertificates,
});

let extraCACertificates;
function cacheExtraCACertificates() {
  extraCACertificates ||= ObjectFreeze(getExtraCACertificates());

  return extraCACertificates;
}

let systemCACertificates;
function cacheSystemCACertificates() {
  systemCACertificates ||= ObjectFreeze(getSystemCACertificates());

  return systemCACertificates;
}

let defaultCACertificates;
let hasResetDefaultCACertificates = false;

function cacheDefaultCACertificates() {
  if (defaultCACertificates) { return defaultCACertificates; }

  if (hasResetDefaultCACertificates) {
    defaultCACertificates = getUserRootCertificates();
    ObjectFreeze(defaultCACertificates);
    return defaultCACertificates;
  }

  defaultCACertificates = [];

  if (!getOptionValue('--use-openssl-ca')) {
    const bundled = cacheBundledRootCertificates();
    for (let i = 0; i < bundled.length; ++i) {
      ArrayPrototypePush(defaultCACertificates, bundled[i]);
    }
    if (getOptionValue('--use-system-ca')) {
      const system = cacheSystemCACertificates();
      for (let i = 0; i < system.length; ++i) {

        ArrayPrototypePush(defaultCACertificates, system[i]);
      }
    }
  }

  if (process.env.NODE_EXTRA_CA_CERTS) {
    const extra = cacheExtraCACertificates();
    for (let i = 0; i < extra.length; ++i) {

      ArrayPrototypePush(defaultCACertificates, extra[i]);
    }
  }

  ObjectFreeze(defaultCACertificates);
  return defaultCACertificates;
}

// TODO(joyeecheung): support X509Certificate output?
function getCACertificates(type = 'default') {
  validateString(type, 'type');

  switch (type) {
    case 'default':
      return cacheDefaultCACertificates();
    case 'bundled':
      return cacheBundledRootCertificates();
    case 'system':
      return cacheSystemCACertificates();
    case 'extra':
      return cacheExtraCACertificates();
    default:
      throw new ERR_INVALID_ARG_VALUE('type', type);
  }
}
exports.getCACertificates = getCACertificates;

function setDefaultCACertificates(certs) {
  validateArray(certs, 'certs');

  // Verify that all elements in the array are strings
  for (let i = 0; i < certs.length; i++) {
    if (typeof certs[i] !== 'string' && !isArrayBufferView(certs[i])) {
      throw new ERR_INVALID_ARG_TYPE(
        `certs[${i}]`, ['string', 'ArrayBufferView'], certs[i]);
    }
  }

  resetRootCertStore(certs);
  defaultCACertificates = undefined; // Reset the cached default certificates
  hasResetDefaultCACertificates = true;
}

exports.setDefaultCACertificates = setDefaultCACertificates;

if (isBuildingSnapshot()) {
  addSerializeCallback(() => {
    // Clear the cached certs so that they are reloaded at runtime.
    // Bundled certificates are immutable so they are spared.
    extraCACertificates = undefined;
    systemCACertificates = undefined;
    if (hasResetDefaultCACertificates) {
      defaultCACertificates = undefined;
    }
  });
  addDeserializeCallback(() => {
    // If the tls module is loaded during snapshotting, load the certificates from
    // various sources again at runtime so that by the time an actual TLS connection is
    // made, the loading is done. If the default CA certificates have been overridden, then
    // the serialized overriding certificates are likely to be used and pre-loading
    // from the sources would probably not yield any benefit, so skip it.
    if (!hasResetDefaultCACertificates) {
      startLoadingCertificatesOffThread();
    }
  });
}

// Convert protocols array into valid OpenSSL protocols list
// ("\x06spdy/2\x08http/1.1\x08http/1.0")
function convertProtocols(protocols) {
  const lens = new Array(protocols.length);
  const buff = Buffer.allocUnsafe(protocols.reduce((p, c, i) => {
    const len = Buffer.byteLength(c);
    if (len === 0) {
      throw new ERR_INVALID_ARG_VALUE(`protocols[${i}]`, c,
                                      'must be a non-empty string');
    }
    if (len > 255) {
      throw new ERR_OUT_OF_RANGE('The byte length of the protocol at index ' +
        `${i} exceeds the maximum length.`, '<= 255', len, true);
    }
    lens[i] = len;
    return p + 1 + len;
  }, 0));

  let offset = 0;
  for (let i = 0, c = protocols.length; i < c; i++) {
    buff[offset++] = lens[i];
    buff.write(protocols[i], offset);
    offset += lens[i];
  }

  return buff;
}

function validateALPNBuffer(buffer) {
  // Wire format: sequence of <len><proto> where len is 1 byte (1-255) and
  // exactly len bytes follow, no trailing bytes, no zero-length entries.
  // Empty buffer is allowed and means skip ALPN (same as []).
  let offset = 0;
  while (offset < buffer.length) {
    const len = buffer[offset];
    if (len === 0) {
      throw new ERR_INVALID_ARG_VALUE('ALPNProtocols', buffer,
                                      'must not contain zero-length protocol');
    }
    if (offset + 1 + len > buffer.length) {
      throw new ERR_INVALID_ARG_VALUE('ALPNProtocols', buffer,
                                      'contains truncated protocol');
    }
    offset += 1 + len;
  }
}

exports.convertALPNProtocols = function convertALPNProtocols(protocols, out) {
  // If protocols is Array - translate it into buffer
  if (ArrayIsArray(protocols)) {
    out.ALPNProtocols = convertProtocols(protocols);
  } else if (isUint8Array(protocols)) {
    // Copy new buffer not to be modified by user.
    const buf = Buffer.from(protocols);
    validateALPNBuffer(buf);
    out.ALPNProtocols = buf;
  } else if (isArrayBufferView(protocols)) {
    const buf = Buffer.from(protocols.buffer.slice(
      protocols.byteOffset,
      protocols.byteOffset + protocols.byteLength,
    ));
    validateALPNBuffer(buf);
    out.ALPNProtocols = buf;
  }
};

function unfqdn(host) {
  return host.replace(/[.]$/, '');
}

// String#toLowerCase() is locale-sensitive so we use
// a conservative version that only lowercases A-Z.
function toLowerCase(c) {
  return StringFromCharCode(32 + c.charCodeAt(0));
}

function splitHost(host) {
  return unfqdn(host).replace(/[A-Z]/g, toLowerCase).split('.');
}

function check(hostParts, pattern, wildcards) {
  // Empty strings, null, undefined, etc. never match.
  if (!pattern)
    return false;

  const patternParts = splitHost(pattern);

  if (hostParts.length !== patternParts.length)
    return false;

  // Pattern has empty components, e.g. "bad..example.com".
  if (patternParts.includes(''))
    return false;

  // RFC 6125 allows IDNA U-labels (Unicode) in names but we have no
  // good way to detect their encoding or normalize them so we simply
  // reject them.  Control characters and blanks are rejected as well
  // because nothing good can come from accepting them.
  const isBad = (s) => /[^\u0021-\u007F]/u.test(s);
  if (patternParts.some(isBad))
    return false;

  // Check host parts from right to left first.
  for (let i = hostParts.length - 1; i > 0; i -= 1) {
    if (hostParts[i] !== patternParts[i])
      return false;
  }

  const hostSubdomain = hostParts[0];
  const patternSubdomain = patternParts[0];
  const patternSubdomainParts = patternSubdomain.split('*', 3);

  // Short-circuit when the subdomain does not contain a wildcard.
  // RFC 6125 does not allow wildcard substitution for components
  // containing IDNA A-labels (Punycode) so match those verbatim.
  if (patternSubdomainParts.length === 1 ||
      patternSubdomain.includes('xn--'))
    return hostSubdomain === patternSubdomain;

  if (!wildcards)
    return false;

  // More than one wildcard is always wrong.
  if (patternSubdomainParts.length > 2)
    return false;

  // *.tld wildcards are not allowed.
  if (patternParts.length <= 2)
    return false;

  const { 0: prefix, 1: suffix } = patternSubdomainParts;

  if (prefix.length + suffix.length > hostSubdomain.length)
    return false;

  if (!hostSubdomain.startsWith(prefix))
    return false;

  if (!hostSubdomain.endsWith(suffix))
    return false;

  return true;
}

// This pattern is used to determine the length of escaped sequences within
// the subject alt names string. It allows any valid JSON string literal.
// This MUST match the JSON specification (ECMA-404 / RFC8259) exactly.
const jsonStringPattern =
  // eslint-disable-next-line no-control-regex
  /^"(?:[^"\\\u0000-\u001f]|\\(?:["\\/bfnrt]|u[0-9a-fA-F]{4}))*"/;

function splitEscapedAltNames(altNames) {
  const result = [];
  let currentToken = '';
  let offset = 0;
  while (offset !== altNames.length) {
    const nextSep = altNames.indexOf(',', offset);
    const nextQuote = altNames.indexOf('"', offset);
    if (nextQuote !== -1 && (nextSep === -1 || nextQuote < nextSep)) {
      // There is a quote character and there is no separator before the quote.
      currentToken += altNames.substring(offset, nextQuote);
      const match = jsonStringPattern.exec(altNames.substring(nextQuote));
      if (!match) {
        throw new ERR_TLS_CERT_ALTNAME_FORMAT();
      }
      currentToken += JSONParse(match[0]);
      offset = nextQuote + match[0].length;
    } else if (nextSep !== -1) {
      // There is a separator and no quote before it.
      currentToken += altNames.substring(offset, nextSep);
      result.push(currentToken);
      currentToken = '';
      offset = nextSep + 2;
    } else {
      currentToken += altNames.substring(offset);
      offset = altNames.length;
    }
  }
  result.push(currentToken);
  return result;
}

exports.checkServerIdentity = function checkServerIdentity(hostname, cert) {
  const subject = cert.subject;
  const altNames = cert.subjectaltname;
  const dnsNames = [];
  const ips = [];

  hostname = '' + hostname;
  const hostnameASCII = domainToASCII(hostname);

  // Remove trailing dots for error messages and matching.
  hostname = unfqdn(hostname);
  const hostnameASCIIWithoutFQDN = unfqdn(hostnameASCII);
  const hostnameIsIP = net.isIP(hostname);
  const hostnameASCIIIsIP = net.isIP(hostnameASCIIWithoutFQDN);

  if (altNames) {
    const splitAltNames = altNames.includes('"') ?
      splitEscapedAltNames(altNames) :
      altNames.split(', ');
    splitAltNames.forEach((name) => {
      if (name.startsWith('DNS:')) {
        dnsNames.push(name.slice(4));
      } else if (name.startsWith('IP Address:')) {
        ips.push(canonicalizeIP(name.slice(11)));
      }
    });
  }

  // Non-canonical IP address spellings can be interpreted differently by
  // domainToASCII() and the operating system resolver. Reject them instead of
  // validating a certificate against an address that may not be the peer.
  if (!hostnameIsIP && hostnameASCIIIsIP) {
    return new ERR_TLS_CERT_ALTNAME_INVALID(
      'Host is a non-canonical IP address', hostname, cert);
  }

  let valid = false;
  let reason = 'Unknown reason';

  // An IP literal must not be IDNA-normalized: domainToASCII() returns '' for
  // an IPv6 literal (it is not a domain), so matching against the normalized
  // host would skip IP-SAN matching for IPv6 entirely. Match IP hosts against
  // the original hostname (net.isIP() rejects non-ASCII, so there is no IDNA
  // confusion to guard against here); the normalized form is kept for the
  // DNS-name path below.
  if (hostnameIsIP) {
    valid = ips.includes(canonicalizeIP(hostname));
    if (!valid) {
      reason =
        `IP: ${hostname} is not in the cert's list: ` + ips.join(', ');
    }
  } else if (dnsNames.length > 0 || subject?.CN) {
    const hostParts = splitHost(hostnameASCIIWithoutFQDN);
    const wildcard = (pattern) => check(hostParts, pattern, true);

    if (dnsNames.length > 0) {
      valid = dnsNames.some(wildcard);
      if (!valid)
        reason =
          `Host: ${hostname}. is not in the cert's altnames: ${altNames}`;
    } else {
      // Match against Common Name only if no supported identifiers exist.
      const cn = subject.CN;

      if (ArrayIsArray(cn))
        valid = cn.some(wildcard);
      else if (cn)
        valid = wildcard(cn);

      if (!valid)
        reason = `Host: ${hostname}. is not cert's CN: ${cn}`;
    }
  } else {
    reason = 'Cert does not contain a DNS name';
  }

  if (!valid) {
    return new ERR_TLS_CERT_ALTNAME_INVALID(reason, hostname, cert);
  }
};

exports.createSecureContext = tlsCommon.createSecureContext;
exports.SecureContext = tlsCommon.SecureContext;
exports.TLSSocket = tlsWrap.TLSSocket;
exports.Server = tlsWrap.Server;
exports.createServer = tlsWrap.createServer;
exports.connect = tlsWrap.connect;