Skip to content

Commit 9d5fbeb

Browse files
committed
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 7b6e9ae commit 9d5fbeb

File tree

16 files changed

+779
-5
lines changed

16 files changed

+779
-5
lines changed

doc/api/cli.md

Lines changed: 7 additions & 0 deletions
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

Lines changed: 42 additions & 0 deletions
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

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

doc/api/index.md

Lines changed: 1 addition & 0 deletions
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

Lines changed: 104 additions & 0 deletions
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

Lines changed: 3 additions & 0 deletions
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

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,28 @@ function startup() {
175175
mainThreadSetup.setupChildProcessIpcChannel();
176176
}
177177

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

lib/internal/errors.js

Lines changed: 25 additions & 0 deletions
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

Lines changed: 32 additions & 5 deletions
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,
@@ -168,6 +171,11 @@ function readPackage(requestPath) {
168171
return false;
169172
}
170173

174+
if (manifest) {
175+
const jsonURL = pathToFileURL(jsonPath);
176+
manifest.assertIntegrity(jsonURL, json);
177+
}
178+
171179
try {
172180
return packageMainCache[requestPath] = JSON.parse(json).main;
173181
} catch (e) {
@@ -676,6 +684,10 @@ function normalizeReferrerURL(referrer) {
676684
// the file.
677685
// Returns exception, if any.
678686
Module.prototype._compile = function(content, filename) {
687+
if (manifest) {
688+
const moduleURL = pathToFileURL(filename);
689+
manifest.assertIntegrity(moduleURL, content);
690+
}
679691

680692
content = stripShebang(content);
681693

@@ -715,11 +727,14 @@ Module.prototype._compile = function(content, filename) {
715727
var depth = requireDepth;
716728
if (depth === 0) stat.cache = new Map();
717729
var result;
730+
var exports = this.exports;
731+
var thisValue = exports;
732+
var module = this;
718733
if (inspectorWrapper) {
719-
result = inspectorWrapper(compiledWrapper, this.exports, this.exports,
720-
require, this, filename, dirname);
734+
result = inspectorWrapper(compiledWrapper, thisValue, exports,
735+
require, module, filename, dirname);
721736
} else {
722-
result = compiledWrapper.call(this.exports, this.exports, require, this,
737+
result = compiledWrapper.call(thisValue, exports, require, module,
723738
filename, dirname);
724739
}
725740
if (depth === 0) stat.cache = null;
@@ -736,7 +751,13 @@ Module._extensions['.js'] = function(module, filename) {
736751

737752
// Native extension for .json
738753
Module._extensions['.json'] = function(module, filename) {
739-
var content = fs.readFileSync(filename, 'utf8');
754+
const content = fs.readFileSync(filename, 'utf8');
755+
756+
if (manifest) {
757+
const moduleURL = pathToFileURL(filename);
758+
manifest.assertIntegrity(moduleURL, content);
759+
}
760+
740761
try {
741762
module.exports = JSON.parse(stripBOM(content));
742763
} catch (err) {
@@ -748,6 +769,12 @@ Module._extensions['.json'] = function(module, filename) {
748769

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

0 commit comments

Comments
 (0)