decompress.js 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259
  1. 'use strict'
  2. const { createInflate, createGunzip, createBrotliDecompress, createZstdDecompress } = require('node:zlib')
  3. const { pipeline } = require('node:stream')
  4. const DecoratorHandler = require('../handler/decorator-handler')
  5. const { runtimeFeatures } = require('../util/runtime-features')
  6. /** @typedef {import('node:stream').Transform} Transform */
  7. /** @typedef {import('node:stream').Transform} Controller */
  8. /** @typedef {Transform&import('node:zlib').Zlib} DecompressorStream */
  9. /** @type {Record<string, () => DecompressorStream>} */
  10. const supportedEncodings = {
  11. gzip: createGunzip,
  12. 'x-gzip': createGunzip,
  13. br: createBrotliDecompress,
  14. deflate: createInflate,
  15. compress: createInflate,
  16. 'x-compress': createInflate,
  17. ...(runtimeFeatures.has('zstd') ? { zstd: createZstdDecompress } : {})
  18. }
  19. const defaultSkipStatusCodes = /** @type {const} */ ([204, 304])
  20. let warningEmitted = /** @type {boolean} */ (false)
  21. /**
  22. * @typedef {Object} DecompressHandlerOptions
  23. * @property {number[]|Readonly<number[]>} [skipStatusCodes=[204, 304]] - List of status codes to skip decompression for
  24. * @property {boolean} [skipErrorResponses] - Whether to skip decompression for error responses (status codes >= 400)
  25. */
  26. class DecompressHandler extends DecoratorHandler {
  27. /** @type {Transform[]} */
  28. #decompressors = []
  29. /** @type {Readonly<number[]>} */
  30. #skipStatusCodes
  31. /** @type {boolean} */
  32. #skipErrorResponses
  33. constructor (handler, { skipStatusCodes = defaultSkipStatusCodes, skipErrorResponses = true } = {}) {
  34. super(handler)
  35. this.#skipStatusCodes = skipStatusCodes
  36. this.#skipErrorResponses = skipErrorResponses
  37. }
  38. /**
  39. * Determines if decompression should be skipped based on encoding and status code
  40. * @param {string} contentEncoding - Content-Encoding header value
  41. * @param {number} statusCode - HTTP status code of the response
  42. * @returns {boolean} - True if decompression should be skipped
  43. */
  44. #shouldSkipDecompression (contentEncoding, statusCode) {
  45. if (!contentEncoding || statusCode < 200) return true
  46. if (this.#skipStatusCodes.includes(statusCode)) return true
  47. if (this.#skipErrorResponses && statusCode >= 400) return true
  48. return false
  49. }
  50. /**
  51. * Creates a chain of decompressors for multiple content encodings
  52. *
  53. * @param {string} encodings - Comma-separated list of content encodings
  54. * @returns {Array<DecompressorStream>} - Array of decompressor streams
  55. * @throws {Error} - If the number of content-encodings exceeds the maximum allowed
  56. */
  57. #createDecompressionChain (encodings) {
  58. const parts = encodings.split(',')
  59. // Limit the number of content-encodings to prevent resource exhaustion.
  60. // CVE fix similar to urllib3 (GHSA-gm62-xv2j-4w53) and curl (CVE-2022-32206).
  61. const maxContentEncodings = 5
  62. if (parts.length > maxContentEncodings) {
  63. throw new Error(`too many content-encodings in response: ${parts.length}, maximum allowed is ${maxContentEncodings}`)
  64. }
  65. /** @type {DecompressorStream[]} */
  66. const decompressors = []
  67. for (let i = parts.length - 1; i >= 0; i--) {
  68. const encoding = parts[i].trim()
  69. if (!encoding) continue
  70. if (!supportedEncodings[encoding]) {
  71. decompressors.length = 0 // Clear if unsupported encoding
  72. return decompressors // Unsupported encoding
  73. }
  74. decompressors.push(supportedEncodings[encoding]())
  75. }
  76. return decompressors
  77. }
  78. /**
  79. * Sets up event handlers for a decompressor stream using readable events
  80. * @param {DecompressorStream} decompressor - The decompressor stream
  81. * @param {Controller} controller - The controller to coordinate with
  82. * @returns {void}
  83. */
  84. #setupDecompressorEvents (decompressor, controller) {
  85. decompressor.on('readable', () => {
  86. let chunk
  87. while ((chunk = decompressor.read()) !== null) {
  88. const result = super.onResponseData(controller, chunk)
  89. if (result === false) {
  90. break
  91. }
  92. }
  93. })
  94. decompressor.on('error', (error) => {
  95. super.onResponseError(controller, error)
  96. })
  97. }
  98. /**
  99. * Sets up event handling for a single decompressor
  100. * @param {Controller} controller - The controller to handle events
  101. * @returns {void}
  102. */
  103. #setupSingleDecompressor (controller) {
  104. const decompressor = this.#decompressors[0]
  105. this.#setupDecompressorEvents(decompressor, controller)
  106. decompressor.on('end', () => {
  107. super.onResponseEnd(controller, {})
  108. })
  109. }
  110. /**
  111. * Sets up event handling for multiple chained decompressors using pipeline
  112. * @param {Controller} controller - The controller to handle events
  113. * @returns {void}
  114. */
  115. #setupMultipleDecompressors (controller) {
  116. const lastDecompressor = this.#decompressors[this.#decompressors.length - 1]
  117. this.#setupDecompressorEvents(lastDecompressor, controller)
  118. pipeline(this.#decompressors, (err) => {
  119. if (err) {
  120. super.onResponseError(controller, err)
  121. return
  122. }
  123. super.onResponseEnd(controller, {})
  124. })
  125. }
  126. /**
  127. * Cleans up decompressor references to prevent memory leaks
  128. * @returns {void}
  129. */
  130. #cleanupDecompressors () {
  131. this.#decompressors.length = 0
  132. }
  133. /**
  134. * @param {Controller} controller
  135. * @param {number} statusCode
  136. * @param {Record<string, string | string[] | undefined>} headers
  137. * @param {string} statusMessage
  138. * @returns {void}
  139. */
  140. onResponseStart (controller, statusCode, headers, statusMessage) {
  141. const contentEncoding = headers['content-encoding']
  142. // If content encoding is not supported or status code is in skip list
  143. if (this.#shouldSkipDecompression(contentEncoding, statusCode)) {
  144. return super.onResponseStart(controller, statusCode, headers, statusMessage)
  145. }
  146. const decompressors = this.#createDecompressionChain(contentEncoding.toLowerCase())
  147. if (decompressors.length === 0) {
  148. this.#cleanupDecompressors()
  149. return super.onResponseStart(controller, statusCode, headers, statusMessage)
  150. }
  151. this.#decompressors = decompressors
  152. // Remove compression headers since we're decompressing
  153. const { 'content-encoding': _, 'content-length': __, ...newHeaders } = headers
  154. if (this.#decompressors.length === 1) {
  155. this.#setupSingleDecompressor(controller)
  156. } else {
  157. this.#setupMultipleDecompressors(controller)
  158. }
  159. return super.onResponseStart(controller, statusCode, newHeaders, statusMessage)
  160. }
  161. /**
  162. * @param {Controller} controller
  163. * @param {Buffer} chunk
  164. * @returns {void}
  165. */
  166. onResponseData (controller, chunk) {
  167. if (this.#decompressors.length > 0) {
  168. this.#decompressors[0].write(chunk)
  169. return
  170. }
  171. super.onResponseData(controller, chunk)
  172. }
  173. /**
  174. * @param {Controller} controller
  175. * @param {Record<string, string | string[]> | undefined} trailers
  176. * @returns {void}
  177. */
  178. onResponseEnd (controller, trailers) {
  179. if (this.#decompressors.length > 0) {
  180. this.#decompressors[0].end()
  181. this.#cleanupDecompressors()
  182. return
  183. }
  184. super.onResponseEnd(controller, trailers)
  185. }
  186. /**
  187. * @param {Controller} controller
  188. * @param {Error} err
  189. * @returns {void}
  190. */
  191. onResponseError (controller, err) {
  192. if (this.#decompressors.length > 0) {
  193. for (const decompressor of this.#decompressors) {
  194. decompressor.destroy(err)
  195. }
  196. this.#cleanupDecompressors()
  197. }
  198. super.onResponseError(controller, err)
  199. }
  200. }
  201. /**
  202. * Creates a decompression interceptor for HTTP responses
  203. * @param {DecompressHandlerOptions} [options] - Options for the interceptor
  204. * @returns {Function} - Interceptor function
  205. */
  206. function createDecompressInterceptor (options = {}) {
  207. // Emit experimental warning only once
  208. if (!warningEmitted) {
  209. process.emitWarning(
  210. 'DecompressInterceptor is experimental and subject to change',
  211. 'ExperimentalWarning'
  212. )
  213. warningEmitted = true
  214. }
  215. return (dispatch) => {
  216. return (opts, handler) => {
  217. const decompressHandler = new DecompressHandler(handler, options)
  218. return dispatch(opts, decompressHandler)
  219. }
  220. }
  221. }
  222. module.exports = createDecompressInterceptor