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 47let HTTPSAgent; 48function HTTPSGet(url, opts) { 49 const https = require('https'); // [1] 50 HTTPSAgent ??= new https.Agent({ // [2] 51 keepAlive: true, 52 }); 53 return https.get(url, { 54 agent: HTTPSAgent, 55 ...opts, 56 }); 57} 58 59let HTTPAgent; 60function HTTPGet(url, opts) { 61 const http = require('http'); // [1] 62 HTTPAgent ??= new http.Agent({ // [2] 63 keepAlive: true, 64 }); 65 return http.get(url, { 66 agent: HTTPAgent, 67 ...opts, 68 }); 69} 70 71function dnsLookup(name, opts) { 72 // eslint-disable-next-line no-func-assign 73 dnsLookup = require('dns/promises').lookup; 74 return dnsLookup(name, opts); 75} 76 77let zlib; 78function createBrotliDecompress() { 79 zlib ??= require('zlib'); // [1] 80 // eslint-disable-next-line no-func-assign 81 createBrotliDecompress = zlib.createBrotliDecompress; 82 return createBrotliDecompress(); 83} 84 85function createUnzip() { 86 zlib ??= require('zlib'); // [1] 87 // eslint-disable-next-line no-func-assign 88 createUnzip = zlib.createUnzip; 89 return createUnzip(); 90} 91 92/** 93 * Redirection status code as per section 6.4 of RFC 7231: 94 * https://datatracker.ietf.org/doc/html/rfc7231#section-6.4 95 * and RFC 7238: 96 * https://datatracker.ietf.org/doc/html/rfc7238 97 * @param {number} statusCode 98 * @returns {boolean} 99 */ 100function isRedirect(statusCode) { 101 switch (statusCode) { 102 case 300: // Multiple Choices 103 case 301: // Moved Permanently 104 case 302: // Found 105 case 303: // See Other 106 case 307: // Temporary Redirect 107 case 308: // Permanent Redirect 108 return true; 109 default: 110 return false; 111 } 112} 113 114/** 115 * @param {URL} parsed 116 * @returns {Promise<CacheEntry> | CacheEntry} 117 */ 118function fetchWithRedirects(parsed) { 119 const existing = cacheForGET.get(parsed.href); 120 if (existing) { 121 return existing; 122 } 123 const handler = parsed.protocol === 'http:' ? HTTPGet : HTTPSGet; 124 const result = (async () => { 125 const req = handler(parsed, { 126 headers: { Accept: '*/*' }, 127 }); 128 // Note that `once` is used here to handle `error` and that it hits the 129 // `finally` on network error/timeout. 130 const { 0: res } = await once(req, 'response'); 131 try { 132 const hasLocation = ObjectPrototypeHasOwnProperty(res.headers, 'location'); 133 if (isRedirect(res.statusCode) && hasLocation) { 134 const location = new URL(res.headers.location, parsed); 135 if (location.protocol !== 'http:' && location.protocol !== 'https:') { 136 throw new ERR_NETWORK_IMPORT_DISALLOWED( 137 res.headers.location, 138 parsed.href, 139 'cannot redirect to non-network location', 140 ); 141 } 142 const entry = await fetchWithRedirects(location); 143 cacheForGET.set(parsed.href, entry); 144 return entry; 145 } 146 if (res.statusCode === 404) { 147 const err = new ERR_MODULE_NOT_FOUND(parsed.href, null); 148 err.message = `Cannot find module '${parsed.href}', HTTP 404`; 149 throw err; 150 } 151 // This condition catches all unsupported status codes, including 152 // 3xx redirection codes without `Location` HTTP header. 153 if (res.statusCode < 200 || res.statusCode >= 300) { 154 throw new ERR_NETWORK_IMPORT_DISALLOWED( 155 res.headers.location, 156 parsed.href, 157 'cannot redirect to non-network location'); 158 } 159 const { headers } = res; 160 const contentType = headers['content-type']; 161 if (!contentType) { 162 throw new ERR_NETWORK_IMPORT_BAD_RESPONSE( 163 parsed.href, 164 "the 'Content-Type' header is required", 165 ); 166 } 167 /** 168 * @type {CacheEntry} 169 */ 170 const entry = { 171 resolvedHREF: parsed.href, 172 headers: { 173 'content-type': res.headers['content-type'], 174 }, 175 body: (async () => { 176 let bodyStream = res; 177 if (res.headers['content-encoding'] === 'br') { 178 bodyStream = compose(res, createBrotliDecompress()); 179 } else if ( 180 res.headers['content-encoding'] === 'gzip' || 181 res.headers['content-encoding'] === 'deflate' 182 ) { 183 bodyStream = compose(res, createUnzip()); 184 } 185 const buffers = await bodyStream.toArray(); 186 const body = BufferConcat(buffers); 187 entry.body = body; 188 return body; 189 })(), 190 }; 191 cacheForGET.set(parsed.href, entry); 192 await entry.body; 193 return entry; 194 } finally { 195 req.destroy(); 196 } 197 })(); 198 cacheForGET.set(parsed.href, result); 199 return result; 200} 201 202const allowList = new net.BlockList(); 203allowList.addAddress('::1', 'ipv6'); 204allowList.addRange('127.0.0.1', '127.255.255.255'); 205 206/** 207 * Returns if an address has local status by if it is going to a local 208 * interface or is an address resolved by DNS to be a local interface 209 * @param {string} hostname url.hostname to test 210 * @returns {Promise<boolean>} 211 */ 212async function isLocalAddress(hostname) { 213 try { 214 if ( 215 StringPrototypeStartsWith(hostname, '[') && 216 StringPrototypeEndsWith(hostname, ']') 217 ) { 218 hostname = StringPrototypeSlice(hostname, 1, -1); 219 } 220 const addr = await dnsLookup(hostname, { verbatim: true }); 221 const ipv = addr.family === 4 ? 'ipv4' : 'ipv6'; 222 return allowList.check(addr.address, ipv); 223 } catch { 224 // If it errored, the answer is no. 225 } 226 return false; 227} 228 229/** 230 * Fetches a location with a shared cache following redirects. 231 * Does not respect HTTP cache headers. 232 * 233 * This splits the header and body Promises so that things only needing 234 * headers don't need to wait on the body. 235 * 236 * In cases where the request & response have already settled, this returns the 237 * cache value synchronously. 238 * @param {URL} parsed 239 * @param {ESModuleContext} context 240 * @returns {ReturnType<typeof fetchWithRedirects>} 241 */ 242function fetchModule(parsed, { parentURL }) { 243 const { href } = parsed; 244 const existing = cacheForGET.get(href); 245 if (existing) { 246 return existing; 247 } 248 if (parsed.protocol === 'http:') { 249 return PromisePrototypeThen(isLocalAddress(parsed.hostname), (is) => { 250 if (is !== true) { 251 throw new ERR_NETWORK_IMPORT_DISALLOWED( 252 href, 253 parentURL, 254 'http can only be used to load local resources (use https instead).', 255 ); 256 } 257 return fetchWithRedirects(parsed); 258 }); 259 } 260 return fetchWithRedirects(parsed); 261} 262 263module.exports = { 264 fetchModule, 265}; 266