From a590159dd5efbc2b6dc9b6d4226a8f2cf0312e9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kat=20March=C3=A1n?= Date: Mon, 3 Apr 2017 02:02:32 -0700 Subject: [PATCH] feat(integrity): full Subresource Integrity support (#10) Fixes: #7 --- README.md | 4 +- cache.js | 34 ++++++++---- index.js | 20 +++++++- package.json | 5 +- test/integrity.js | 128 ++++++++++++++++++++++++++++++++++++++++++++-- 5 files changed, 171 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 89704bf..3053aac 100644 --- a/README.md +++ b/README.md @@ -281,11 +281,9 @@ fetch('http://reliable.site.com', { #### `> opts.integrity` -**(NOT IMPLEMENTED YET)** - Matches the response body against the given [Subresource Integrity](https://developer.mozilla.org/en-US/docs/Web/Security/Subresource_Integrity) metadata. If verification fails, the request will fail with an `EBADCHECKSUM` error. -`integrity` may either be a string or an [`ssri`](https://npm.im/ssri) Integrity-like. +`integrity` may either be a string or an [`ssri`](https://npm.im/ssri) `Integrity`-like. ##### Example diff --git a/cache.js b/cache.js index 3fb169f..d86ce2e 100644 --- a/cache.js +++ b/cache.js @@ -4,6 +4,7 @@ const cacache = require('cacache') const fetch = require('node-fetch') const fs = require('fs') const pipe = require('mississippi').pipe +const ssri = require('ssri') const through = require('mississippi').through const to = require('mississippi').to const url = require('url') @@ -37,12 +38,14 @@ module.exports = class Cache { // Returns a Promise that resolves to the response associated with the first // matching request in the Cache object. - match (req) { + match (req, opts) { return cacache.get.info(this._path, cacheKey(req)).then(info => { if (info && matchDetails(req, { url: info.metadata.url, reqHeaders: new fetch.Headers(info.metadata.reqHeaders), - resHeaders: new fetch.Headers(info.metadata.resHeaders) + resHeaders: new fetch.Headers(info.metadata.resHeaders), + cacheIntegrity: info.integrity, + integrity: opts && opts.integrity })) { if (req.method === 'HEAD') { return new fetch.Response(null, { @@ -70,13 +73,14 @@ module.exports = class Cache { } else { disturbed = true if (stat.size > MAX_MEM_SIZE) { - pipe(cacache.get.stream.byDigest(cachePath, info.digest, { - hashAlgorithm: info.hashAlgorithm - }), body, () => {}) + pipe( + cacache.get.stream.byDigest(cachePath, info.integrity), + body, + () => {} + ) } else { // cacache is much faster at bulk reads - cacache.get.byDigest(cachePath, info.digest, { - hashAlgorithm: info.hashAlgorithm, + cacache.get.byDigest(cachePath, info.integrity, { memoize: true }).then(data => { body.write(data, () => { @@ -120,11 +124,10 @@ module.exports = class Cache { // Update metadata without writing return cacache.get.info(this._path, cacheKey(req)).then(info => { // Providing these will bypass content write - opts.hashAlgorithm = info.hashAlgorithm - opts.digest = info.digest + opts.integrity = info.integrity return new this.Promise((resolve, reject) => { pipe( - cacache.get.stream.byDigest(this._path, info.digest, opts), + cacache.get.stream.byDigest(this._path, info.integrity, opts), cacache.put.stream(this._path, cacheKey(req), opts), err => err ? reject(err) : resolve(response) ) @@ -212,6 +215,17 @@ function matchDetails (req, cached) { } } } + if (cached.integrity) { + const cachedSri = ssri.parse(cached.cacheIntegrity) + const sri = ssri.parse(cached.integrity) + const algo = sri.pickAlgorithm() + if (cachedSri[algo] && !sri[algo].some(hash => { + // cachedSri always has exactly one item per algorithm + return cachedSri[algo][0].digest === hash.digest + })) { + return false + } + } reqUrl.hash = null cacheUrl.hash = null return url.format(reqUrl) === url.format(cacheUrl) diff --git a/index.js b/index.js index 7513496..93aac9b 100644 --- a/index.js +++ b/index.js @@ -7,6 +7,7 @@ const https = require('https') let ProxyAgent const pkg = require('./package.json') const retry = require('promise-retry') +let ssri const Stream = require('stream') const url = require('url') @@ -45,6 +46,9 @@ function cachingFetch (uri, _opts) { // Default cacache-based cache Cache = require('./cache') } + if (opts.integrity && !ssri) { + ssri = require('ssri') + } opts.cacheManager = opts.cacheManager && ( typeof opts.cacheManager === 'string' ? new Cache(opts.cacheManager, opts.cacheOpts) @@ -71,7 +75,7 @@ function cachingFetch (uri, _opts) { method: opts.method, headers: opts.headers }) - return opts.cacheManager.match(req, opts.cacheOpts).then(res => { + return opts.cacheManager.match(req, opts).then(res => { if (res) { const warningCode = (res.headers.get('Warning') || '').match(/^\d+/) if (warningCode && +warningCode >= 100 && +warningCode < 200) { @@ -238,6 +242,20 @@ function remoteFetch (uri, opts) { return retry((retryHandler, attemptNum) => { const req = new fetch.Request(uri, reqOpts) return fetch(req).then(res => { + if (opts.integrity) { + const oldBod = res.body + const newBod = ssri.integrityStream({ + integrity: opts.integrity + }) + oldBod.pipe(newBod) + res.body = newBod + oldBod.once('error', err => { + newBod.emit('error', err) + }) + newBod.once('error', err => { + oldBod.emit('error', err) + }) + } const cacheCtrl = res.headers.get('cache-control') || '' if ( (req.method === 'GET' || req.method === 'HEAD') && diff --git a/package.json b/package.json index ea8abd2..586e326 100644 --- a/package.json +++ b/package.json @@ -34,14 +34,15 @@ "license": "CC0-1.0", "dependencies": { "bluebird": "^3.5.0", - "cacache": "^6.3.0", + "cacache": "^7.0.1", "checksum-stream": "^1.0.2", "lru-cache": "^4.0.2", "mississippi": "^1.2.0", "node-fetch": "^2.0.0-alpha.3", "promise-retry": "^1.1.1", "proxy-agent": "^2.0.0", - "safe-buffer": "^5.0.1" + "safe-buffer": "^5.0.1", + "ssri": "^3.0.2" }, "devDependencies": { "mkdirp": "^0.5.1", diff --git a/test/integrity.js b/test/integrity.js index 366666e..6b39981 100644 --- a/test/integrity.js +++ b/test/integrity.js @@ -1,8 +1,128 @@ 'use strict' +const Buffer = require('safe-buffer').Buffer + +const ssri = require('ssri') const test = require('tap').test +const tnock = require('./util/tnock') + +const CACHE = require('./util/test-dir')(__filename) +const CONTENT = Buffer.from('hello, world!', 'utf8') +const INTEGRITY = ssri.fromData(CONTENT) +const HOST = 'https://make-fetch-happen-safely.npm' + +const fetch = require('..').defaults({retry: false}) + +test('basic integrity verification', t => { + const srv = tnock(t, HOST) + srv.get('/wowsosafe').reply(200, CONTENT) + srv.get('/wowsobad').reply(200, Buffer.from('pwnd')) + const safetch = fetch.defaults({ + integrity: INTEGRITY + }) + return safetch(`${HOST}/wowsosafe`).then(res => { + return res.buffer() + }).then(buf => { + t.deepEqual(buf, CONTENT, 'good content passed scrutiny 👍🏼') + return safetch(`${HOST}/wowsobad`).then(res => { + return res.buffer() + }).then(buf => { + throw new Error(`bad data: ${buf.toString('utf8')}`) + }).catch(err => { + t.equal(err.code, 'EBADCHECKSUM', 'content failed checksum!') + }) + }) +}) + +test('picks the "best" algorithm', t => { + const integrity = ssri.fromData(CONTENT, { + algorithms: ['md5', 'sha384', 'sha1', 'sha256'] + }) + integrity['md5'][0].digest = 'badc0ffee' + integrity['sha1'][0].digest = 'badc0ffee' + const safetch = fetch.defaults({integrity}) + const srv = tnock(t, HOST) + srv.get('/good').times(3).reply(200, CONTENT) + srv.get('/bad').reply(200, 'pwnt') + return safetch(`${HOST}/good`).then(res => res.buffer()).then(buf => { + t.deepEqual(buf, CONTENT, 'data passed integrity check') + return safetch(`${HOST}/bad`).then(res => { + return res.buffer() + }).then(buf => { + throw new Error(`bad data: ${buf.toString('utf8')}`) + }).catch(err => { + t.equal(err.code, 'EBADCHECKSUM', 'content validated with either sha256 or sha384 (likely the latter)') + }) + }).then(() => { + // invalidate sha384. sha256 is still valid, in theory + integrity['sha384'][0].digest = 'pwnt' + return safetch(`${HOST}/good`).then(res => { + return res.buffer() + }).then(buf => { + throw new Error(`bad data: ${buf.toString('utf8')}`) + }).catch(err => { + t.equal(err.code, 'EBADCHECKSUM', 'strongest algorithm (sha384) treated as authoritative -- sha256 not used') + }) + }).then(() => { + // remove bad sha384 altogether. sha256 remains valid + delete integrity['sha384'] + return safetch(`${HOST}/good`).then(res => res.buffer()) + }).then(buf => { + t.deepEqual(buf, CONTENT, 'data passed integrity check with sha256') + }) +}) + +test('supports multiple hashes per algorithm', t => { + const ALTCONTENT = Buffer.from('alt-content is like content but not really') + const integrity = ssri.fromData(CONTENT, { + algorithms: ['md5', 'sha384', 'sha1', 'sha256'] + }).concat(ssri.fromData(ALTCONTENT, { + algorithms: ['sha384'] + })) + const safetch = fetch.defaults({integrity}) + const srv = tnock(t, HOST) + srv.get('/main').reply(200, CONTENT) + srv.get('/alt').reply(200, ALTCONTENT) + srv.get('/bad').reply(200, 'nope') + return safetch(`${HOST}/main`).then(res => res.buffer()).then(buf => { + t.deepEqual(buf, CONTENT, 'main content validated against sha384') + return safetch(`${HOST}/alt`).then(res => res.buffer()) + }).then(buf => { + t.deepEqual(buf, ALTCONTENT, 'alt content validated against sha384') + return safetch(`${HOST}/bad`).then(res => res.buffer()).then(buf => { + throw new Error(`bad data: ${buf.toString('utf8')}`) + }).catch(err => { + t.equal(err.code, 'EBADCHECKSUM', 'only the two valid contents pass') + }) + }) +}) -test('basic integrity verification') -test('picks the "best" algorithm') -test('fails with EBADCHECKSUM if integrity fails') -test('checks integrity on cache fetch too') +test('checks integrity on cache fetch too', t => { + const srv = tnock(t, HOST) + srv.get('/test').reply(200, CONTENT) + const safetch = fetch.defaults({ + cacheManager: CACHE, + integrity: INTEGRITY, + cache: 'must-revalidate' + }) + return safetch(`${HOST}/test`).then(res => res.buffer()).then(buf => { + t.deepEqual(buf, CONTENT, 'good content passed scrutiny 👍🏼') + srv.get('/test').reply(200, 'nope') + return safetch(`${HOST}/test`).then(res => res.buffer()).then(buf => { + throw new Error(`bad data: ${buf.toString('utf8')}`) + }).catch(err => { + t.equal(err.code, 'EBADCHECKSUM', 'cached content failed checksum!') + }) + }).then(() => { + srv.get('/test').reply(200, 'nope') + return safetch(`${HOST}/test`, { + // try to use local cached version + cache: 'force-cache', + integrity: {algorithm: 'sha512', digest: 'doesnotmatch'} + }).then(res => res.buffer()).then(buf => { + throw new Error(`bad data: ${buf.toString('utf8')}`) + }).catch(err => { + t.equal(err.code, 'EBADCHECKSUM', 'cached content failed checksum!') + }) + }) +})