diff --git a/doc/api/cli.md b/doc/api/cli.md index 403fd704c32658..2a85ab07e1ba00 100644 --- a/doc/api/cli.md +++ b/doc/api/cli.md @@ -3696,6 +3696,18 @@ specified proxy. This can also be enabled using the [`--use-env-proxy`][] command-line flag. When both are set, `--use-env-proxy` takes precedence. +### `NODE_USE_SYSTEM_CA=1` + + + +Node.js uses the trusted CA certificates present in the system store along with +the `--use-bundled-ca` option and the `NODE_EXTRA_CA_CERTS` environment variable. + +This can also be enabled using the [`--use-system-ca`][] command-line flag. +When both are set, `--use-system-ca` takes precedence. + ### `NODE_V8_COVERAGE=dir` When set, Node.js will begin outputting [V8 JavaScript code coverage][] and @@ -4025,6 +4037,7 @@ node --stack-trace-limit=12 -p -e "Error.stackTraceLimit" # prints 12 [`--redirect-warnings`]: #--redirect-warningsfile [`--require`]: #-r---require-module [`--use-env-proxy`]: #--use-env-proxy +[`--use-system-ca`]: #--use-system-ca [`AsyncLocalStorage`]: async_context.md#class-asynclocalstorage [`Buffer`]: buffer.md#class-buffer [`CRYPTO_secure_malloc_init`]: https://www.openssl.org/docs/man3.0/man3/CRYPTO_secure_malloc_init.html diff --git a/doc/node.1 b/doc/node.1 index 74d2d411a746eb..f7cb4ea0c78d50 100644 --- a/doc/node.1 +++ b/doc/node.1 @@ -844,6 +844,12 @@ This currently only affects requests sent over .Ar fetch() . Support for other built-in http and https methods is under way. . +.It Ev NODE_USE_SYSTEM_CA +Similar to +.Fl -use-system-ca . +Use the trusted CA certificates present in the system store, in addition to the certificates in the +bundled Mozilla CA store and certificates from `NODE_EXTRA_CA_CERTS`. +. .It Ev NODE_V8_COVERAGE Ar dir When set, Node.js writes JavaScript code coverage information to .Ar dir . diff --git a/src/node.cc b/src/node.cc index d3bbaa40250557..d6f9922a5b1562 100644 --- a/src/node.cc +++ b/src/node.cc @@ -868,6 +868,15 @@ static ExitCode InitializeNodeWithArgsInternal( // default value. V8::SetFlagsFromString("--rehash-snapshot"); +#if HAVE_OPENSSL + // TODO(joyeecheung): make this a per-env option and move the normalization + // into HandleEnvOptions. + std::string use_system_ca; + if (credentials::SafeGetenv("NODE_USE_SYSTEM_CA", &use_system_ca) && + use_system_ca == "1") { + per_process::cli_options->use_system_ca = true; + } +#endif // HAVE_OPENSSL HandleEnvOptions(per_process::cli_options->per_isolate->per_env); std::string node_options; diff --git a/src/node_sea.cc b/src/node_sea.cc index 891d69ae973f08..461862d67907c2 100644 --- a/src/node_sea.cc +++ b/src/node_sea.cc @@ -3,7 +3,6 @@ #include "blob_serializer_deserializer-inl.h" #include "debug_utils-inl.h" #include "env-inl.h" -#include "json_parser.h" #include "node_contextify.h" #include "node_errors.h" #include "node_external_reference.h" @@ -11,6 +10,7 @@ #include "node_snapshot_builder.h" #include "node_union_bytes.h" #include "node_v8_platform-inl.h" +#include "simdjson.h" #include "util-inl.h" // The POSTJECT_SENTINEL_FUSE macro is a string of random characters selected by @@ -303,79 +303,131 @@ std::optional ParseSingleExecutableConfig( } SeaConfig result; - JSONParser parser; - if (!parser.Parse(config)) { - FPrintF(stderr, "Cannot parse JSON from %s\n", config_path); - return std::nullopt; - } - result.main_path = - parser.GetTopLevelStringField("main").value_or(std::string()); - if (result.main_path.empty()) { - FPrintF(stderr, - "\"main\" field of %s is not a non-empty string\n", - config_path); - return std::nullopt; - } + simdjson::ondemand::parser parser; + simdjson::ondemand::document document; + simdjson::ondemand::object main_object; + simdjson::error_code error = + parser.iterate(simdjson::pad(config)).get(document); - result.output_path = - parser.GetTopLevelStringField("output").value_or(std::string()); - if (result.output_path.empty()) { + if (!error) { + error = document.get_object().get(main_object); + } + if (error) { FPrintF(stderr, - "\"output\" field of %s is not a non-empty string\n", - config_path); + "Cannot parse JSON from %s: %s\n", + config_path, + simdjson::error_message(error)); return std::nullopt; } - std::optional disable_experimental_sea_warning = - parser.GetTopLevelBoolField("disableExperimentalSEAWarning"); - if (!disable_experimental_sea_warning.has_value()) { - FPrintF(stderr, + bool use_snapshot_value = false; + bool use_code_cache_value = false; + + for (auto field : main_object) { + std::string_view key; + if (field.unescaped_key().get(key)) { + FPrintF(stderr, "Cannot read key from %s\n", config_path); + return std::nullopt; + } + if (key == "main") { + if (field.value().get_string().get(result.main_path) || + result.main_path.empty()) { + FPrintF(stderr, + "\"main\" field of %s is not a non-empty string\n", + config_path); + return std::nullopt; + } + } else if (key == "output") { + if (field.value().get_string().get(result.output_path) || + result.output_path.empty()) { + FPrintF(stderr, + "\"output\" field of %s is not a non-empty string\n", + config_path); + return std::nullopt; + } + } else if (key == "disableExperimentalSEAWarning") { + bool disable_experimental_sea_warning; + if (field.value().get_bool().get(disable_experimental_sea_warning)) { + FPrintF( + stderr, "\"disableExperimentalSEAWarning\" field of %s is not a Boolean\n", config_path); - return std::nullopt; - } - if (disable_experimental_sea_warning.value()) { - result.flags |= SeaFlags::kDisableExperimentalSeaWarning; + return std::nullopt; + } + if (disable_experimental_sea_warning) { + result.flags |= SeaFlags::kDisableExperimentalSeaWarning; + } + } else if (key == "useSnapshot") { + if (field.value().get_bool().get(use_snapshot_value)) { + FPrintF(stderr, + "\"useSnapshot\" field of %s is not a Boolean\n", + config_path); + return std::nullopt; + } + if (use_snapshot_value) { + result.flags |= SeaFlags::kUseSnapshot; + } + } else if (key == "useCodeCache") { + if (field.value().get_bool().get(use_code_cache_value)) { + FPrintF(stderr, + "\"useCodeCache\" field of %s is not a Boolean\n", + config_path); + return std::nullopt; + } + if (use_code_cache_value) { + result.flags |= SeaFlags::kUseCodeCache; + } + } else if (key == "assets") { + simdjson::ondemand::object assets_object; + if (field.value().get_object().get(assets_object)) { + FPrintF(stderr, + "\"assets\" field of %s is not a map of strings\n", + config_path); + return std::nullopt; + } + simdjson::ondemand::value asset_value; + for (auto asset_field : assets_object) { + std::string_view key_str; + std::string_view value_str; + if (asset_field.unescaped_key().get(key_str) || + asset_field.value().get(asset_value) || + asset_value.get_string().get(value_str)) { + FPrintF(stderr, + "\"assets\" field of %s is not a map of strings\n", + config_path); + return std::nullopt; + } + + result.assets.emplace(key_str, value_str); + } + + if (!result.assets.empty()) { + result.flags |= SeaFlags::kIncludeAssets; + } + } } - std::optional use_snapshot = parser.GetTopLevelBoolField("useSnapshot"); - if (!use_snapshot.has_value()) { - FPrintF( - stderr, "\"useSnapshot\" field of %s is not a Boolean\n", config_path); - return std::nullopt; - } - if (use_snapshot.value()) { - result.flags |= SeaFlags::kUseSnapshot; + if (static_cast(result.flags & SeaFlags::kUseSnapshot) && + static_cast(result.flags & SeaFlags::kUseCodeCache)) { + // TODO(joyeecheung): code cache in snapshot should be configured by + // separate snapshot configurations. + FPrintF(stderr, + "\"useCodeCache\" is redundant when \"useSnapshot\" is true\n"); } - std::optional use_code_cache = - parser.GetTopLevelBoolField("useCodeCache"); - if (!use_code_cache.has_value()) { - FPrintF( - stderr, "\"useCodeCache\" field of %s is not a Boolean\n", config_path); + if (result.main_path.empty()) { + FPrintF(stderr, + "\"main\" field of %s is not a non-empty string\n", + config_path); return std::nullopt; } - if (use_code_cache.value()) { - if (use_snapshot.value()) { - // TODO(joyeecheung): code cache in snapshot should be configured by - // separate snapshot configurations. - FPrintF(stderr, - "\"useCodeCache\" is redundant when \"useSnapshot\" is true\n"); - } else { - result.flags |= SeaFlags::kUseCodeCache; - } - } - auto assets_opt = parser.GetTopLevelStringDict("assets"); - if (!assets_opt.has_value()) { + if (result.output_path.empty()) { FPrintF(stderr, - "\"assets\" field of %s is not a map of strings\n", + "\"output\" field of %s is not a non-empty string\n", config_path); return std::nullopt; - } else if (!assets_opt.value().empty()) { - result.flags |= SeaFlags::kIncludeAssets; - result.assets = std::move(assets_opt.value()); } return result; diff --git a/test/parallel/test-single-executable-blob-config-errors.js b/test/parallel/test-single-executable-blob-config-errors.js index 364a533c0c90fb..a30850010e2e4d 100644 --- a/test/parallel/test-single-executable-blob-config-errors.js +++ b/test/parallel/test-single-executable-blob-config-errors.js @@ -5,113 +5,88 @@ require('../common'); const tmpdir = require('../common/tmpdir'); const { writeFileSync, mkdirSync } = require('fs'); -const { spawnSync } = require('child_process'); -const assert = require('assert'); +const { spawnSyncAndAssert } = require('../common/child_process'); { tmpdir.refresh(); const config = 'non-existent-relative.json'; - const child = spawnSync( + spawnSyncAndAssert( process.execPath, ['--experimental-sea-config', config], { cwd: tmpdir.path, + }, { + status: 1, + stderr: /Cannot read single executable configuration from non-existent-relative\.json/ }); - const stderr = child.stderr.toString(); - assert.strictEqual(child.status, 1); - assert.match( - stderr, - /Cannot read single executable configuration from non-existent-relative\.json/ - ); } { tmpdir.refresh(); const config = tmpdir.resolve('non-existent-absolute.json'); - const child = spawnSync( + spawnSyncAndAssert( process.execPath, ['--experimental-sea-config', config], { cwd: tmpdir.path, + }, { + status: 1, + stderr: /Cannot read single executable configuration from .*non-existent-absolute\.json/ }); - const stderr = child.stderr.toString(); - assert.strictEqual(child.status, 1); - assert( - stderr.includes( - `Cannot read single executable configuration from ${config}` - ) - ); } { tmpdir.refresh(); const config = tmpdir.resolve('invalid.json'); writeFileSync(config, '\n{\n"main"', 'utf8'); - const child = spawnSync( + spawnSyncAndAssert( process.execPath, ['--experimental-sea-config', config], { cwd: tmpdir.path, + }, { + status: 1, + stderr: /INCOMPLETE_ARRAY_OR_OBJECT/ }); - const stderr = child.stderr.toString(); - assert.strictEqual(child.status, 1); - assert.match(stderr, /SyntaxError: Expected ':' after property name/); - assert( - stderr.includes( - `Cannot parse JSON from ${config}` - ) - ); } { tmpdir.refresh(); const config = tmpdir.resolve('empty.json'); writeFileSync(config, '{}', 'utf8'); - const child = spawnSync( + spawnSyncAndAssert( process.execPath, ['--experimental-sea-config', config], { cwd: tmpdir.path, + }, { + status: 1, + stderr: /"main" field of .*empty\.json is not a non-empty string/ }); - const stderr = child.stderr.toString(); - assert.strictEqual(child.status, 1); - assert( - stderr.includes( - `"main" field of ${config} is not a non-empty string` - ) - ); } { tmpdir.refresh(); const config = tmpdir.resolve('no-main.json'); writeFileSync(config, '{"output": "test.blob"}', 'utf8'); - const child = spawnSync( + spawnSyncAndAssert( process.execPath, ['--experimental-sea-config', config], { cwd: tmpdir.path, + }, { + status: 1, + stderr: /"main" field of .*no-main\.json is not a non-empty string/ }); - const stderr = child.stderr.toString(); - assert.strictEqual(child.status, 1); - assert( - stderr.includes( - `"main" field of ${config} is not a non-empty string` - ) - ); } { tmpdir.refresh(); const config = tmpdir.resolve('no-output.json'); writeFileSync(config, '{"main": "bundle.js"}', 'utf8'); - const child = spawnSync( + spawnSyncAndAssert( process.execPath, ['--experimental-sea-config', config], { cwd: tmpdir.path, + }, { + status: 1, + stderr: /"output" field of .*no-output\.json is not a non-empty string/ }); - const stderr = child.stderr.toString(); - assert.strictEqual(child.status, 1); - assert( - stderr.includes( - `"output" field of ${config} is not a non-empty string` - ) - ); } { @@ -124,32 +99,28 @@ const assert = require('assert'); "disableExperimentalSEAWarning": "💥" } `, 'utf8'); - const child = spawnSync( + spawnSyncAndAssert( process.execPath, ['--experimental-sea-config', config], { cwd: tmpdir.path, + }, { + status: 1, + stderr: /"disableExperimentalSEAWarning" field of .*invalid-disableExperimentalSEAWarning\.json is not a Boolean/ }); - const stderr = child.stderr.toString(); - assert.strictEqual(child.status, 1); - assert( - stderr.includes( - `"disableExperimentalSEAWarning" field of ${config} is not a Boolean` - ) - ); } { tmpdir.refresh(); const config = tmpdir.resolve('nonexistent-main-relative.json'); writeFileSync(config, '{"main": "bundle.js", "output": "sea.blob"}', 'utf8'); - const child = spawnSync( + spawnSyncAndAssert( process.execPath, ['--experimental-sea-config', config], { cwd: tmpdir.path, + }, { + status: 1, + stderr: /Cannot read main script .*bundle\.js/ }); - const stderr = child.stderr.toString(); - assert.strictEqual(child.status, 1); - assert.match(stderr, /Cannot read main script bundle\.js/); } { @@ -161,19 +132,14 @@ const assert = require('assert'); output: 'sea.blob' }); writeFileSync(config, configJson, 'utf8'); - const child = spawnSync( + spawnSyncAndAssert( process.execPath, ['--experimental-sea-config', config], { cwd: tmpdir.path, + }, { + status: 1, + stderr: /Cannot read main script .*bundle\.js/ }); - - const stderr = child.stderr.toString(); - assert.strictEqual(child.status, 1); - assert( - stderr.includes( - `Cannot read main script ${main}` - ) - ); } { @@ -188,19 +154,14 @@ const assert = require('assert'); output, }); writeFileSync(config, configJson, 'utf8'); - const child = spawnSync( + spawnSyncAndAssert( process.execPath, ['--experimental-sea-config', config], { cwd: tmpdir.path, + }, { + status: 1, + stderr: /Cannot write output to .*output-dir/ }); - - const stderr = child.stderr.toString(); - assert.strictEqual(child.status, 1); - assert( - stderr.includes( - `Cannot write output to ${output}` - ) - ); } { @@ -215,13 +176,12 @@ const assert = require('assert'); output: 'output-dir' }); writeFileSync(config, configJson, 'utf8'); - const child = spawnSync( + spawnSyncAndAssert( process.execPath, ['--experimental-sea-config', config], { cwd: tmpdir.path, + }, { + status: 1, + stderr: /Cannot write output to output-dir/ }); - - const stderr = child.stderr.toString(); - assert.strictEqual(child.status, 1); - assert.match(stderr, /Cannot write output to output-dir/); } diff --git a/test/parallel/test-tls-get-ca-certificates-node-use-system-ca.js b/test/parallel/test-tls-get-ca-certificates-node-use-system-ca.js new file mode 100644 index 00000000000000..81a5cba4da77e2 --- /dev/null +++ b/test/parallel/test-tls-get-ca-certificates-node-use-system-ca.js @@ -0,0 +1,29 @@ +'use strict'; +// This tests that NODE_USE_SYSTEM_CA environment variable works the same +// as --use-system-ca flag by comparing certificate counts. + +const common = require('../common'); +if (!common.hasCrypto) common.skip('missing crypto'); + +const tls = require('tls'); +const { spawnSyncAndExitWithoutError } = require('../common/child_process'); + +const systemCerts = tls.getCACertificates('system'); +if (systemCerts.length === 0) { + common.skip('no system certificates available'); +} + +const { child: { stdout: expectedLength } } = spawnSyncAndExitWithoutError(process.execPath, [ + '--use-system-ca', + '-p', + `tls.getCACertificates('default').length`, +], { + env: { ...process.env, NODE_USE_SYSTEM_CA: '0' }, +}); + +spawnSyncAndExitWithoutError(process.execPath, [ + '-p', + `assert.strictEqual(tls.getCACertificates('default').length, ${expectedLength.toString()})`, +], { + env: { ...process.env, NODE_USE_SYSTEM_CA: '1' }, +}); diff --git a/test/system-ca/test-native-root-certs-env.mjs b/test/system-ca/test-native-root-certs-env.mjs new file mode 100644 index 00000000000000..bde7dfcd9610bc --- /dev/null +++ b/test/system-ca/test-native-root-certs-env.mjs @@ -0,0 +1,56 @@ +// Env: NODE_USE_SYSTEM_CA=1 +// Same as test-native-root-certs.mjs, just testing the environment variable instead of the flag. + +import * as common from '../common/index.mjs'; +import assert from 'node:assert/strict'; +import https from 'node:https'; +import fixtures from '../common/fixtures.js'; +import { it, beforeEach, afterEach, describe } from 'node:test'; +import { once } from 'events'; + +if (!common.hasCrypto) { + common.skip('requires crypto'); +} + +// To run this test, the system needs to be configured to trust +// the CA certificate first (which needs an interactive GUI approval, e.g. TouchID): +// see the README.md in this folder for instructions on how to do this. +const handleRequest = (req, res) => { + const path = req.url; + switch (path) { + case '/hello-world': + res.writeHead(200); + res.end('hello world\n'); + break; + default: + assert(false, `Unexpected path: ${path}`); + } +}; + +describe('use-system-ca', function() { + + async function setupServer(key, cert) { + const theServer = https.createServer({ + key: fixtures.readKey(key), + cert: fixtures.readKey(cert), + }, handleRequest); + theServer.listen(0); + await once(theServer, 'listening'); + + return theServer; + } + + let server; + + beforeEach(async function() { + server = await setupServer('agent8-key.pem', 'agent8-cert.pem'); + }); + + it('trusts a valid root certificate', async function() { + await fetch(`https://localhost:${server.address().port}/hello-world`); + }); + + afterEach(async function() { + server?.close(); + }); +});