File size: 4,686 Bytes
af13576
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
import type { IncomingMessage } from "node:http";
import http from "node:http";
import https from "node:https";
import { isIP } from "node:net";

/**
 * What the capped reader needs from a response body: an async stream of
 * chunks that can be torn down early. `IncomingMessage` satisfies it, and so
 * does a test double, which keeps the reader testable without a socket.
 */
export interface CappedReadable extends AsyncIterable<Uint8Array> {
  destroy(): void;
}

/**
 * GETs `url` over a socket pinned to `address`, the address the caller
 * already vetted with `resolvePublicUrlAndAddress`. The custom `lookup`
 * never touches DNS, so nothing re-resolves between the check and the
 * connect: the rebinding window a check-then-`fetch` leaves open, where a
 * second DNS answer could steer the socket into private space, is closed.
 *
 * The original hostname survives the pin three ways: it stays the request's
 * `hostname`, so TLS SNI and certificate verification run against it, not
 * against the IP being dialled, and the `Host` header is set explicitly so
 * virtual-host routing is unaffected.
 *
 * @param url - The URL to fetch; its protocol picks http or https
 * @param address - The vetted IP literal to connect to, IPv4 or IPv6
 * @throws When the protocol is not http(s) or the request fails
 */
export function requestPinnedToVettedAddress(
  url: URL,
  address: string,
  options: { signal?: AbortSignal } = {},
): Promise<IncomingMessage> {
  if (url.protocol !== "https:" && url.protocol !== "http:") {
    return Promise.reject(new Error(`Unsupported URL scheme: ${url.protocol}`));
  }

  const isTls = url.protocol === "https:";
  const defaultPort = isTls ? 443 : 80;
  const hostname = url.hostname.replace(/^\[|\]$/g, "");
  const port = url.port ? Number(url.port) : defaultPort;
  // An IPv6 host needs its brackets back in the Host header.
  const bracketed = isIP(hostname) === 6 ? `[${hostname}]` : hostname;
  const hostHeader = port === defaultPort ? bracketed : `${bracketed}:${port}`;

  const requestOptions: https.RequestOptions = {
    path: `${url.pathname}${url.search}`,
    method: "GET",
    hostname,
    port,
    // SNI and certificate verification follow the hostname, not the pinned
    // address, so a wrong-host certificate still fails the request.
    servername: isTls ? hostname : undefined,
    headers: { Host: hostHeader },
    // The pin itself: the socket connects to the vetted address and no
    // resolver is consulted, so the address checked is the address reached.
    // Node's Happy Eyeballs path (autoSelectFamily, on by default since
    // Node 20) asks the resolver with `all: true` and reads an array back,
    // so the pin answers in whichever shape the connect layer asked for β€”
    // one entry either way, the vetted address.
    lookup: (
      _hostname: string,
      lookupOptions: unknown,
      callback: (
        error: NodeJS.ErrnoException | null,
        addresses: string | { address: string; family: number }[],
        family?: number,
      ) => void,
    ) => {
      const family = isIP(address);
      if ((lookupOptions as { all?: boolean } | undefined)?.all) {
        callback(null, [{ address, family }]);
      } else {
        callback(null, address, family);
      }
    },
    signal: options.signal,
  };

  return new Promise<IncomingMessage>((resolve, reject) => {
    const request = isTls
      ? https.request(requestOptions, resolve)
      : http.request(requestOptions, resolve);
    request.on("error", reject);
    request.end();
  });
}

/**
 * Reads at most `maxBytes` from a Node response stream, the counterpart of
 * `readCappedBytes` for the `node:http` world: a hostile or merely huge
 * upstream must not be able to pin the server's memory, and a caller that
 * only needs the head of the body gains nothing from the tail. Breaking out
 * of the iteration destroys the stream, releasing the socket instead of
 * draining the rest.
 */
export async function readCappedStream(
  stream: CappedReadable,
  maxBytes: number,
): Promise<{ bytes: Uint8Array; truncated: boolean }> {
  const chunks: Uint8Array[] = [];
  let bytesRead = 0;
  let truncated = false;

  for await (const chunk of stream) {
    const remaining = maxBytes - bytesRead;
    if (chunk.byteLength > remaining) {
      chunks.push(chunk.subarray(0, remaining));
      bytesRead = maxBytes;
      truncated = true;
      break;
    }
    chunks.push(chunk);
    bytesRead += chunk.byteLength;
  }

  const bytes = new Uint8Array(bytesRead);
  let offset = 0;
  for (const chunk of chunks) {
    bytes.set(chunk, offset);
    offset += chunk.byteLength;
  }
  return { bytes, truncated };
}