You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

index.js 14KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524
  1. 'use strict'
  2. const crypto = require('crypto')
  3. const MiniPass = require('minipass')
  4. const SPEC_ALGORITHMS = ['sha256', 'sha384', 'sha512']
  5. // TODO: this should really be a hardcoded list of algorithms we support,
  6. // rather than [a-z0-9].
  7. const BASE64_REGEX = /^[a-z0-9+/]+(?:=?=?)$/i
  8. const SRI_REGEX = /^([a-z0-9]+)-([^?]+)([?\S*]*)$/
  9. const STRICT_SRI_REGEX = /^([a-z0-9]+)-([A-Za-z0-9+/=]{44,88})(\?[\x21-\x7E]*)?$/
  10. const VCHAR_REGEX = /^[\x21-\x7E]+$/
  11. const defaultOpts = {
  12. algorithms: ['sha512'],
  13. error: false,
  14. options: [],
  15. pickAlgorithm: getPrioritizedHash,
  16. sep: ' ',
  17. single: false,
  18. strict: false,
  19. }
  20. const ssriOpts = (opts = {}) => ({ ...defaultOpts, ...opts })
  21. const getOptString = options => !options || !options.length
  22. ? ''
  23. : `?${options.join('?')}`
  24. const _onEnd = Symbol('_onEnd')
  25. const _getOptions = Symbol('_getOptions')
  26. const _emittedSize = Symbol('_emittedSize')
  27. const _emittedIntegrity = Symbol('_emittedIntegrity')
  28. const _emittedVerified = Symbol('_emittedVerified')
  29. class IntegrityStream extends MiniPass {
  30. constructor (opts) {
  31. super()
  32. this.size = 0
  33. this.opts = opts
  34. // may be overridden later, but set now for class consistency
  35. this[_getOptions]()
  36. // options used for calculating stream. can't be changed.
  37. const { algorithms = defaultOpts.algorithms } = opts
  38. this.algorithms = Array.from(
  39. new Set(algorithms.concat(this.algorithm ? [this.algorithm] : []))
  40. )
  41. this.hashes = this.algorithms.map(crypto.createHash)
  42. }
  43. [_getOptions] () {
  44. const {
  45. integrity,
  46. size,
  47. options,
  48. } = { ...defaultOpts, ...this.opts }
  49. // For verification
  50. this.sri = integrity ? parse(integrity, this.opts) : null
  51. this.expectedSize = size
  52. this.goodSri = this.sri ? !!Object.keys(this.sri).length : false
  53. this.algorithm = this.goodSri ? this.sri.pickAlgorithm(this.opts) : null
  54. this.digests = this.goodSri ? this.sri[this.algorithm] : null
  55. this.optString = getOptString(options)
  56. }
  57. on (ev, handler) {
  58. if (ev === 'size' && this[_emittedSize]) {
  59. return handler(this[_emittedSize])
  60. }
  61. if (ev === 'integrity' && this[_emittedIntegrity]) {
  62. return handler(this[_emittedIntegrity])
  63. }
  64. if (ev === 'verified' && this[_emittedVerified]) {
  65. return handler(this[_emittedVerified])
  66. }
  67. return super.on(ev, handler)
  68. }
  69. emit (ev, data) {
  70. if (ev === 'end') {
  71. this[_onEnd]()
  72. }
  73. return super.emit(ev, data)
  74. }
  75. write (data) {
  76. this.size += data.length
  77. this.hashes.forEach(h => h.update(data))
  78. return super.write(data)
  79. }
  80. [_onEnd] () {
  81. if (!this.goodSri) {
  82. this[_getOptions]()
  83. }
  84. const newSri = parse(this.hashes.map((h, i) => {
  85. return `${this.algorithms[i]}-${h.digest('base64')}${this.optString}`
  86. }).join(' '), this.opts)
  87. // Integrity verification mode
  88. const match = this.goodSri && newSri.match(this.sri, this.opts)
  89. if (typeof this.expectedSize === 'number' && this.size !== this.expectedSize) {
  90. /* eslint-disable-next-line max-len */
  91. const err = new Error(`stream size mismatch when checking ${this.sri}.\n Wanted: ${this.expectedSize}\n Found: ${this.size}`)
  92. err.code = 'EBADSIZE'
  93. err.found = this.size
  94. err.expected = this.expectedSize
  95. err.sri = this.sri
  96. this.emit('error', err)
  97. } else if (this.sri && !match) {
  98. /* eslint-disable-next-line max-len */
  99. const err = new Error(`${this.sri} integrity checksum failed when using ${this.algorithm}: wanted ${this.digests} but got ${newSri}. (${this.size} bytes)`)
  100. err.code = 'EINTEGRITY'
  101. err.found = newSri
  102. err.expected = this.digests
  103. err.algorithm = this.algorithm
  104. err.sri = this.sri
  105. this.emit('error', err)
  106. } else {
  107. this[_emittedSize] = this.size
  108. this.emit('size', this.size)
  109. this[_emittedIntegrity] = newSri
  110. this.emit('integrity', newSri)
  111. if (match) {
  112. this[_emittedVerified] = match
  113. this.emit('verified', match)
  114. }
  115. }
  116. }
  117. }
  118. class Hash {
  119. get isHash () {
  120. return true
  121. }
  122. constructor (hash, opts) {
  123. opts = ssriOpts(opts)
  124. const strict = !!opts.strict
  125. this.source = hash.trim()
  126. // set default values so that we make V8 happy to
  127. // always see a familiar object template.
  128. this.digest = ''
  129. this.algorithm = ''
  130. this.options = []
  131. // 3.1. Integrity metadata (called "Hash" by ssri)
  132. // https://w3c.github.io/webappsec-subresource-integrity/#integrity-metadata-description
  133. const match = this.source.match(
  134. strict
  135. ? STRICT_SRI_REGEX
  136. : SRI_REGEX
  137. )
  138. if (!match) {
  139. return
  140. }
  141. if (strict && !SPEC_ALGORITHMS.some(a => a === match[1])) {
  142. return
  143. }
  144. this.algorithm = match[1]
  145. this.digest = match[2]
  146. const rawOpts = match[3]
  147. if (rawOpts) {
  148. this.options = rawOpts.slice(1).split('?')
  149. }
  150. }
  151. hexDigest () {
  152. return this.digest && Buffer.from(this.digest, 'base64').toString('hex')
  153. }
  154. toJSON () {
  155. return this.toString()
  156. }
  157. toString (opts) {
  158. opts = ssriOpts(opts)
  159. if (opts.strict) {
  160. // Strict mode enforces the standard as close to the foot of the
  161. // letter as it can.
  162. if (!(
  163. // The spec has very restricted productions for algorithms.
  164. // https://www.w3.org/TR/CSP2/#source-list-syntax
  165. SPEC_ALGORITHMS.some(x => x === this.algorithm) &&
  166. // Usually, if someone insists on using a "different" base64, we
  167. // leave it as-is, since there's multiple standards, and the
  168. // specified is not a URL-safe variant.
  169. // https://www.w3.org/TR/CSP2/#base64_value
  170. this.digest.match(BASE64_REGEX) &&
  171. // Option syntax is strictly visual chars.
  172. // https://w3c.github.io/webappsec-subresource-integrity/#grammardef-option-expression
  173. // https://tools.ietf.org/html/rfc5234#appendix-B.1
  174. this.options.every(opt => opt.match(VCHAR_REGEX))
  175. )) {
  176. return ''
  177. }
  178. }
  179. const options = this.options && this.options.length
  180. ? `?${this.options.join('?')}`
  181. : ''
  182. return `${this.algorithm}-${this.digest}${options}`
  183. }
  184. }
  185. class Integrity {
  186. get isIntegrity () {
  187. return true
  188. }
  189. toJSON () {
  190. return this.toString()
  191. }
  192. isEmpty () {
  193. return Object.keys(this).length === 0
  194. }
  195. toString (opts) {
  196. opts = ssriOpts(opts)
  197. let sep = opts.sep || ' '
  198. if (opts.strict) {
  199. // Entries must be separated by whitespace, according to spec.
  200. sep = sep.replace(/\S+/g, ' ')
  201. }
  202. return Object.keys(this).map(k => {
  203. return this[k].map(hash => {
  204. return Hash.prototype.toString.call(hash, opts)
  205. }).filter(x => x.length).join(sep)
  206. }).filter(x => x.length).join(sep)
  207. }
  208. concat (integrity, opts) {
  209. opts = ssriOpts(opts)
  210. const other = typeof integrity === 'string'
  211. ? integrity
  212. : stringify(integrity, opts)
  213. return parse(`${this.toString(opts)} ${other}`, opts)
  214. }
  215. hexDigest () {
  216. return parse(this, { single: true }).hexDigest()
  217. }
  218. // add additional hashes to an integrity value, but prevent
  219. // *changing* an existing integrity hash.
  220. merge (integrity, opts) {
  221. opts = ssriOpts(opts)
  222. const other = parse(integrity, opts)
  223. for (const algo in other) {
  224. if (this[algo]) {
  225. if (!this[algo].find(hash =>
  226. other[algo].find(otherhash =>
  227. hash.digest === otherhash.digest))) {
  228. throw new Error('hashes do not match, cannot update integrity')
  229. }
  230. } else {
  231. this[algo] = other[algo]
  232. }
  233. }
  234. }
  235. match (integrity, opts) {
  236. opts = ssriOpts(opts)
  237. const other = parse(integrity, opts)
  238. const algo = other.pickAlgorithm(opts)
  239. return (
  240. this[algo] &&
  241. other[algo] &&
  242. this[algo].find(hash =>
  243. other[algo].find(otherhash =>
  244. hash.digest === otherhash.digest
  245. )
  246. )
  247. ) || false
  248. }
  249. pickAlgorithm (opts) {
  250. opts = ssriOpts(opts)
  251. const pickAlgorithm = opts.pickAlgorithm
  252. const keys = Object.keys(this)
  253. return keys.reduce((acc, algo) => {
  254. return pickAlgorithm(acc, algo) || acc
  255. })
  256. }
  257. }
  258. module.exports.parse = parse
  259. function parse (sri, opts) {
  260. if (!sri) {
  261. return null
  262. }
  263. opts = ssriOpts(opts)
  264. if (typeof sri === 'string') {
  265. return _parse(sri, opts)
  266. } else if (sri.algorithm && sri.digest) {
  267. const fullSri = new Integrity()
  268. fullSri[sri.algorithm] = [sri]
  269. return _parse(stringify(fullSri, opts), opts)
  270. } else {
  271. return _parse(stringify(sri, opts), opts)
  272. }
  273. }
  274. function _parse (integrity, opts) {
  275. // 3.4.3. Parse metadata
  276. // https://w3c.github.io/webappsec-subresource-integrity/#parse-metadata
  277. if (opts.single) {
  278. return new Hash(integrity, opts)
  279. }
  280. const hashes = integrity.trim().split(/\s+/).reduce((acc, string) => {
  281. const hash = new Hash(string, opts)
  282. if (hash.algorithm && hash.digest) {
  283. const algo = hash.algorithm
  284. if (!acc[algo]) {
  285. acc[algo] = []
  286. }
  287. acc[algo].push(hash)
  288. }
  289. return acc
  290. }, new Integrity())
  291. return hashes.isEmpty() ? null : hashes
  292. }
  293. module.exports.stringify = stringify
  294. function stringify (obj, opts) {
  295. opts = ssriOpts(opts)
  296. if (obj.algorithm && obj.digest) {
  297. return Hash.prototype.toString.call(obj, opts)
  298. } else if (typeof obj === 'string') {
  299. return stringify(parse(obj, opts), opts)
  300. } else {
  301. return Integrity.prototype.toString.call(obj, opts)
  302. }
  303. }
  304. module.exports.fromHex = fromHex
  305. function fromHex (hexDigest, algorithm, opts) {
  306. opts = ssriOpts(opts)
  307. const optString = getOptString(opts.options)
  308. return parse(
  309. `${algorithm}-${
  310. Buffer.from(hexDigest, 'hex').toString('base64')
  311. }${optString}`, opts
  312. )
  313. }
  314. module.exports.fromData = fromData
  315. function fromData (data, opts) {
  316. opts = ssriOpts(opts)
  317. const algorithms = opts.algorithms
  318. const optString = getOptString(opts.options)
  319. return algorithms.reduce((acc, algo) => {
  320. const digest = crypto.createHash(algo).update(data).digest('base64')
  321. const hash = new Hash(
  322. `${algo}-${digest}${optString}`,
  323. opts
  324. )
  325. /* istanbul ignore else - it would be VERY strange if the string we
  326. * just calculated with an algo did not have an algo or digest.
  327. */
  328. if (hash.algorithm && hash.digest) {
  329. const hashAlgo = hash.algorithm
  330. if (!acc[hashAlgo]) {
  331. acc[hashAlgo] = []
  332. }
  333. acc[hashAlgo].push(hash)
  334. }
  335. return acc
  336. }, new Integrity())
  337. }
  338. module.exports.fromStream = fromStream
  339. function fromStream (stream, opts) {
  340. opts = ssriOpts(opts)
  341. const istream = integrityStream(opts)
  342. return new Promise((resolve, reject) => {
  343. stream.pipe(istream)
  344. stream.on('error', reject)
  345. istream.on('error', reject)
  346. let sri
  347. istream.on('integrity', s => {
  348. sri = s
  349. })
  350. istream.on('end', () => resolve(sri))
  351. istream.on('data', () => {})
  352. })
  353. }
  354. module.exports.checkData = checkData
  355. function checkData (data, sri, opts) {
  356. opts = ssriOpts(opts)
  357. sri = parse(sri, opts)
  358. if (!sri || !Object.keys(sri).length) {
  359. if (opts.error) {
  360. throw Object.assign(
  361. new Error('No valid integrity hashes to check against'), {
  362. code: 'EINTEGRITY',
  363. }
  364. )
  365. } else {
  366. return false
  367. }
  368. }
  369. const algorithm = sri.pickAlgorithm(opts)
  370. const digest = crypto.createHash(algorithm).update(data).digest('base64')
  371. const newSri = parse({ algorithm, digest })
  372. const match = newSri.match(sri, opts)
  373. if (match || !opts.error) {
  374. return match
  375. } else if (typeof opts.size === 'number' && (data.length !== opts.size)) {
  376. /* eslint-disable-next-line max-len */
  377. const err = new Error(`data size mismatch when checking ${sri}.\n Wanted: ${opts.size}\n Found: ${data.length}`)
  378. err.code = 'EBADSIZE'
  379. err.found = data.length
  380. err.expected = opts.size
  381. err.sri = sri
  382. throw err
  383. } else {
  384. /* eslint-disable-next-line max-len */
  385. const err = new Error(`Integrity checksum failed when using ${algorithm}: Wanted ${sri}, but got ${newSri}. (${data.length} bytes)`)
  386. err.code = 'EINTEGRITY'
  387. err.found = newSri
  388. err.expected = sri
  389. err.algorithm = algorithm
  390. err.sri = sri
  391. throw err
  392. }
  393. }
  394. module.exports.checkStream = checkStream
  395. function checkStream (stream, sri, opts) {
  396. opts = ssriOpts(opts)
  397. opts.integrity = sri
  398. sri = parse(sri, opts)
  399. if (!sri || !Object.keys(sri).length) {
  400. return Promise.reject(Object.assign(
  401. new Error('No valid integrity hashes to check against'), {
  402. code: 'EINTEGRITY',
  403. }
  404. ))
  405. }
  406. const checker = integrityStream(opts)
  407. return new Promise((resolve, reject) => {
  408. stream.pipe(checker)
  409. stream.on('error', reject)
  410. checker.on('error', reject)
  411. let verified
  412. checker.on('verified', s => {
  413. verified = s
  414. })
  415. checker.on('end', () => resolve(verified))
  416. checker.on('data', () => {})
  417. })
  418. }
  419. module.exports.integrityStream = integrityStream
  420. function integrityStream (opts = {}) {
  421. return new IntegrityStream(opts)
  422. }
  423. module.exports.create = createIntegrity
  424. function createIntegrity (opts) {
  425. opts = ssriOpts(opts)
  426. const algorithms = opts.algorithms
  427. const optString = getOptString(opts.options)
  428. const hashes = algorithms.map(crypto.createHash)
  429. return {
  430. update: function (chunk, enc) {
  431. hashes.forEach(h => h.update(chunk, enc))
  432. return this
  433. },
  434. digest: function (enc) {
  435. const integrity = algorithms.reduce((acc, algo) => {
  436. const digest = hashes.shift().digest('base64')
  437. const hash = new Hash(
  438. `${algo}-${digest}${optString}`,
  439. opts
  440. )
  441. /* istanbul ignore else - it would be VERY strange if the hash we
  442. * just calculated with an algo did not have an algo or digest.
  443. */
  444. if (hash.algorithm && hash.digest) {
  445. const hashAlgo = hash.algorithm
  446. if (!acc[hashAlgo]) {
  447. acc[hashAlgo] = []
  448. }
  449. acc[hashAlgo].push(hash)
  450. }
  451. return acc
  452. }, new Integrity())
  453. return integrity
  454. },
  455. }
  456. }
  457. const NODE_HASHES = new Set(crypto.getHashes())
  458. // This is a Best Effort™ at a reasonable priority for hash algos
  459. const DEFAULT_PRIORITY = [
  460. 'md5', 'whirlpool', 'sha1', 'sha224', 'sha256', 'sha384', 'sha512',
  461. // TODO - it's unclear _which_ of these Node will actually use as its name
  462. // for the algorithm, so we guesswork it based on the OpenSSL names.
  463. 'sha3',
  464. 'sha3-256', 'sha3-384', 'sha3-512',
  465. 'sha3_256', 'sha3_384', 'sha3_512',
  466. ].filter(algo => NODE_HASHES.has(algo))
  467. function getPrioritizedHash (algo1, algo2) {
  468. /* eslint-disable-next-line max-len */
  469. return DEFAULT_PRIORITY.indexOf(algo1.toLowerCase()) >= DEFAULT_PRIORITY.indexOf(algo2.toLowerCase())
  470. ? algo1
  471. : algo2
  472. }