diff options
author | Bradley Farias <bradley.meck@gmail.com> | 2018-09-13 14:27:12 -0500 |
---|---|---|
committer | Bradley Farias <bradley.meck@gmail.com> | 2019-01-17 09:43:42 -0600 |
commit | 9d5fbeb55fb1927928237e09475d39346d9c3ad9 (patch) | |
tree | ca2f567ff647c9a1706f39e93e54caa03cd98c1d /lib/internal | |
parent | 7b6e9aedaf8c9aa219ff759bed6b1680910eefe0 (diff) | |
download | android-node-v8-9d5fbeb55fb1927928237e09475d39346d9c3ad9.tar.gz android-node-v8-9d5fbeb55fb1927928237e09475d39346d9c3ad9.tar.bz2 android-node-v8-9d5fbeb55fb1927928237e09475d39346d9c3ad9.zip |
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: https://github.com/nodejs/node/pull/23834
Reviewed-By: Guy Bedford <guybedford@gmail.com>
Reviewed-By: Vladimir de Turckheim <vlad2t@hotmail.com>
Reviewed-By: Matteo Collina <matteo.collina@gmail.com>
Diffstat (limited to 'lib/internal')
-rw-r--r-- | lib/internal/bootstrap/node.js | 22 | ||||
-rw-r--r-- | lib/internal/errors.js | 25 | ||||
-rw-r--r-- | lib/internal/modules/cjs/loader.js | 37 | ||||
-rw-r--r-- | lib/internal/policy/manifest.js | 130 | ||||
-rw-r--r-- | lib/internal/policy/sri.js | 68 | ||||
-rw-r--r-- | lib/internal/process/policy.js | 33 | ||||
-rw-r--r-- | lib/internal/safe_globals.js | 1 |
7 files changed, 311 insertions, 5 deletions
diff --git a/lib/internal/bootstrap/node.js b/lib/internal/bootstrap/node.js index 26585f5df9..21f642e4d1 100644 --- a/lib/internal/bootstrap/node.js +++ b/lib/internal/bootstrap/node.js @@ -175,6 +175,28 @@ function startup() { mainThreadSetup.setupChildProcessIpcChannel(); } + // TODO(joyeecheung): move this down further to get better snapshotting + if (getOptionValue('[has_experimental_policy]')) { + process.emitWarning('Policies are experimental.', + 'ExperimentalWarning'); + const experimentalPolicy = getOptionValue('--experimental-policy'); + const { pathToFileURL, URL } = NativeModule.require('url'); + // URL here as it is slightly different parsing + // no bare specifiers for now + let manifestURL; + if (NativeModule.require('path').isAbsolute(experimentalPolicy)) { + manifestURL = new URL(`file:///${experimentalPolicy}`); + } else { + const cwdURL = pathToFileURL(process.cwd()); + cwdURL.pathname += '/'; + manifestURL = new URL(experimentalPolicy, cwdURL); + } + const fs = NativeModule.require('fs'); + const src = fs.readFileSync(manifestURL, 'utf8'); + NativeModule.require('internal/process/policy') + .setup(src, manifestURL.href); + } + const browserGlobals = !process._noBrowserGlobals; if (browserGlobals) { setupGlobalTimeouts(); diff --git a/lib/internal/errors.js b/lib/internal/errors.js index 7002776300..45701c8252 100644 --- a/lib/internal/errors.js +++ b/lib/internal/errors.js @@ -818,6 +818,28 @@ E('ERR_IPC_CHANNEL_CLOSED', 'Channel closed', Error); E('ERR_IPC_DISCONNECTED', 'IPC channel is already disconnected', Error); E('ERR_IPC_ONE_PIPE', 'Child process can have only one IPC pipe', Error); E('ERR_IPC_SYNC_FORK', 'IPC cannot be used with synchronous forks', Error); +E('ERR_MANIFEST_ASSERT_INTEGRITY', + (moduleURL, realIntegrities) => { + let msg = `The content of "${ + moduleURL + }" does not match the expected integrity.`; + if (realIntegrities.size) { + const sri = [...realIntegrities.entries()].map(([alg, dgs]) => { + return `${alg}-${dgs}`; + }).join(' '); + msg += ` Integrities found are: ${sri}`; + } else { + msg += ' The resource was not found in the policy.'; + } + return msg; + }, Error); +E('ERR_MANIFEST_INTEGRITY_MISMATCH', + 'Manifest resource %s has multiple entries but integrity lists do not match', + SyntaxError); +E('ERR_MANIFEST_TDZ', 'Manifest initialization has not yet run', Error); +E('ERR_MANIFEST_UNKNOWN_ONERROR', + 'Manifest specified unknown error behavior "%s".', + SyntaxError); E('ERR_METHOD_NOT_IMPLEMENTED', 'The %s method is not implemented', Error); E('ERR_MISSING_ARGS', (...args) => { @@ -889,6 +911,9 @@ E('ERR_SOCKET_BUFFER_SIZE', E('ERR_SOCKET_CANNOT_SEND', 'Unable to send data', Error); E('ERR_SOCKET_CLOSED', 'Socket is closed', Error); E('ERR_SOCKET_DGRAM_NOT_RUNNING', 'Not running', Error); +E('ERR_SRI_PARSE', + 'Subresource Integrity string %s had an unexpected at %d', + SyntaxError); E('ERR_STREAM_CANNOT_PIPE', 'Cannot pipe, not readable', Error); E('ERR_STREAM_DESTROYED', 'Cannot call %s after a stream was destroyed', Error); E('ERR_STREAM_NULL_VALUES', 'May not write null values to stream', TypeError); diff --git a/lib/internal/modules/cjs/loader.js b/lib/internal/modules/cjs/loader.js index f4551b7d47..1dcf5ef6ac 100644 --- a/lib/internal/modules/cjs/loader.js +++ b/lib/internal/modules/cjs/loader.js @@ -22,8 +22,8 @@ 'use strict'; const { NativeModule } = require('internal/bootstrap/loaders'); -const util = require('util'); const { pathToFileURL } = require('internal/url'); +const util = require('util'); const vm = require('vm'); const assert = require('assert').ok; const fs = require('fs'); @@ -45,6 +45,9 @@ const { getOptionValue } = require('internal/options'); const preserveSymlinks = getOptionValue('--preserve-symlinks'); const preserveSymlinksMain = getOptionValue('--preserve-symlinks-main'); const experimentalModules = getOptionValue('--experimental-modules'); +const manifest = getOptionValue('[has_experimental_policy]') ? + require('internal/process/policy').manifest : + null; const { ERR_INVALID_ARG_VALUE, @@ -168,6 +171,11 @@ function readPackage(requestPath) { return false; } + if (manifest) { + const jsonURL = pathToFileURL(jsonPath); + manifest.assertIntegrity(jsonURL, json); + } + try { return packageMainCache[requestPath] = JSON.parse(json).main; } catch (e) { @@ -676,6 +684,10 @@ function normalizeReferrerURL(referrer) { // the file. // Returns exception, if any. Module.prototype._compile = function(content, filename) { + if (manifest) { + const moduleURL = pathToFileURL(filename); + manifest.assertIntegrity(moduleURL, content); + } content = stripShebang(content); @@ -715,11 +727,14 @@ Module.prototype._compile = function(content, filename) { var depth = requireDepth; if (depth === 0) stat.cache = new Map(); var result; + var exports = this.exports; + var thisValue = exports; + var module = this; if (inspectorWrapper) { - result = inspectorWrapper(compiledWrapper, this.exports, this.exports, - require, this, filename, dirname); + result = inspectorWrapper(compiledWrapper, thisValue, exports, + require, module, filename, dirname); } else { - result = compiledWrapper.call(this.exports, this.exports, require, this, + result = compiledWrapper.call(thisValue, exports, require, module, filename, dirname); } if (depth === 0) stat.cache = null; @@ -736,7 +751,13 @@ Module._extensions['.js'] = function(module, filename) { // Native extension for .json Module._extensions['.json'] = function(module, filename) { - var content = fs.readFileSync(filename, 'utf8'); + const content = fs.readFileSync(filename, 'utf8'); + + if (manifest) { + const moduleURL = pathToFileURL(filename); + manifest.assertIntegrity(moduleURL, content); + } + try { module.exports = JSON.parse(stripBOM(content)); } catch (err) { @@ -748,6 +769,12 @@ Module._extensions['.json'] = function(module, filename) { // Native extension for .node Module._extensions['.node'] = function(module, filename) { + if (manifest) { + const content = fs.readFileSync(filename); + const moduleURL = pathToFileURL(filename); + manifest.assertIntegrity(moduleURL, content); + } + // be aware this doesn't use `content` return process.dlopen(module, path.toNamespacedPath(filename)); }; diff --git a/lib/internal/policy/manifest.js b/lib/internal/policy/manifest.js new file mode 100644 index 0000000000..272abf2457 --- /dev/null +++ b/lib/internal/policy/manifest.js @@ -0,0 +1,130 @@ +'use strict'; +const { + ERR_MANIFEST_ASSERT_INTEGRITY, + ERR_MANIFEST_INTEGRITY_MISMATCH, + ERR_MANIFEST_UNKNOWN_ONERROR, +} = require('internal/errors').codes; +const debug = require('util').debuglog('policy'); +const SRI = require('internal/policy/sri'); +const { SafeWeakMap } = require('internal/safe_globals'); +const crypto = require('crypto'); +const { Buffer } = require('buffer'); +const { URL } = require('url'); +const { createHash, timingSafeEqual } = crypto; +const HashUpdate = Function.call.bind(crypto.Hash.prototype.update); +const HashDigest = Function.call.bind(crypto.Hash.prototype.digest); +const BufferEquals = Function.call.bind(Buffer.prototype.equals); +const BufferToString = Function.call.bind(Buffer.prototype.toString); +const RegExpTest = Function.call.bind(RegExp.prototype.test); +const { entries } = Object; +const kIntegrities = new SafeWeakMap(); +const kReactions = new SafeWeakMap(); +const kRelativeURLStringPattern = /^\.{0,2}\//; +const { shouldAbortOnUncaughtException } = internalBinding('config'); +const { abort, exit, _rawDebug } = process; +function REACTION_THROW(error) { + throw error; +} +function REACTION_EXIT(error) { + REACTION_LOG(error); + if (shouldAbortOnUncaughtException) { + abort(); + } + exit(1); +} +function REACTION_LOG(error) { + _rawDebug(error.stack); +} +class Manifest { + constructor(obj, manifestURL) { + const integrities = { + __proto__: null, + }; + const reactions = { + __proto__: null, + integrity: REACTION_THROW, + }; + if (obj.onerror) { + const behavior = obj.onerror; + if (behavior === 'throw') { + } else if (behavior === 'exit') { + reactions.integrity = REACTION_EXIT; + } else if (behavior === 'log') { + reactions.integrity = REACTION_LOG; + } else { + throw new ERR_MANIFEST_UNKNOWN_ONERROR(behavior); + } + } + kReactions.set(this, Object.freeze(reactions)); + const manifestEntries = entries(obj.resources); + for (var i = 0; i < manifestEntries.length; i++) { + let url = manifestEntries[i][0]; + const integrity = manifestEntries[i][1].integrity; + if (integrity != null) { + debug(`Manifest contains integrity for url ${url}`); + if (RegExpTest(kRelativeURLStringPattern, url)) { + url = new URL(url, manifestURL).href; + } + const sri = Object.freeze(SRI.parse(integrity)); + if (url in integrities) { + const old = integrities[url]; + let mismatch = false; + if (old.length !== sri.length) { + mismatch = true; + } else { + compare: + for (var sriI = 0; sriI < sri.length; sriI++) { + for (var oldI = 0; oldI < old.length; oldI++) { + if (sri[sriI].algorithm === old[oldI].algorithm && + BufferEquals(sri[sriI].value, old[oldI].value) && + sri[sriI].options === old[oldI].options) { + continue compare; + } + } + mismatch = true; + break compare; + } + } + if (mismatch) { + throw new ERR_MANIFEST_INTEGRITY_MISMATCH(url); + } + } + integrities[url] = sri; + } + } + Object.freeze(integrities); + kIntegrities.set(this, integrities); + Object.freeze(this); + } + assertIntegrity(url, content) { + debug(`Checking integrity of ${url}`); + const integrities = kIntegrities.get(this); + const realIntegrities = new Map(); + if (integrities && url in integrities) { + const integrityEntries = integrities[url]; + // Avoid clobbered Symbol.iterator + for (var i = 0; i < integrityEntries.length; i++) { + const { + algorithm, + value: expected + } = integrityEntries[i]; + const hash = createHash(algorithm); + HashUpdate(hash, content); + const digest = HashDigest(hash); + if (digest.length === expected.length && + timingSafeEqual(digest, expected)) { + return true; + } + realIntegrities.set(algorithm, BufferToString(digest, 'base64')); + } + } + const error = new ERR_MANIFEST_ASSERT_INTEGRITY(url, realIntegrities); + kReactions.get(this).integrity(error); + } +} +// Lock everything down to avoid problems even if reference is leaked somehow +Object.setPrototypeOf(Manifest, null); +Object.setPrototypeOf(Manifest.prototype, null); +Object.freeze(Manifest); +Object.freeze(Manifest.prototype); +module.exports = Object.freeze({ Manifest }); diff --git a/lib/internal/policy/sri.js b/lib/internal/policy/sri.js new file mode 100644 index 0000000000..fff4e066b1 --- /dev/null +++ b/lib/internal/policy/sri.js @@ -0,0 +1,68 @@ +'use strict'; +// Value of https://w3c.github.io/webappsec-subresource-integrity/#the-integrity-attribute + +// Returns [{algorithm, value (in base64 string), options,}] +const { + ERR_SRI_PARSE +} = require('internal/errors').codes; +const kWSP = '[\\x20\\x09]'; +const kVCHAR = '[\\x21-\\x7E]'; +const kHASH_ALGO = 'sha256|sha384|sha512'; +// Base64 +const kHASH_VALUE = '[A-Za-z0-9+/]+[=]{0,2}'; +const kHASH_EXPRESSION = `(${kHASH_ALGO})-(${kHASH_VALUE})`; +const kOPTION_EXPRESSION = `(${kVCHAR}*)`; +const kHASH_WITH_OPTIONS = `${kHASH_EXPRESSION}(?:[?](${kOPTION_EXPRESSION}))?`; +const kSRIPattern = new RegExp(`(${kWSP}*)(?:${kHASH_WITH_OPTIONS})`, 'g'); +const { freeze } = Object; +Object.seal(kSRIPattern); +const kAllWSP = new RegExp(`^${kWSP}*$`); +Object.seal(kAllWSP); +const RegExpExec = Function.call.bind(RegExp.prototype.exec); +const RegExpTest = Function.call.bind(RegExp.prototype.test); +const StringSlice = Function.call.bind(String.prototype.slice); +const { + Buffer: { + from: BufferFrom + } +} = require('buffer'); +const { defineProperty } = Object; +const parse = (str) => { + kSRIPattern.lastIndex = 0; + let prevIndex = 0; + let match = RegExpExec(kSRIPattern, str); + const entries = []; + while (match) { + if (match.index !== prevIndex) { + throw new ERR_SRI_PARSE(str, prevIndex); + } + if (entries.length > 0) { + if (match[1] === '') { + throw new ERR_SRI_PARSE(str, prevIndex); + } + } + // Avoid setters being fired + defineProperty(entries, entries.length, { + enumerable: true, + configurable: true, + value: freeze({ + __proto__: null, + algorithm: match[2], + value: BufferFrom(match[3], 'base64'), + options: match[4] === undefined ? null : match[4], + }) + }); + prevIndex = prevIndex + match[0].length; + match = RegExpExec(kSRIPattern, str); + } + if (prevIndex !== str.length) { + if (!RegExpTest(kAllWSP, StringSlice(str, prevIndex))) { + throw new ERR_SRI_PARSE(str, prevIndex); + } + } + return entries; +}; + +module.exports = { + parse, +}; diff --git a/lib/internal/process/policy.js b/lib/internal/process/policy.js new file mode 100644 index 0000000000..f5ca4eeb07 --- /dev/null +++ b/lib/internal/process/policy.js @@ -0,0 +1,33 @@ +'use strict'; + +const { + ERR_MANIFEST_TDZ, +} = require('internal/errors').codes; +const { Manifest } = require('internal/policy/manifest'); +let manifest; +module.exports = Object.freeze({ + __proto__: null, + setup(src, url) { + if (src === null) { + manifest = null; + return; + } + const json = JSON.parse(src, (_, o) => { + if (o && typeof o === 'object') { + Reflect.setPrototypeOf(o, null); + Object.freeze(o); + } + return o; + }); + manifest = new Manifest(json, url); + }, + get manifest() { + if (typeof manifest === 'undefined') { + throw new ERR_MANIFEST_TDZ(); + } + return manifest; + }, + assertIntegrity(moduleURL, content) { + this.manifest.matchesIntegrity(moduleURL, content); + } +}); diff --git a/lib/internal/safe_globals.js b/lib/internal/safe_globals.js index 31de4137f0..109409d535 100644 --- a/lib/internal/safe_globals.js +++ b/lib/internal/safe_globals.js @@ -20,5 +20,6 @@ const makeSafe = (unsafe, safe) => { }; exports.SafeMap = makeSafe(Map, class SafeMap extends Map {}); +exports.SafeWeakMap = makeSafe(WeakMap, class SafeWeakMap extends WeakMap {}); exports.SafeSet = makeSafe(Set, class SafeSet extends Set {}); exports.SafePromise = makeSafe(Promise, class SafePromise extends Promise {}); |