• 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
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