Skip to content

Commit da8c526

Browse files
bmecktargos
authored andcommitted
policy: manifest with subresource integrity checks
This enables code loaded via the module system to be checked for integrity to ensure the code loaded matches expectations. PR-URL: #23834 Reviewed-By: Guy Bedford <guybedford@gmail.com> Reviewed-By: Vladimir de Turckheim <vlad2t@hotmail.com> Reviewed-By: Matteo Collina <matteo.collina@gmail.com>
1 parent eac438a commit da8c526

File tree

16 files changed

+779
-5
lines changed

16 files changed

+779
-5
lines changed

doc/api/cli.md

+7
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,13 @@ added: v8.5.0
9090

9191
Enable experimental ES module support and caching modules.
9292

93+
### `--experimental-policy`
94+
<!-- YAML
95+
added: TODO
96+
-->
97+
98+
Use the specified file as a security policy.
99+
93100
### `--experimental-repl-await`
94101
<!-- YAML
95102
added: v10.0.0

doc/api/errors.md

+42
Original file line numberDiff line numberDiff line change
@@ -1380,6 +1380,39 @@ An attempt was made to open an IPC communication channel with a synchronously
13801380
forked Node.js process. See the documentation for the [`child_process`][] module
13811381
for more information.
13821382

1383+
<a id="ERR_MANIFEST_ASSERT_INTEGRITY"></a>
1384+
### ERR_MANIFEST_ASSERT_INTEGRITY
1385+
1386+
An attempt was made to load a resource, but the resource did not match the
1387+
integrity defined by the policy manifest. See the documentation for [policy]
1388+
manifests for more information.
1389+
1390+
<a id="ERR_MANIFEST_INTEGRITY_MISMATCH"></a>
1391+
### ERR_MANIFEST_INTEGRITY_MISMATCH
1392+
1393+
An attempt was made to load a policy manifest, but the manifest had multiple
1394+
entries for a resource which did not match each other. Update the manifest
1395+
entries to match in order to resolve this error. See the documentation for
1396+
[policy] manifests for more information.
1397+
1398+
<a id="ERR_MANIFEST_PARSE_POLICY"></a>
1399+
### ERR_MANIFEST_PARSE_POLICY
1400+
1401+
An attempt was made to load a policy manifest, but the manifest was unable to
1402+
be parsed. See the documentation for [policy] manifests for more information.
1403+
1404+
<a id="ERR_MANIFEST_TDZ"></a>
1405+
### ERR_MANIFEST_TDZ
1406+
1407+
An attempt was made to read from a policy manifest, but the manifest
1408+
initialization has not yet taken place. This is likely a bug in Node.js.
1409+
1410+
<a id="ERR_MANIFEST_UNKNOWN_ONERROR"></a>
1411+
### ERR_MANIFEST_UNKNOWN_ONERROR
1412+
1413+
A policy manifest was loaded, but had an unknown value for its "onerror"
1414+
behavior. See the documentation for [policy] manifests for more information.
1415+
13831416
<a id="ERR_MEMORY_ALLOCATION_FAILED"></a>
13841417
### ERR_MEMORY_ALLOCATION_FAILED
13851418

@@ -1590,6 +1623,13 @@ An attempt was made to operate on an already closed socket.
15901623

15911624
A call was made and the UDP subsystem was not running.
15921625

1626+
<a id="ERR_SRI_PARSE"></a>
1627+
### ERR_SRI_PARSE
1628+
1629+
A string was provided for a Subresource Integrity check, but was unable to be
1630+
parsed. Check the format of integrity attributes by looking at the
1631+
[Subresource Integrity specification][].
1632+
15931633
<a id="ERR_STREAM_CANNOT_PIPE"></a>
15941634
### ERR_STREAM_CANNOT_PIPE
15951635

@@ -2228,7 +2268,9 @@ such as `process.stdout.on('data')`.
22282268
[domains]: domain.html
22292269
[event emitter-based]: events.html#events_class_eventemitter
22302270
[file descriptors]: https://en.wikipedia.org/wiki/File_descriptor
2271+
[policy]: policy.html
22312272
[stream-based]: stream.html
22322273
[syscall]: http://man7.org/linux/man-pages/man2/syscalls.2.html
2274+
[Subresource Integrity specification]: https://www.w3.org/TR/SRI/#the-integrity-attribute
22332275
[try-catch]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/try...catch
22342276
[vm]: vm.html

