1'use strict'; 2const { 3 ObjectPrototypeHasOwnProperty, 4 PromisePrototypeThen, 5 SafeMap, 6 StringPrototypeEndsWith, 7 StringPrototypeSlice, 8 StringPrototypeStartsWith, 9} = primordials; 10const { 11 Buffer: { concat: BufferConcat }, 12} = require('buffer'); 13const { 14 ERR_NETWORK_IMPORT_DISALLOWED, 15 ERR_NETWORK_IMPORT_BAD_RESPONSE, 16 ERR_MODULE_NOT_FOUND, 17} = require('internal/errors').codes; 18const { URL } = require('internal/url'); 19const net = require('net'); 20const { once } = require('events'); 21const { compose } = require('stream'); 22/** 23 * @typedef CacheEntry 24 * @property {Promise<string> | string} resolvedHREF Parsed HREF of the request. 25 * @property {Record<string, string>} headers HTTP headers of the response. 26 * @property {Promise<Buffer> | Buffer} body Response body. 27 */ 28 29/** 30 * Only for GET requests, other requests would need new Map 31 * HTTP cache semantics keep diff caches 32 * 33 * It caches either the promise or the cache entry since import.meta.url needs 34 * the value synchronously for the response location after all redirects. 35 * 36 * Maps HREF to pending cache entry 37 * @type {Map<string, Promise<CacheEntry> | CacheEntry>} 38 */ 39const cacheForGET = new SafeMap(); 40 41// [1] The V8 snapshot doesn't like some C++ APIs to be loaded eagerly. Do it 42// lazily/at runtime and not top level of an internal module. 43 44// [2] Creating a new agent instead of using the gloabl agent improves 45// performance and precludes the agent becoming tainted. 46 47/** @type {import('https').Agent} The Cached HTTP Agent for **secure** HTTP requests. */ 48let HTTPSAgent; 49/** 50 * Make a HTTPs GET request (handling agent setup if needed, caching the agent to avoid 51 * redudant instantiations). 52 * @param {Parameters<import('https').get>[0]} input - The URI to fetch. 53 * @param {Parameters<import('https').get>[1]} options - See https.get() options. 54 */ 55function HTTPSGet(input, options) { 56 const https = require('https'); // [1] 57 HTTPSAgent ??= new https.Agent({ // [2] 58 keepAlive: true, 59 }); 60 return https.get(input, { 61 agent: HTTPSAgent, 62 ...options, 63 }); 64} 65 66/** @type {import('https').Agent} The Cached HTTP Agent for **insecure** HTTP requests. */ 67let HTTPAgent; 68/** 69 * Make a HTTP GET request (handling agent setup if needed, caching the agent to avoid 70 * redudant instantiations). 71 * @param {Parameters<import('http').get>[0]} input - The URI to fetch. 72 * @param {Parameters<import('http').get>[1]} options - See http.get() options. 73 */ 74function HTTPGet(input, options) { 75 const http = require('http'); // [1] 76 HTTPAgent ??= new http.Agent({ // [2] 77 keepAlive: true, 78 }); 79 return http.get(input, { 80 agent: HTTPAgent, 81 ...options, 82 }); 83} 84 85/** @type {import('../../dns/promises.js').lookup} */ 86function dnsLookup(hostname, options) { 87 // eslint-disable-next-line no-func-assign 88 dnsLookup = require('dns/promises').lookup; 89 return dnsLookup(hostname, options); 90} 91 92let zlib; 93/** 94 * Create a decompressor for the Brotli format. 95 * @returns {import('zlib').BrotliDecompress} 96 */ 97function createBrotliDecompress() { 98 zlib ??= require('zlib'); // [1] 99 // eslint-disable-next-line no-func-assign 100 createBrotliDecompress = zlib.createBrotliDecompress; 101 return createBrotliDecompress(); 102} 103 104/** 105 * Create an unzip handler. 106 * @returns {import('zlib').Unzip} 107 */ 108function createUnzip() { 109 zlib ??= require('zlib'); // [1] 110 // eslint-disable-next-line no-func-assign 111 createUnzip = zlib.createUnzip; 112 return createUnzip(); 113} 114 115/** 116 * Redirection status code as per section 6.4 of RFC 7231: 117 * https://datatracker.ietf.org/doc/html/rfc7231#section-6.4 118 * and RFC 7238: 119 * https://datatracker.ietf.org/doc/html/rfc7238 120 * @param {number} statusCode 121 * @returns {boolean} 122 */ 123function isRedirect(statusCode) { 124 switch (statusCode) { 125 case 300: // Multiple Choices 126 case 301: // Moved Permanently 127 case 302: // Found 128 case 303: // See Other 129 case 307: // Temporary Redirect 130 case 308: // Permanent Redirect 131 return true; 132 default: 133 return false; 134 } 135} 136 137/** 138 * @param {URL} parsed 139 * @returns {Promise<CacheEntry> | CacheEntry} 140 */ 141function fetchWithRedirects(parsed) { 142 const existing = cacheForGET.get(parsed.href); 143 if (existing) { 144 return existing; 145 } 146 const handler = parsed.protocol === 'http:' ? HTTPGet : HTTPSGet; 147 const result = (async () => { 148 const req = handler(parsed, { 149 headers: { Accept: '*/*' }, 150 }); 151 // Note that `once` is used here to handle `error` and that it hits the 152 // `finally` on network error/timeout. 153 const { 0: res } = await once(req, 'response'); 154 try { 155 const hasLocation = ObjectPrototypeHasOwnProperty(res.headers, 'location'); 156 if (isRedirect(res.statusCode) && hasLocation) { 157 const location = new URL(res.headers.location, parsed); 158 if (location.protocol !== 'http:' && location.protocol !== 'https:') { 159 throw new ERR_NETWORK_IMPORT_DISALLOWED( 160 res.headers.location, 161 parsed.href, 162 'cannot redirect to non-network location', 163 ); 164 } 165 const entry = await fetchWithRedirects(location); 166 cacheForGET.set(parsed.href, entry); 167 return entry; 168 } 169 if (res.statusCode === 404) { 170 const err = new ERR_MODULE_NOT_FOUND(parsed.href, null, parsed); 171 err.message = `Cannot find module '${parsed.href}', HTTP 404`; 172 throw err; 173 } 174 // This condition catches all unsupported status codes, including 175 // 3xx redirection codes without `Location` HTTP header. 176 if (res.statusCode < 200 || res.statusCode >= 300) { 177 throw new ERR_NETWORK_IMPORT_DISALLOWED( 178 res.headers.location, 179 parsed.href, 180 'cannot redirect to non-network location'); 181 } 182 const { headers } = res; 183 const contentType = headers['content-type']; 184 if (!contentType) { 185 throw new ERR_NETWORK_IMPORT_BAD_RESPONSE( 186 parsed.href, 187 "the 'Content-Type' header is required", 188 ); 189 } 190 /** 191 * @type {CacheEntry} 192 */ 193 const entry = { 194 resolvedHREF: parsed.href, 195 headers: { 196 'content-type': res.headers['content-type'], 197 }, 198 body: (async () => { 199 let bodyStream = res; 200 if (res.headers['content-encoding'] === 'br') { 201 bodyStream = compose(res, createBrotliDecompress()); 202 } else if ( 203 res.headers['content-encoding'] === 'gzip' || 204 res.headers['content-encoding'] === 'deflate' 205 ) { 206 bodyStream = compose(res, createUnzip()); 207 } 208 const buffers = await bodyStream.toArray(); 209 const body = BufferConcat(buffers); 210 entry.body = body; 211 return body; 212 })(), 213 }; 214 cacheForGET.set(parsed.href, entry); 215 await entry.body; 216 return entry; 217 } finally { 218 req.destroy(); 219 } 220 })(); 221 cacheForGET.set(parsed.href, result); 222 return result; 223} 224 225const allowList = new net.BlockList(); 226allowList.addAddress('::1', 'ipv6'); 227allowList.addRange('127.0.0.1', '127.255.255.255'); 228 229/** 230 * Returns if an address has local status by if it is going to a local 231 * interface or is an address resolved by DNS to be a local interface 232 * @param {string} hostname url.hostname to test 233 * @returns {Promise<boolean>} 234 */ 235async function isLocalAddress(hostname) { 236 try { 237 if ( 238 StringPrototypeStartsWith(hostname, '[') && 239 StringPrototypeEndsWith(hostname, ']') 240 ) { 241 hostname = StringPrototypeSlice(hostname, 1, -1); 242 } 243 const addr = await dnsLookup(hostname, { verbatim: true }); 244 const ipv = addr.family === 4 ? 'ipv4' : 'ipv6'; 245 return allowList.check(addr.address, ipv); 246 } catch { 247 // If it errored, the answer is no. 248 } 249 return false; 250} 251 252/** 253 * Fetches a location with a shared cache following redirects. 254 * Does not respect HTTP cache headers. 255 * 256 * This splits the header and body Promises so that things only needing 257 * headers don't need to wait on the body. 258 * 259 * In cases where the request & response have already settled, this returns the 260 * cache value synchronously. 261 * @param {URL} parsed 262 * @param {ESModuleContext} context 263 * @returns {ReturnType<typeof fetchWithRedirects>} 264 */ 265function fetchModule(parsed, { parentURL }) { 266 const { href } = parsed; 267 const existing = cacheForGET.get(href); 268 if (existing) { 269 return existing; 270 } 271 if (parsed.protocol === 'http:') { 272 return PromisePrototypeThen(isLocalAddress(parsed.hostname), (is) => { 273 if (is !== true) { 274 throw new ERR_NETWORK_IMPORT_DISALLOWED( 275 href, 276 parentURL, 277 'http can only be used to load local resources (use https instead).', 278 ); 279 } 280 return fetchWithRedirects(parsed); 281 }); 282 } 283 return fetchWithRedirects(parsed); 284} 285 286module.exports = { 287 fetchModule, 288}; 289