request.js 5.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172
  1. // eslint-disable-next-line max-classes-per-file
  2. const http = require('node:http')
  3. const https = require('node:https')
  4. const { URL } = require('node:url')
  5. const dns = require('node:dns')
  6. const ipaddr = require('ipaddr.js')
  7. const got = require('got').default
  8. const logger = require('../logger')
  9. const FORBIDDEN_IP_ADDRESS = 'Forbidden IP address'
  10. const FORBIDDEN_RESOLVED_IP_ADDRESS = 'Forbidden resolved IP address'
  11. // Example scary IPs that should return false (ipv6-to-ipv4 mapped):
  12. // ::FFFF:127.0.0.1
  13. // ::ffff:7f00:1
  14. const isDisallowedIP = (ipAddress) => ipaddr.parse(ipAddress).range() !== 'unicast'
  15. module.exports.FORBIDDEN_IP_ADDRESS = FORBIDDEN_IP_ADDRESS
  16. module.exports.FORBIDDEN_RESOLVED_IP_ADDRESS = FORBIDDEN_RESOLVED_IP_ADDRESS
  17. module.exports.getRedirectEvaluator = (rawRequestURL, isEnabled) => {
  18. const requestURL = new URL(rawRequestURL)
  19. return ({ headers }) => {
  20. if (!isEnabled) return true
  21. let redirectURL = null
  22. try {
  23. redirectURL = new URL(headers.location, requestURL)
  24. } catch (err) {
  25. return false
  26. }
  27. const shouldRedirect = redirectURL.protocol === requestURL.protocol
  28. if (!shouldRedirect) {
  29. logger.info(
  30. `blocking redirect from ${requestURL} to ${redirectURL}`, 'redirect.protection',
  31. )
  32. }
  33. return shouldRedirect
  34. }
  35. }
  36. /**
  37. * Returns http Agent that will prevent requests to private IPs (to preven SSRF)
  38. */
  39. const getProtectedHttpAgent = ({ protocol, blockLocalIPs }) => {
  40. function dnsLookup (hostname, options, callback) {
  41. dns.lookup(hostname, options, (err, addresses, maybeFamily) => {
  42. if (err) {
  43. callback(err, addresses, maybeFamily)
  44. return
  45. }
  46. const toValidate = Array.isArray(addresses) ? addresses : [{ address: addresses }]
  47. for (const record of toValidate) {
  48. if (blockLocalIPs && isDisallowedIP(record.address)) {
  49. callback(new Error(FORBIDDEN_RESOLVED_IP_ADDRESS), addresses, maybeFamily)
  50. return
  51. }
  52. }
  53. callback(err, addresses, maybeFamily)
  54. })
  55. }
  56. const isBlocked = (options) => ipaddr.isValid(options.host) && blockLocalIPs && isDisallowedIP(options.host)
  57. class HttpAgent extends http.Agent {
  58. createConnection (options, callback) {
  59. if (isBlocked(options)) {
  60. callback(new Error(FORBIDDEN_IP_ADDRESS))
  61. return undefined
  62. }
  63. // @ts-ignore
  64. return super.createConnection({ ...options, lookup: dnsLookup }, callback)
  65. }
  66. }
  67. class HttpsAgent extends https.Agent {
  68. createConnection (options, callback) {
  69. if (isBlocked(options)) {
  70. callback(new Error(FORBIDDEN_IP_ADDRESS))
  71. return undefined
  72. }
  73. // @ts-ignore
  74. return super.createConnection({ ...options, lookup: dnsLookup }, callback)
  75. }
  76. }
  77. return protocol.startsWith('https') ? HttpsAgent : HttpAgent
  78. }
  79. function getProtectedGot ({ url, blockLocalIPs }) {
  80. const HttpAgent = getProtectedHttpAgent({ protocol: 'http', blockLocalIPs })
  81. const HttpsAgent = getProtectedHttpAgent({ protocol: 'https', blockLocalIPs })
  82. const httpAgent = new HttpAgent()
  83. const httpsAgent = new HttpsAgent()
  84. const redirectEvaluator = module.exports.getRedirectEvaluator(url, blockLocalIPs)
  85. const beforeRedirect = (options, response) => {
  86. const allowRedirect = redirectEvaluator(response)
  87. if (!allowRedirect) {
  88. throw new Error(`Redirect evaluator does not allow the redirect to ${response.headers.location}`)
  89. }
  90. }
  91. // @ts-ignore
  92. return got.extend({ hooks: { beforeRedirect: [beforeRedirect] }, agent: { http: httpAgent, https: httpsAgent } })
  93. }
  94. module.exports.getProtectedGot = getProtectedGot
  95. /**
  96. * Gets the size and content type of a url's content
  97. *
  98. * @param {string} url
  99. * @param {boolean} blockLocalIPs
  100. * @returns {Promise<{type: string, size: number}>}
  101. */
  102. exports.getURLMeta = async (url, blockLocalIPs = false) => {
  103. async function requestWithMethod (method) {
  104. const protectedGot = getProtectedGot({ url, blockLocalIPs })
  105. const stream = protectedGot.stream(url, { method, throwHttpErrors: false })
  106. return new Promise((resolve, reject) => (
  107. stream
  108. .on('response', (response) => {
  109. // Can be undefined for unknown length URLs, e.g. transfer-encoding: chunked
  110. const contentLength = parseInt(response.headers['content-length'], 10)
  111. // No need to get the rest of the response, as we only want header (not really relevant for HEAD, but why not)
  112. stream.destroy()
  113. resolve({
  114. type: response.headers['content-type'],
  115. size: Number.isNaN(contentLength) ? null : contentLength,
  116. statusCode: response.statusCode,
  117. })
  118. })
  119. .on('error', (err) => {
  120. reject(err)
  121. })
  122. ))
  123. }
  124. // We prefer to use a HEAD request, as it doesn't download the content. If the URL doesn't
  125. // support HEAD, or doesn't follow the spec and provide the correct Content-Length, we
  126. // fallback to GET.
  127. let urlMeta = await requestWithMethod('HEAD')
  128. // If HTTP error response, we retry with GET, which may work on non-compliant servers
  129. // (e.g. HEAD doesn't work on signed S3 URLs)
  130. // We look for status codes in the 400 and 500 ranges here, as 3xx errors are
  131. // unlikely to have to do with our choice of method
  132. // todo add unit test for this
  133. if (urlMeta.statusCode >= 400 || urlMeta.size === 0 || urlMeta.size == null) {
  134. urlMeta = await requestWithMethod('GET')
  135. }
  136. if (urlMeta.statusCode >= 300) {
  137. // @todo possibly set a status code in the error object to get a more helpful
  138. // hint at what the cause of error is.
  139. throw new Error(`URL server responded with status: ${urlMeta.statusCode}`)
  140. }
  141. const { size, type } = urlMeta
  142. return { size, type }
  143. }