Skip to content

Commit a590159

Browse files
authored
feat(integrity): full Subresource Integrity support (#10)
Fixes: #7
1 parent 11774a1 commit a590159

File tree

5 files changed

+171
-20
lines changed

5 files changed

+171
-20
lines changed

README.md

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -281,11 +281,9 @@ fetch('http://reliable.site.com', {
281281

282282
#### <a name="opts-integrity"></a> `> opts.integrity`
283283

284-
**(NOT IMPLEMENTED YET)**
285-
286284
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.
287285

288-
`integrity` may either be a string or an [`ssri`](https://npm.im/ssri) Integrity-like.
286+
`integrity` may either be a string or an [`ssri`](https://npm.im/ssri) `Integrity`-like.
289287

290288
##### Example
291289

cache.js

Lines changed: 24 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ const cacache = require('cacache')
44
const fetch = require('node-fetch')
55
const fs = require('fs')
66
const pipe = require('mississippi').pipe
7+
const ssri = require('ssri')
78
const through = require('mississippi').through
89
const to = require('mississippi').to
910
const url = require('url')
@@ -37,12 +38,14 @@ module.exports = class Cache {
3738

3839
// Returns a Promise that resolves to the response associated with the first
3940
// matching request in the Cache object.
40-
match (req) {
41+
match (req, opts) {
4142
return cacache.get.info(this._path, cacheKey(req)).then(info => {
4243
if (info && matchDetails(req, {
4344
url: info.metadata.url,
4445
reqHeaders: new fetch.Headers(info.metadata.reqHeaders),
45-
resHeaders: new fetch.Headers(info.metadata.resHeaders)
46+
resHeaders: new fetch.Headers(info.metadata.resHeaders),
47+
cacheIntegrity: info.integrity,
48+
integrity: opts && opts.integrity
4649
})) {
4750
if (req.method === 'HEAD') {
4851
return new fetch.Response(null, {
@@ -70,13 +73,14 @@ module.exports = class Cache {
7073
} else {
7174
disturbed = true
7275
if (stat.size > MAX_MEM_SIZE) {
73-
pipe(cacache.get.stream.byDigest(cachePath, info.digest, {
74-
hashAlgorithm: info.hashAlgorithm
75-
}), body, () => {})
76+
pipe(
77+
cacache.get.stream.byDigest(cachePath, info.integrity),
78+
body,
79+
() => {}
80+
)
7681
} else {
7782
// cacache is much faster at bulk reads
78-
cacache.get.byDigest(cachePath, info.digest, {
79-
hashAlgorithm: info.hashAlgorithm,
83+
cacache.get.byDigest(cachePath, info.integrity, {
8084
memoize: true
8185
}).then(data => {
8286
body.write(data, () => {
@@ -120,11 +124,10 @@ module.exports = class Cache {
120124
// Update metadata without writing
121125
return cacache.get.info(this._path, cacheKey(req)).then(info => {
122126
// Providing these will bypass content write
123-
opts.hashAlgorithm = info.hashAlgorithm
124-
opts.digest = info.digest
127+
opts.integrity = info.integrity
125128
return new this.Promise((resolve, reject) => {
126129
pipe(
127-
cacache.get.stream.byDigest(this._path, info.digest, opts),
130+
cacache.get.stream.byDigest(this._path, info.integrity, opts),
128131
cacache.put.stream(this._path, cacheKey(req), opts),
129132
err => err ? reject(err) : resolve(response)
130133
)
@@ -212,6 +215,17 @@ function matchDetails (req, cached) {
212215
}
213216
}
214217
}
218+
if (cached.integrity) {
219+
const cachedSri = ssri.parse(cached.cacheIntegrity)
220+
const sri = ssri.parse(cached.integrity)
221+
const algo = sri.pickAlgorithm()
222+
if (cachedSri[algo] && !sri[algo].some(hash => {
223+
// cachedSri always has exactly one item per algorithm
224+
return cachedSri[algo][0].digest === hash.digest
225+
})) {
226+
return false
227+
}
228+
}
215229
reqUrl.hash = null
216230
cacheUrl.hash = null
217231
return url.format(reqUrl) === url.format(cacheUrl)

index.js

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ const https = require('https')
77
let ProxyAgent
88
const pkg = require('./package.json')
99
const retry = require('promise-retry')
10+
let ssri
1011
const Stream = require('stream')
1112
const url = require('url')
1213

@@ -45,6 +46,9 @@ function cachingFetch (uri, _opts) {
4546
// Default cacache-based cache
4647
Cache = require('./cache')
4748
}
49+
if (opts.integrity && !ssri) {
50+
ssri = require('ssri')
51+
}
4852
opts.cacheManager = opts.cacheManager && (
4953
typeof opts.cacheManager === 'string'
5054
? new Cache(opts.cacheManager, opts.cacheOpts)
@@ -71,7 +75,7 @@ function cachingFetch (uri, _opts) {
7175
method: opts.method,
7276
headers: opts.headers
7377
})
74-
return opts.cacheManager.match(req, opts.cacheOpts).then(res => {
78+
return opts.cacheManager.match(req, opts).then(res => {
7579
if (res) {
7680
const warningCode = (res.headers.get('Warning') || '').match(/^\d+/)
7781
if (warningCode && +warningCode >= 100 && +warningCode < 200) {
@@ -238,6 +242,20 @@ function remoteFetch (uri, opts) {
238242
return retry((retryHandler, attemptNum) => {
239243
const req = new fetch.Request(uri, reqOpts)
240244
return fetch(req).then(res => {
245+
if (opts.integrity) {
246+
const oldBod = res.body
247+
const newBod = ssri.integrityStream({
248+
integrity: opts.integrity
249+
})
250+
oldBod.pipe(newBod)
251+
res.body = newBod
252+
oldBod.once('error', err => {
253+
newBod.emit('error', err)
254+
})
255+
newBod.once('error', err => {
256+
oldBod.emit('error', err)
257+
})
258+
}
241259
const cacheCtrl = res.headers.get('cache-control') || ''
242260
if (
243261
(req.method === 'GET' || req.method === 'HEAD') &&

package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,14 +34,15 @@
3434
"license": "CC0-1.0",
3535
"dependencies": {
3636
"bluebird": "^3.5.0",
37-
"cacache": "^6.3.0",
37+
"cacache": "^7.0.1",
3838
"checksum-stream": "^1.0.2",
3939
"lru-cache": "^4.0.2",
4040
"mississippi": "^1.2.0",
4141
"node-fetch": "^2.0.0-alpha.3",
4242
"promise-retry": "^1.1.1",
4343
"proxy-agent": "^2.0.0",
44-
"safe-buffer": "^5.0.1"
44+
"safe-buffer": "^5.0.1",
45+
"ssri": "^3.0.2"
4546
},
4647
"devDependencies": {
4748
"mkdirp": "^0.5.1",

test/integrity.js

Lines changed: 124 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,128 @@
11
'use strict'
22

3+
const Buffer = require('safe-buffer').Buffer
4+
5+
const ssri = require('ssri')
36
const test = require('tap').test
7+
const tnock = require('./util/tnock')
8+
9+
const CACHE = require('./util/test-dir')(__filename)
10+
const CONTENT = Buffer.from('hello, world!', 'utf8')
11+
const INTEGRITY = ssri.fromData(CONTENT)
12+
const HOST = 'https://make-fetch-happen-safely.npm'
13+
14+
const fetch = require('..').defaults({retry: false})
15+
16+
test('basic integrity verification', t => {
17+
const srv = tnock(t, HOST)
18+
srv.get('/wowsosafe').reply(200, CONTENT)
19+
srv.get('/wowsobad').reply(200, Buffer.from('pwnd'))
20+
const safetch = fetch.defaults({
21+
integrity: INTEGRITY
22+
})
23+
return safetch(`${HOST}/wowsosafe`).then(res => {
24+
return res.buffer()
25+
}).then(buf => {
26+
t.deepEqual(buf, CONTENT, 'good content passed scrutiny 👍🏼')
27+
return safetch(`${HOST}/wowsobad`).then(res => {
28+
return res.buffer()
29+
}).then(buf => {
30+
throw new Error(`bad data: ${buf.toString('utf8')}`)
31+
}).catch(err => {
32+
t.equal(err.code, 'EBADCHECKSUM', 'content failed checksum!')
33+
})
34+
})
35+
})
36+
37+
test('picks the "best" algorithm', t => {
38+
const integrity = ssri.fromData(CONTENT, {
39+
algorithms: ['md5', 'sha384', 'sha1', 'sha256']
40+
})
41+
integrity['md5'][0].digest = 'badc0ffee'
42+
integrity['sha1'][0].digest = 'badc0ffee'
43+
const safetch = fetch.defaults({integrity})
44+
const srv = tnock(t, HOST)
45+
srv.get('/good').times(3).reply(200, CONTENT)
46+
srv.get('/bad').reply(200, 'pwnt')
47+
return safetch(`${HOST}/good`).then(res => res.buffer()).then(buf => {
48+
t.deepEqual(buf, CONTENT, 'data passed integrity check')
49+
return safetch(`${HOST}/bad`).then(res => {
50+
return res.buffer()
51+
}).then(buf => {
52+
throw new Error(`bad data: ${buf.toString('utf8')}`)
53+
}).catch(err => {
54+
t.equal(err.code, 'EBADCHECKSUM', 'content validated with either sha256 or sha384 (likely the latter)')
55+
})
56+
}).then(() => {
57+
// invalidate sha384. sha256 is still valid, in theory
58+
integrity['sha384'][0].digest = 'pwnt'
59+
return safetch(`${HOST}/good`).then(res => {
60+
return res.buffer()
61+
}).then(buf => {
62+
throw new Error(`bad data: ${buf.toString('utf8')}`)
63+
}).catch(err => {
64+
t.equal(err.code, 'EBADCHECKSUM', 'strongest algorithm (sha384) treated as authoritative -- sha256 not used')
65+
})
66+
}).then(() => {
67+
// remove bad sha384 altogether. sha256 remains valid
68+
delete integrity['sha384']
69+
return safetch(`${HOST}/good`).then(res => res.buffer())
70+
}).then(buf => {
71+
t.deepEqual(buf, CONTENT, 'data passed integrity check with sha256')
72+
})
73+
})
74+
75+
test('supports multiple hashes per algorithm', t => {
76+
const ALTCONTENT = Buffer.from('alt-content is like content but not really')
77+
const integrity = ssri.fromData(CONTENT, {
78+
algorithms: ['md5', 'sha384', 'sha1', 'sha256']
79+
}).concat(ssri.fromData(ALTCONTENT, {
80+
algorithms: ['sha384']
81+
}))
82+
const safetch = fetch.defaults({integrity})
83+
const srv = tnock(t, HOST)
84+
srv.get('/main').reply(200, CONTENT)
85+
srv.get('/alt').reply(200, ALTCONTENT)
86+
srv.get('/bad').reply(200, 'nope')
87+
return safetch(`${HOST}/main`).then(res => res.buffer()).then(buf => {
88+
t.deepEqual(buf, CONTENT, 'main content validated against sha384')
89+
return safetch(`${HOST}/alt`).then(res => res.buffer())
90+
}).then(buf => {
91+
t.deepEqual(buf, ALTCONTENT, 'alt content validated against sha384')
92+
return safetch(`${HOST}/bad`).then(res => res.buffer()).then(buf => {
93+
throw new Error(`bad data: ${buf.toString('utf8')}`)
94+
}).catch(err => {
95+
t.equal(err.code, 'EBADCHECKSUM', 'only the two valid contents pass')
96+
})
97+
})
98+
})
499

5-
test('basic integrity verification')
6-
test('picks the "best" algorithm')
7-
test('fails with EBADCHECKSUM if integrity fails')
8-
test('checks integrity on cache fetch too')
100+
test('checks integrity on cache fetch too', t => {
101+
const srv = tnock(t, HOST)
102+
srv.get('/test').reply(200, CONTENT)
103+
const safetch = fetch.defaults({
104+
cacheManager: CACHE,
105+
integrity: INTEGRITY,
106+
cache: 'must-revalidate'
107+
})
108+
return safetch(`${HOST}/test`).then(res => res.buffer()).then(buf => {
109+
t.deepEqual(buf, CONTENT, 'good content passed scrutiny 👍🏼')
110+
srv.get('/test').reply(200, 'nope')
111+
return safetch(`${HOST}/test`).then(res => res.buffer()).then(buf => {
112+
throw new Error(`bad data: ${buf.toString('utf8')}`)
113+
}).catch(err => {
114+
t.equal(err.code, 'EBADCHECKSUM', 'cached content failed checksum!')
115+
})
116+
}).then(() => {
117+
srv.get('/test').reply(200, 'nope')
118+
return safetch(`${HOST}/test`, {
119+
// try to use local cached version
120+
cache: 'force-cache',
121+
integrity: {algorithm: 'sha512', digest: 'doesnotmatch'}
122+
}).then(res => res.buffer()).then(buf => {
123+
throw new Error(`bad data: ${buf.toString('utf8')}`)
124+
}).catch(err => {
125+
t.equal(err.code, 'EBADCHECKSUM', 'cached content failed checksum!')
126+
})
127+
})
128+
})

0 commit comments

Comments
 (0)