• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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