doc/api/index.md

+1
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
* [OS](os.html)
4040
* [Path](path.html)
4141
* [Performance Hooks](perf_hooks.html)
42+
* [Policies](policy.html)
4243
* [Process](process.html)
4344
* [Punycode](punycode.html)
4445
* [Query Strings](querystring.html)

doc/api/policy.md

+104
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
# Policies
2+
3+
<!--introduced_in=TODO-->
4+
<!-- type=misc -->
5+
6+
> Stability: 1 - Experimental
7+
8+
<!-- name=policy -->
9+
10+
Node.js contains experimental support for creating policies on loading code.
11+
12+
Policies are a security feature intended to allow guarantees
13+
about what code Node.js is able to load. The use of policies assumes
14+
safe practices for the policy files such as ensuring that policy
15+
files cannot be overwritten by the Node.js application by using
16+
file permissions.
17+
18+
A best practice would be to ensure that the policy manifest is read only for
19+
the running Node.js application, and that the file cannot be changed
20+
by the running Node.js application in any way. A typical setup would be to
21+
create the policy file as a different user id than the one running Node.js
22+
and granting read permissions to the user id running Node.js.
23+
24+
## Enabling
25+
26+
<!-- type=misc -->
27+
28+
The `--experimental-policy` flag can be used to enable features for policies
29+
when loading modules.
30+
31+
Once this has been set, all modules must conform to a policy manifest file
32+
passed to the flag:
33+
34+
```sh
35+
node --experimental-policy=policy.json app.js
36+
```
37+
38+
The policy manifest will be used to enforce constraints on code loaded by
39+
Node.js.
40+
41+
## Features
42+
43+
### Error Behavior
44+
45+
When a policy check fails, Node.js by default will throw an error.
46+
It is possible to change the error behavior to one of a few possibilities
47+
by defining an "onerror" field in a policy manifest. The following values are
48+
available to change the behavior:
49+
50+
* `"exit"` - will exit the process immediately.
51+
No cleanup code will be allowed to run.
52+
* `"log"` - will log the error at the site of the failure.
53+
* `"throw"` (default) - will throw a JS error at the site of the failure.
54+
55+
```json
56+
{
57+
"onerror": "log",
58+
"resources": {
59+
"./app/checked.js": {
60+
"integrity": "sha384-SggXRQHwCG8g+DktYYzxkXRIkTiEYWBHqev0xnpCxYlqMBufKZHAHQM3/boDaI/0"
61+
}
62+
}
63+
}
64+
```
65+
66+
### Integrity Checks
67+
68+
Policy files must use integrity checks with Subresource Integrity strings
69+
compatible with the browser
70+
[integrity attribute](https://www.w3.org/TR/SRI/#the-integrity-attribute)
71+
associated with absolute URLs.
72+
73+
When using `require()` all resources involved in loading are checked for
74+
integrity if a policy manifest has been specified. If a resource does not match
75+
the integrity listed in the manifest, an error will be thrown.
76+
77+
An example policy file that would allow loading a file `checked.js`:
78+
79+
```json
80+
{
81+
"resources": {
82+
"./app/checked.js": {
83+
"integrity": "sha384-SggXRQHwCG8g+DktYYzxkXRIkTiEYWBHqev0xnpCxYlqMBufKZHAHQM3/boDaI/0"
84+
}
85+
}
86+
}
87+
```
88+
89+
Each resource listed in the policy manifest can be of one the following
90+
formats to determine its location:
91+
92+
1. A [relative url string][] to a resource from the manifest such as `./resource.js`, `../resource.js`, or `/resource.js`.
93+
2. A complete url string to a resource such as `file:///resource.js`.
94+
95+
When loading resources the entire URL must match including search parameters
96+
and hash fragment. `./a.js?b` will not be used when attempting to load
97+
`./a.js` and vice versa.
98+
99+
In order to generate integrity strings, a script such as
100+
`printf "sha384-$(cat checked.js | openssl dgst -sha384 -binary | base64)"`
101+
can be used.
102+
103+
104+
[relative url string]: https://url.spec.whatwg.org/#relative-url-with-fragment-string

doc/node.1

+3
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,9 @@ Requires Node.js to be built with
8686
.It Fl -experimental-modules
8787
Enable experimental ES module support and caching modules.
8888
.
89+
.It Fl -experimental-policy
90+
Use the specified file as a security policy.
91+
.
8992
.It Fl -experimental-repl-await
9093
Enable experimental top-level
9194
.Sy await

lib/internal/bootstrap/node.js

+22
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,28 @@ function startup() {
171171
mainThreadSetup.setupChildProcessIpcChannel();
172172
}
173173

174+
// TODO(joyeecheung): move this down further to get better snapshotting
175+
if (getOptionValue('[has_experimental_policy]')) {
176+
process.emitWarning('Policies are experimental.',
177+
'ExperimentalWarning');
178+
const experimentalPolicy = getOptionValue('--experimental-policy');
179+
const { pathToFileURL, URL } = NativeModule.require('url');
180+
// URL here as it is slightly different parsing
181+
// no bare specifiers for now
182+
let manifestURL;
183+
if (NativeModule.require('path').isAbsolute(experimentalPolicy)) {
184+
manifestURL = new URL(`file:///${experimentalPolicy}`);
185+
} else {
186+
const cwdURL = pathToFileURL(process.cwd());
187+
cwdURL.pathname += '/';
188+
manifestURL = new URL(experimentalPolicy, cwdURL);
189+
}
190+
const fs = NativeModule.require('fs');
191+
const src = fs.readFileSync(manifestURL, 'utf8');
192+
NativeModule.require('internal/process/policy')
193+
.setup(src, manifestURL.href);
194+
}
195+
174196
const browserGlobals = !process._noBrowserGlobals;
175197
if (browserGlobals) {
176198
setupGlobalTimeouts();

lib/internal/errors.js

+25
Original file line numberDiff line numberDiff line change
@@ -818,6 +818,28 @@ E('ERR_IPC_CHANNEL_CLOSED', 'Channel closed', Error);
818818
E('ERR_IPC_DISCONNECTED', 'IPC channel is already disconnected', Error);
819819
E('ERR_IPC_ONE_PIPE', 'Child process can have only one IPC pipe', Error);
820820
E('ERR_IPC_SYNC_FORK', 'IPC cannot be used with synchronous forks', Error);
821+
E('ERR_MANIFEST_ASSERT_INTEGRITY',
822+
(moduleURL, realIntegrities) => {
823+
let msg = `The content of "${
824+
moduleURL
825+
}" does not match the expected integrity.`;
826+
if (realIntegrities.size) {
827+
const sri = [...realIntegrities.entries()].map(([alg, dgs]) => {
828+
return `${alg}-${dgs}`;
829+
}).join(' ');
830+
msg += ` Integrities found are: ${sri}`;
831+
} else {
832+
msg += ' The resource was not found in the policy.';
833+
}
834+
return msg;
835+
}, Error);
836+
E('ERR_MANIFEST_INTEGRITY_MISMATCH',
837+
'Manifest resource %s has multiple entries but integrity lists do not match',
838+
SyntaxError);
839+
E('ERR_MANIFEST_TDZ', 'Manifest initialization has not yet run', Error);
840+
E('ERR_MANIFEST_UNKNOWN_ONERROR',
841+
'Manifest specified unknown error behavior "%s".',
842+
SyntaxError);
821843
E('ERR_METHOD_NOT_IMPLEMENTED', 'The %s method is not implemented', Error);
822844
E('ERR_MISSING_ARGS',
823845
(...args) => {
@@ -889,6 +911,9 @@ E('ERR_SOCKET_BUFFER_SIZE',
889911
E('ERR_SOCKET_CANNOT_SEND', 'Unable to send data', Error);
890912
E('ERR_SOCKET_CLOSED', 'Socket is closed', Error);
891913
E('ERR_SOCKET_DGRAM_NOT_RUNNING', 'Not running', Error);
914+
E('ERR_SRI_PARSE',
915+
'Subresource Integrity string %s had an unexpected at %d',
916+
SyntaxError);
892917
E('ERR_STREAM_CANNOT_PIPE', 'Cannot pipe, not readable', Error);
893918
E('ERR_STREAM_DESTROYED', 'Cannot call %s after a stream was destroyed', Error);
894919
E('ERR_STREAM_NULL_VALUES', 'May not write null values to stream', TypeError);

lib/internal/modules/cjs/loader.js

+32-5
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,8 @@
2222
'use strict';
2323

2424
const { NativeModule } = require('internal/bootstrap/loaders');
25-
const util = require('util');
2625
const { pathToFileURL } = require('internal/url');
26+
const util = require('util');
2727
const vm = require('vm');
2828
const assert = require('assert').ok;
2929
const fs = require('fs');
@@ -45,6 +45,9 @@ const { getOptionValue } = require('internal/options');
4545
const preserveSymlinks = getOptionValue('--preserve-symlinks');
4646
const preserveSymlinksMain = getOptionValue('--preserve-symlinks-main');
4747
const experimentalModules = getOptionValue('--experimental-modules');
48+
const manifest = getOptionValue('[has_experimental_policy]') ?
49+
require('internal/process/policy').manifest :
50+
null;
4851

4952
const {
5053
ERR_INVALID_ARG_VALUE,
@@ -164,6 +167,11 @@ function readPackage(requestPath) {
164167
return false;
165168
}
166169

170+
if (manifest) {
171+
const jsonURL = pathToFileURL(jsonPath);
172+
manifest.assertIntegrity(jsonURL, json);
173+
}
174+
167175
try {
168176
return packageMainCache[requestPath] = JSON.parse(json).main;
169177
} catch (e) {
@@ -675,6 +683,10 @@ function normalizeReferrerURL(referrer) {
675683
// the file.
676684
// Returns exception, if any.
677685
Module.prototype._compile = function(content, filename) {
686+
if (manifest) {
687+
const moduleURL = pathToFileURL(filename);
688+
manifest.assertIntegrity(moduleURL, content);
689+
}
678690

679691
content = stripShebang(content);
680692

@@ -714,11 +726,14 @@ Module.prototype._compile = function(content, filename) {
714726
var depth = requireDepth;
715727
if (depth === 0) stat.cache = new Map();
716728
var result;
729+
var exports = this.exports;
730+
var thisValue = exports;
731+
var module = this;
717732
if (inspectorWrapper) {
718-
result = inspectorWrapper(compiledWrapper, this.exports, this.exports,
719-
require, this, filename, dirname);
733+
result = inspectorWrapper(compiledWrapper, thisValue, exports,
734+
require, module, filename, dirname);
720735
} else {
721-
result = compiledWrapper.call(this.exports, this.exports, require, this,
736+
result = compiledWrapper.call(thisValue, exports, require, module,
722737
filename, dirname);
723738
}
724739
if (depth === 0) stat.cache = null;
@@ -735,7 +750,13 @@ Module._extensions['.js'] = function(module, filename) {
735750

736751
// Native extension for .json
737752
Module._extensions['.json'] = function(module, filename) {
738-
var content = fs.readFileSync(filename, 'utf8');
753+
const content = fs.readFileSync(filename, 'utf8');
754+
755+
if (manifest) {
756+
const moduleURL = pathToFileURL(filename);
757+
manifest.assertIntegrity(moduleURL, content);
758+
}
759+
739760
try {
740761
module.exports = JSON.parse(stripBOM(content));
741762
} catch (err) {
@@ -747,6 +768,12 @@ Module._extensions['.json'] = function(module, filename) {
747768

748769
// Native extension for .node
749770
Module._extensions['.node'] = function(module, filename) {
771+
if (manifest) {
772+
const content = fs.readFileSync(filename);
773+
const moduleURL = pathToFileURL(filename);
774+
manifest.assertIntegrity(moduleURL, content);
775+
}
776+
// be aware this doesn't use `content`
750777
return process.dlopen(module, path.toNamespacedPath(filename));
751778
};
752779

0 commit comments

Comments
 (0)