@c9up/ream 0.2.27 → 0.2.29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (195) hide show
  1. package/README.md +1 -0
  2. package/dist/Codemods.d.ts +16 -0
  3. package/dist/Codemods.d.ts.map +1 -1
  4. package/dist/Codemods.js +182 -0
  5. package/dist/Codemods.js.map +1 -1
  6. package/dist/Ignitor.d.ts +11 -1
  7. package/dist/Ignitor.d.ts.map +1 -1
  8. package/dist/Ignitor.js +27 -21
  9. package/dist/Ignitor.js.map +1 -1
  10. package/dist/bodyparser/BodyParserMiddleware.d.ts.map +1 -1
  11. package/dist/bodyparser/BodyParserMiddleware.js +3 -0
  12. package/dist/bodyparser/BodyParserMiddleware.js.map +1 -1
  13. package/dist/bodyparser/qsParse.d.ts +10 -1
  14. package/dist/bodyparser/qsParse.d.ts.map +1 -1
  15. package/dist/bodyparser/qsParse.js +53 -15
  16. package/dist/bodyparser/qsParse.js.map +1 -1
  17. package/dist/codemods/source.d.ts +57 -0
  18. package/dist/codemods/source.d.ts.map +1 -0
  19. package/dist/codemods/source.js +282 -0
  20. package/dist/codemods/source.js.map +1 -0
  21. package/dist/graphql/GraphQLEngine.d.ts.map +1 -1
  22. package/dist/graphql/GraphQLEngine.js +3 -2
  23. package/dist/graphql/GraphQLEngine.js.map +1 -1
  24. package/dist/http/Exception.d.ts +14 -1
  25. package/dist/http/Exception.d.ts.map +1 -1
  26. package/dist/http/Exception.js +10 -2
  27. package/dist/http/Exception.js.map +1 -1
  28. package/dist/http/HttpContext.d.ts +11 -0
  29. package/dist/http/HttpContext.d.ts.map +1 -1
  30. package/dist/http/HttpContext.js +2 -1
  31. package/dist/http/HttpContext.js.map +1 -1
  32. package/dist/http/RedirectBuilder.d.ts +9 -3
  33. package/dist/http/RedirectBuilder.d.ts.map +1 -1
  34. package/dist/http/RedirectBuilder.js +39 -37
  35. package/dist/http/RedirectBuilder.js.map +1 -1
  36. package/dist/http/Request.d.ts +42 -17
  37. package/dist/http/Request.d.ts.map +1 -1
  38. package/dist/http/Request.js +65 -29
  39. package/dist/http/Request.js.map +1 -1
  40. package/dist/http/SseStream.js +6 -7
  41. package/dist/http/SseStream.js.map +1 -1
  42. package/dist/http/permissions.d.ts +19 -0
  43. package/dist/http/permissions.d.ts.map +1 -0
  44. package/dist/http/permissions.js +28 -0
  45. package/dist/http/permissions.js.map +1 -0
  46. package/dist/http-client/HttpClient.d.ts +111 -0
  47. package/dist/http-client/HttpClient.d.ts.map +1 -0
  48. package/dist/http-client/HttpClient.js +386 -0
  49. package/dist/http-client/HttpClient.js.map +1 -0
  50. package/dist/http-client/addresses.d.ts +14 -0
  51. package/dist/http-client/addresses.d.ts.map +1 -0
  52. package/dist/http-client/addresses.js +57 -0
  53. package/dist/http-client/addresses.js.map +1 -0
  54. package/dist/http-client/fake.d.ts +50 -0
  55. package/dist/http-client/fake.d.ts.map +1 -0
  56. package/dist/http-client/fake.js +95 -0
  57. package/dist/http-client/fake.js.map +1 -0
  58. package/dist/http-client/index.d.ts +7 -0
  59. package/dist/http-client/index.d.ts.map +1 -0
  60. package/dist/http-client/index.js +7 -0
  61. package/dist/http-client/index.js.map +1 -0
  62. package/dist/index.d.ts +5 -4
  63. package/dist/index.d.ts.map +1 -1
  64. package/dist/index.js +2 -2
  65. package/dist/index.js.map +1 -1
  66. package/dist/middleware/Pipeline.d.ts.map +1 -1
  67. package/dist/middleware/Pipeline.js +6 -3
  68. package/dist/middleware/Pipeline.js.map +1 -1
  69. package/dist/native/generated.d.ts +108 -3
  70. package/dist/native/generated.d.ts.map +1 -1
  71. package/dist/rpc/RpcProvider.d.ts +6 -0
  72. package/dist/rpc/RpcProvider.d.ts.map +1 -1
  73. package/dist/rpc/RpcProvider.js +4 -1
  74. package/dist/rpc/RpcProvider.js.map +1 -1
  75. package/dist/rpc/RpcRouter.d.ts +27 -14
  76. package/dist/rpc/RpcRouter.d.ts.map +1 -1
  77. package/dist/rpc/RpcRouter.js +95 -38
  78. package/dist/rpc/RpcRouter.js.map +1 -1
  79. package/dist/scheduler/locks/RedisLockBackend.d.ts.map +1 -1
  80. package/dist/scheduler/locks/RedisLockBackend.js +13 -2
  81. package/dist/scheduler/locks/RedisLockBackend.js.map +1 -1
  82. package/dist/security/CookieSigner.d.ts.map +1 -1
  83. package/dist/security/CookieSigner.js +17 -3
  84. package/dist/security/CookieSigner.js.map +1 -1
  85. package/dist/security/SignedUrl.d.ts.map +1 -1
  86. package/dist/security/SignedUrl.js +6 -1
  87. package/dist/security/SignedUrl.js.map +1 -1
  88. package/dist/server/Server.d.ts +75 -9
  89. package/dist/server/Server.d.ts.map +1 -1
  90. package/dist/server/Server.js +60 -9
  91. package/dist/server/Server.js.map +1 -1
  92. package/dist/server/nativeGrpc.d.ts +27 -0
  93. package/dist/server/nativeGrpc.d.ts.map +1 -0
  94. package/dist/server/nativeGrpc.js +47 -0
  95. package/dist/server/nativeGrpc.js.map +1 -0
  96. package/dist/server/nativeLimits.d.ts +49 -0
  97. package/dist/server/nativeLimits.d.ts.map +1 -0
  98. package/dist/server/nativeLimits.js +67 -0
  99. package/dist/server/nativeLimits.js.map +1 -0
  100. package/dist/session/Session.d.ts +45 -0
  101. package/dist/session/Session.d.ts.map +1 -1
  102. package/dist/session/Session.js +31 -2
  103. package/dist/session/Session.js.map +1 -1
  104. package/dist/session/SessionMiddleware.d.ts.map +1 -1
  105. package/dist/session/SessionMiddleware.js +52 -13
  106. package/dist/session/SessionMiddleware.js.map +1 -1
  107. package/dist/session/config.d.ts +5 -0
  108. package/dist/session/config.d.ts.map +1 -1
  109. package/dist/session/config.js +8 -0
  110. package/dist/session/config.js.map +1 -1
  111. package/dist/session/drivers/CookieDriver.d.ts +12 -3
  112. package/dist/session/drivers/CookieDriver.d.ts.map +1 -1
  113. package/dist/session/drivers/CookieDriver.js +20 -6
  114. package/dist/session/drivers/CookieDriver.js.map +1 -1
  115. package/dist/session/drivers/DatabaseDriver.d.ts +2 -0
  116. package/dist/session/drivers/DatabaseDriver.d.ts.map +1 -1
  117. package/dist/session/drivers/DatabaseDriver.js +6 -0
  118. package/dist/session/drivers/DatabaseDriver.js.map +1 -1
  119. package/dist/session/drivers/FileDriver.d.ts +8 -0
  120. package/dist/session/drivers/FileDriver.d.ts.map +1 -1
  121. package/dist/session/drivers/FileDriver.js +85 -11
  122. package/dist/session/drivers/FileDriver.js.map +1 -1
  123. package/dist/session/drivers/MemoryDriver.d.ts +2 -0
  124. package/dist/session/drivers/MemoryDriver.d.ts.map +1 -1
  125. package/dist/session/drivers/MemoryDriver.js +21 -5
  126. package/dist/session/drivers/MemoryDriver.js.map +1 -1
  127. package/dist/session/drivers/RedisDriver.d.ts +2 -0
  128. package/dist/session/drivers/RedisDriver.d.ts.map +1 -1
  129. package/dist/session/drivers/RedisDriver.js +6 -0
  130. package/dist/session/drivers/RedisDriver.js.map +1 -1
  131. package/dist/session/errors.d.ts +27 -0
  132. package/dist/session/errors.d.ts.map +1 -1
  133. package/dist/session/errors.js +33 -0
  134. package/dist/session/errors.js.map +1 -1
  135. package/dist/session/templateTags.d.ts +14 -0
  136. package/dist/session/templateTags.d.ts.map +1 -1
  137. package/dist/session/templateTags.js +45 -17
  138. package/dist/session/templateTags.js.map +1 -1
  139. package/dist/storage/StaticMiddleware.d.ts.map +1 -1
  140. package/dist/storage/StaticMiddleware.js +29 -18
  141. package/dist/storage/StaticMiddleware.js.map +1 -1
  142. package/events.darwin-arm64.node +0 -0
  143. package/events.darwin-x64.node +0 -0
  144. package/events.linux-arm64-gnu.node +0 -0
  145. package/events.linux-x64-gnu.node +0 -0
  146. package/events.win32-x64-msvc.node +0 -0
  147. package/index.darwin-arm64.node +0 -0
  148. package/index.darwin-x64.node +0 -0
  149. package/index.linux-arm64-gnu.node +0 -0
  150. package/index.linux-x64-gnu.node +0 -0
  151. package/index.win32-x64-msvc.node +0 -0
  152. package/package.json +11 -2
  153. package/scheduler.darwin-arm64.node +0 -0
  154. package/scheduler.darwin-x64.node +0 -0
  155. package/scheduler.linux-arm64-gnu.node +0 -0
  156. package/scheduler.linux-x64-gnu.node +0 -0
  157. package/scheduler.win32-x64-msvc.node +0 -0
  158. package/src/Codemods.ts +265 -0
  159. package/src/Ignitor.ts +48 -20
  160. package/src/bodyparser/BodyParserMiddleware.ts +3 -0
  161. package/src/bodyparser/qsParse.ts +69 -14
  162. package/src/codemods/source.ts +303 -0
  163. package/src/graphql/GraphQLEngine.ts +3 -2
  164. package/src/http/Exception.ts +17 -2
  165. package/src/http/HttpContext.ts +14 -1
  166. package/src/http/RedirectBuilder.ts +48 -37
  167. package/src/http/Request.ts +87 -29
  168. package/src/http/SseStream.ts +6 -7
  169. package/src/http/permissions.ts +31 -0
  170. package/src/http-client/HttpClient.ts +505 -0
  171. package/src/http-client/addresses.ts +58 -0
  172. package/src/http-client/fake.ts +141 -0
  173. package/src/http-client/index.ts +21 -0
  174. package/src/index.ts +9 -1
  175. package/src/middleware/Pipeline.ts +7 -3
  176. package/src/native/generated.ts +123 -3
  177. package/src/rpc/RpcProvider.ts +10 -1
  178. package/src/rpc/RpcRouter.ts +126 -42
  179. package/src/scheduler/locks/RedisLockBackend.ts +12 -4
  180. package/src/security/CookieSigner.ts +17 -3
  181. package/src/security/SignedUrl.ts +6 -1
  182. package/src/server/Server.ts +103 -9
  183. package/src/server/nativeGrpc.ts +59 -0
  184. package/src/server/nativeLimits.ts +94 -0
  185. package/src/session/Session.ts +72 -2
  186. package/src/session/SessionMiddleware.ts +53 -14
  187. package/src/session/config.ts +7 -0
  188. package/src/session/drivers/CookieDriver.ts +21 -6
  189. package/src/session/drivers/DatabaseDriver.ts +10 -0
  190. package/src/session/drivers/FileDriver.ts +91 -11
  191. package/src/session/drivers/MemoryDriver.ts +20 -5
  192. package/src/session/drivers/RedisDriver.ts +7 -0
  193. package/src/session/errors.ts +48 -0
  194. package/src/session/templateTags.ts +57 -0
  195. package/src/storage/StaticMiddleware.ts +28 -18
@@ -0,0 +1,505 @@
1
+ /**
2
+ * Outbound HTTP for server code that calls a URL it does not fully control —
3
+ * a customer's domain, a webhook target, a CMS the user pointed the app at.
4
+ *
5
+ * `fetch` connects wherever DNS says. A domain that resolves to `10.0.0.5`,
6
+ * `127.0.0.1` or `169.254.169.254` makes the server read its own network on
7
+ * the attacker's behalf (SSRF). Checking the address before the call is not
8
+ * enough: a DNS answer can change between the check and the connection
9
+ * (rebinding). So the check runs AT CONNECT TIME, inside the `lookup` of the
10
+ * client's own agents — the address that is checked is the address the
11
+ * socket connects to. The agents belong to this client alone, so a pooled
12
+ * socket opened by other code is never reused.
13
+ *
14
+ * Also bounded: one deadline for the whole exchange (redirects included), a
15
+ * maximum response size, and a few redirects, to the same host by default.
16
+ * Every failure is an {@link HttpClientError} with a `reason`.
17
+ *
18
+ * No AdonisJS counterpart: the framework ships no outbound client.
19
+ */
20
+
21
+ import { lookup as dnsLookup } from 'node:dns'
22
+ import http from 'node:http'
23
+ import https from 'node:https'
24
+ import { isIP, type LookupFunction } from 'node:net'
25
+ import { ReamError } from '../errors/ReamError.js'
26
+ import { isPublicAddress } from './addresses.js'
27
+
28
+ /** Why an outbound request failed. */
29
+ export type HttpClientFailure =
30
+ | 'invalidUrl'
31
+ | 'privateAddress'
32
+ | 'unreachable'
33
+ | 'timedOut'
34
+ | 'tooLarge'
35
+ | 'redirect'
36
+
37
+ const CODES: Record<HttpClientFailure, string> = {
38
+ invalidUrl: 'E_HTTP_CLIENT_INVALID_URL',
39
+ privateAddress: 'E_HTTP_CLIENT_PRIVATE_ADDRESS',
40
+ unreachable: 'E_HTTP_CLIENT_UNREACHABLE',
41
+ timedOut: 'E_HTTP_CLIENT_TIMED_OUT',
42
+ tooLarge: 'E_HTTP_CLIENT_TOO_LARGE',
43
+ redirect: 'E_HTTP_CLIENT_REDIRECT_REFUSED',
44
+ }
45
+
46
+ /**
47
+ * A failed outbound request. A `ReamError`, not an HTTP `Exception`: left
48
+ * uncaught in a handler it answers a generic 500 and its message, which names
49
+ * hosts and addresses, never reaches the client.
50
+ */
51
+ export class HttpClientError extends ReamError {
52
+ readonly reason: HttpClientFailure
53
+ /** The target, without its query string (which may carry credentials). */
54
+ readonly url: string
55
+
56
+ constructor(reason: HttpClientFailure, url: URL | string, message: string, cause?: unknown) {
57
+ const shown = describeUrl(url)
58
+ super(CODES[reason], message, { context: { url: shown } })
59
+ this.name = 'HttpClientError'
60
+ this.reason = reason
61
+ this.url = shown
62
+ if (cause !== undefined) this.cause = cause
63
+ }
64
+ }
65
+
66
+ export interface HttpClientOptions {
67
+ /** Deadline for the whole exchange, redirects included, in ms. Default 10 000. */
68
+ timeout?: number
69
+ /** Largest response body accepted, in bytes. Default 5 MiB. */
70
+ maxResponseBytes?: number
71
+ /** Redirects followed before giving up. Default 3; `0` hands back the 3xx itself. */
72
+ maxRedirects?: number
73
+ /** Follow a redirect to another host. Default `false`. */
74
+ crossHostRedirects?: boolean
75
+ /** `User-Agent` sent with every request. Default `@c9up/ream`. */
76
+ userAgent?: string
77
+ /** Headers sent with every request (a request's own headers win). */
78
+ headers?: Record<string, string>
79
+ /**
80
+ * Hosts exempt from the public-address rule, as `host` or `host:port` —
81
+ * `'localhost:4399'` for a service running next to the app in development.
82
+ * Exact matches only; keep it out of production configuration.
83
+ */
84
+ allowHosts?: string[]
85
+ }
86
+
87
+ export interface HttpRequestOptions {
88
+ /** Default `GET`. */
89
+ method?: string
90
+ headers?: Record<string, string>
91
+ body?: string | Uint8Array
92
+ /** Sent as JSON, with `content-type: application/json` unless one is given. */
93
+ json?: unknown
94
+ /** Abort from outside; the deadline still applies. */
95
+ signal?: AbortSignal
96
+ /** Overrides the client's `timeout` for this request. */
97
+ timeout?: number
98
+ /** Overrides the client's `maxResponseBytes` for this request. */
99
+ maxResponseBytes?: number
100
+ }
101
+
102
+ /** One request on the wire — what {@link HttpClient.send} receives. */
103
+ export interface OutgoingRequest {
104
+ url: URL
105
+ method: string
106
+ headers: Record<string, string>
107
+ body?: Buffer
108
+ signal: AbortSignal
109
+ maxResponseBytes: number
110
+ }
111
+
112
+ /** A complete response, its body read and bounded. */
113
+ export class HttpResponse {
114
+ readonly status: number
115
+ readonly headers: Readonly<Record<string, string | string[]>>
116
+ /** The URL that answered, after redirects. */
117
+ readonly url: string
118
+ readonly body: Buffer
119
+
120
+ constructor(
121
+ status: number,
122
+ headers: Record<string, string | string[]>,
123
+ url: string,
124
+ body: Buffer,
125
+ ) {
126
+ this.status = status
127
+ this.headers = headers
128
+ this.url = url
129
+ this.body = body
130
+ }
131
+
132
+ /** A 2xx status. */
133
+ get ok(): boolean {
134
+ return this.status >= 200 && this.status < 300
135
+ }
136
+
137
+ /** A header's first value; names are case-insensitive. */
138
+ header(name: string): string | undefined {
139
+ const value = this.headers[name.toLowerCase()]
140
+ return Array.isArray(value) ? value[0] : value
141
+ }
142
+
143
+ text(): string {
144
+ return this.body.toString('utf8')
145
+ }
146
+
147
+ /** The body parsed as JSON — `unknown` until the caller validates it. */
148
+ json(): unknown {
149
+ return JSON.parse(this.text())
150
+ }
151
+ }
152
+
153
+ const DEFAULT_TIMEOUT = 10_000
154
+ const DEFAULT_MAX_RESPONSE_BYTES = 5 * 1024 * 1024
155
+ const DEFAULT_MAX_REDIRECTS = 3
156
+ const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308])
157
+ /** Headers that must not follow a redirect to another host. */
158
+ const CREDENTIAL_HEADERS = ['authorization', 'cookie', 'proxy-authorization']
159
+
160
+ export class HttpClient {
161
+ readonly #timeout: number
162
+ readonly #maxResponseBytes: number
163
+ readonly #maxRedirects: number
164
+ readonly #crossHostRedirects: boolean
165
+ readonly #headers: Record<string, string>
166
+ readonly #allowHosts: ReadonlySet<string>
167
+ #guardedAgents?: { http: http.Agent; https: https.Agent }
168
+ #exemptAgents?: { http: http.Agent; https: https.Agent }
169
+
170
+ constructor(options: HttpClientOptions = {}) {
171
+ this.#timeout = options.timeout ?? DEFAULT_TIMEOUT
172
+ this.#maxResponseBytes = options.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES
173
+ this.#maxRedirects = options.maxRedirects ?? DEFAULT_MAX_REDIRECTS
174
+ this.#crossHostRedirects = options.crossHostRedirects ?? false
175
+ this.#headers = {
176
+ 'user-agent': options.userAgent ?? '@c9up/ream',
177
+ ...lowercaseKeys(options.headers ?? {}),
178
+ }
179
+ this.#allowHosts = new Set((options.allowHosts ?? []).map((entry) => entry.toLowerCase()))
180
+ }
181
+
182
+ get(url: string | URL, options: Omit<HttpRequestOptions, 'method'> = {}): Promise<HttpResponse> {
183
+ return this.request(url, { ...options, method: 'GET' })
184
+ }
185
+
186
+ post(url: string | URL, options: Omit<HttpRequestOptions, 'method'> = {}): Promise<HttpResponse> {
187
+ return this.request(url, { ...options, method: 'POST' })
188
+ }
189
+
190
+ put(url: string | URL, options: Omit<HttpRequestOptions, 'method'> = {}): Promise<HttpResponse> {
191
+ return this.request(url, { ...options, method: 'PUT' })
192
+ }
193
+
194
+ patch(
195
+ url: string | URL,
196
+ options: Omit<HttpRequestOptions, 'method'> = {},
197
+ ): Promise<HttpResponse> {
198
+ return this.request(url, { ...options, method: 'PATCH' })
199
+ }
200
+
201
+ delete(
202
+ url: string | URL,
203
+ options: Omit<HttpRequestOptions, 'method'> = {},
204
+ ): Promise<HttpResponse> {
205
+ return this.request(url, { ...options, method: 'DELETE' })
206
+ }
207
+
208
+ /** Send a request, following redirects within the client's rules. */
209
+ async request(input: string | URL, options: HttpRequestOptions = {}): Promise<HttpResponse> {
210
+ let url = parseTarget(input)
211
+ let method = (options.method ?? 'GET').toUpperCase()
212
+ let headers: Record<string, string> = {
213
+ ...this.#headers,
214
+ ...lowercaseKeys(options.headers ?? {}),
215
+ }
216
+ let body = encodeBody(options, headers)
217
+ const maxResponseBytes = options.maxResponseBytes ?? this.#maxResponseBytes
218
+
219
+ const timeout = options.timeout ?? this.#timeout
220
+ const deadline = new AbortController()
221
+ const timer = setTimeout(() => {
222
+ deadline.abort(
223
+ new HttpClientError('timedOut', url, `No complete response within ${timeout} ms.`),
224
+ )
225
+ }, timeout)
226
+ const signal = options.signal
227
+ ? AbortSignal.any([deadline.signal, options.signal])
228
+ : deadline.signal
229
+
230
+ try {
231
+ for (let redirects = 0; ; redirects++) {
232
+ this.#assertAddressLiteralAllowed(url)
233
+ if (body !== undefined) headers['content-length'] = String(body.length)
234
+ const response = await this.send({ url, method, headers, body, signal, maxResponseBytes })
235
+ if (response.body.length > maxResponseBytes) {
236
+ throw new HttpClientError('tooLarge', url, `Response exceeds ${maxResponseBytes} bytes.`)
237
+ }
238
+
239
+ const location = response.header('location')
240
+ if (!REDIRECT_STATUSES.has(response.status) || location === undefined) return response
241
+ if (this.#maxRedirects === 0) return response
242
+ if (redirects >= this.#maxRedirects) {
243
+ throw new HttpClientError('redirect', url, `More than ${this.#maxRedirects} redirects.`)
244
+ }
245
+
246
+ const next = parseTarget(location, url)
247
+ if (url.protocol === 'https:' && next.protocol === 'http:') {
248
+ throw new HttpClientError('redirect', next, 'Refused a redirect from https to http.')
249
+ }
250
+ if (next.host !== url.host) {
251
+ if (!this.#crossHostRedirects) {
252
+ throw new HttpClientError(
253
+ 'redirect',
254
+ next,
255
+ `Refused a redirect from ${url.host} to another host (${next.host}).`,
256
+ )
257
+ }
258
+ headers = withoutKeys(headers, CREDENTIAL_HEADERS)
259
+ }
260
+ // What browsers and fetch do: a 303 always becomes a GET, and a
261
+ // 301/302 does when it answered a POST. 307/308 keep method and body.
262
+ const becomesGet =
263
+ response.status === 303 ||
264
+ ((response.status === 301 || response.status === 302) && method === 'POST')
265
+ if (becomesGet && method !== 'HEAD') {
266
+ method = 'GET'
267
+ body = undefined
268
+ headers = withoutKeys(headers, ['content-type', 'content-length'])
269
+ }
270
+ url = next
271
+ }
272
+ } catch (error) {
273
+ // An abort from the deadline surfaces as whatever the transport threw;
274
+ // the deadline's own error says what happened.
275
+ if (deadline.signal.aborted && deadline.signal.reason instanceof HttpClientError) {
276
+ throw deadline.signal.reason
277
+ }
278
+ throw error
279
+ } finally {
280
+ clearTimeout(timer)
281
+ }
282
+ }
283
+
284
+ /**
285
+ * Put one request on the wire and read its response, bounded. Redirects are
286
+ * not followed here. Overridden by the test fake.
287
+ */
288
+ protected send(request: OutgoingRequest): Promise<HttpResponse> {
289
+ const { url, method, headers, body, signal, maxResponseBytes } = request
290
+ const agents = this.#agentsFor(url)
291
+ const transport = url.protocol === 'https:' ? https : http
292
+ const agent = url.protocol === 'https:' ? agents.https : agents.http
293
+
294
+ return new Promise<HttpResponse>((resolve, reject) => {
295
+ let settled = false
296
+ const fail = (error: unknown): void => {
297
+ if (settled) return
298
+ settled = true
299
+ signal.removeEventListener('abort', onAbort)
300
+ req.destroy()
301
+ reject(toClientError(error, url, signal))
302
+ }
303
+ const onAbort = (): void => fail(signal.reason)
304
+
305
+ const req = transport.request(url, { method, headers, agent }, (res) => {
306
+ const declared = Number(res.headers['content-length'])
307
+ if (Number.isFinite(declared) && declared > maxResponseBytes) {
308
+ fail(new HttpClientError('tooLarge', url, `Response exceeds ${maxResponseBytes} bytes.`))
309
+ return
310
+ }
311
+ const chunks: Buffer[] = []
312
+ let received = 0
313
+ res.on('data', (chunk: Buffer) => {
314
+ received += chunk.length
315
+ if (received > maxResponseBytes) {
316
+ fail(
317
+ new HttpClientError('tooLarge', url, `Response exceeds ${maxResponseBytes} bytes.`),
318
+ )
319
+ return
320
+ }
321
+ chunks.push(chunk)
322
+ })
323
+ res.on('error', fail)
324
+ res.on('close', () => {
325
+ if (!res.complete) fail(new Error('The response was cut short.'))
326
+ })
327
+ res.on('end', () => {
328
+ if (settled) return
329
+ settled = true
330
+ signal.removeEventListener('abort', onAbort)
331
+ resolve(
332
+ new HttpResponse(
333
+ res.statusCode ?? 0,
334
+ copyHeaders(res.headers),
335
+ url.href,
336
+ Buffer.concat(chunks),
337
+ ),
338
+ )
339
+ })
340
+ })
341
+ req.on('error', fail)
342
+ if (signal.aborted) {
343
+ fail(signal.reason)
344
+ return
345
+ }
346
+ signal.addEventListener('abort', onAbort, { once: true })
347
+ req.end(body)
348
+ })
349
+ }
350
+
351
+ /** Release the pooled connections. The client stays usable; new ones are opened on demand. */
352
+ close(): void {
353
+ for (const agents of [this.#guardedAgents, this.#exemptAgents]) {
354
+ agents?.http.destroy()
355
+ agents?.https.destroy()
356
+ }
357
+ this.#guardedAgents = undefined
358
+ this.#exemptAgents = undefined
359
+ }
360
+
361
+ #isExempt(url: URL): boolean {
362
+ if (this.#allowHosts.size === 0) return false
363
+ const port = url.port || (url.protocol === 'https:' ? '443' : '80')
364
+ const hostname = url.hostname.toLowerCase()
365
+ return this.#allowHosts.has(hostname) || this.#allowHosts.has(`${hostname}:${port}`)
366
+ }
367
+
368
+ /**
369
+ * An address written in the URL is never looked up, so the connect-time
370
+ * check does not see it: it is judged here, on every hop.
371
+ */
372
+ #assertAddressLiteralAllowed(url: URL): void {
373
+ const address = url.hostname.replace(/^\[|\]$/g, '')
374
+ if (isIP(address) === 0 || this.#isExempt(url)) return
375
+ if (!isPublicAddress(address)) {
376
+ throw new HttpClientError('privateAddress', url, `${address} is not a public address.`)
377
+ }
378
+ }
379
+
380
+ #agentsFor(url: URL): { http: http.Agent; https: https.Agent } {
381
+ if (this.#isExempt(url)) {
382
+ this.#exemptAgents ??= {
383
+ http: new http.Agent({ keepAlive: true }),
384
+ https: new https.Agent({ keepAlive: true }),
385
+ }
386
+ return this.#exemptAgents
387
+ }
388
+ this.#guardedAgents ??= {
389
+ http: new http.Agent({ keepAlive: true, lookup: publicOnlyLookup }),
390
+ https: new https.Agent({ keepAlive: true, lookup: publicOnlyLookup }),
391
+ }
392
+ return this.#guardedAgents
393
+ }
394
+ }
395
+
396
+ /**
397
+ * `dns.lookup`, refusing the connection when ANY address the name resolves
398
+ * to is not public — a name that answers both a public and a private address
399
+ * is a rebinding setup, not a choice to make on the attacker's behalf.
400
+ */
401
+ const publicOnlyLookup: LookupFunction = (hostname, options, callback) => {
402
+ dnsLookup(hostname, { ...options, all: true }, (error, addresses) => {
403
+ if (error) {
404
+ callback(error, '')
405
+ return
406
+ }
407
+ const refused = addresses.find((entry) => !isPublicAddress(entry.address))
408
+ const [first] = addresses
409
+ if (refused !== undefined || first === undefined) {
410
+ callback(
411
+ new HttpClientError(
412
+ 'privateAddress',
413
+ `http://${hostname}`,
414
+ `${hostname} resolves to ${refused?.address ?? 'no address'}, which is not a public address.`,
415
+ ),
416
+ '',
417
+ )
418
+ return
419
+ }
420
+ if (options.all) callback(null, addresses)
421
+ else callback(null, first.address, first.family)
422
+ })
423
+ }
424
+
425
+ function parseTarget(input: string | URL, base?: URL): URL {
426
+ let url: URL
427
+ try {
428
+ url = new URL(input, base)
429
+ } catch (error) {
430
+ throw new HttpClientError('invalidUrl', String(input), 'Not a valid URL.', error)
431
+ }
432
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') {
433
+ throw new HttpClientError(
434
+ 'invalidUrl',
435
+ url,
436
+ `Only http and https URLs are allowed, not ${url.protocol}`,
437
+ )
438
+ }
439
+ if (url.username !== '' || url.password !== '') {
440
+ throw new HttpClientError(
441
+ 'invalidUrl',
442
+ url,
443
+ 'Credentials in the URL are refused; send a header.',
444
+ )
445
+ }
446
+ return url
447
+ }
448
+
449
+ function encodeBody(
450
+ options: HttpRequestOptions,
451
+ headers: Record<string, string>,
452
+ ): Buffer | undefined {
453
+ if (options.json !== undefined) {
454
+ headers['content-type'] ??= 'application/json'
455
+ return Buffer.from(JSON.stringify(options.json), 'utf8')
456
+ }
457
+ if (options.body === undefined) return undefined
458
+ return typeof options.body === 'string'
459
+ ? Buffer.from(options.body, 'utf8')
460
+ : Buffer.from(options.body)
461
+ }
462
+
463
+ function toClientError(error: unknown, url: URL, signal: AbortSignal): unknown {
464
+ if (error instanceof HttpClientError) return error
465
+ // An abort the caller asked for is theirs to recognise: hand back its reason.
466
+ if (signal.aborted && error === signal.reason) return error
467
+ const message = error instanceof Error ? error.message : String(error)
468
+ return new HttpClientError('unreachable', url, `Could not reach ${url.host}: ${message}`, error)
469
+ }
470
+
471
+ function lowercaseKeys(headers: Record<string, string>): Record<string, string> {
472
+ const out: Record<string, string> = {}
473
+ for (const [name, value] of Object.entries(headers)) out[name.toLowerCase()] = value
474
+ return out
475
+ }
476
+
477
+ function withoutKeys(
478
+ headers: Record<string, string>,
479
+ names: readonly string[],
480
+ ): Record<string, string> {
481
+ const out: Record<string, string> = {}
482
+ for (const [name, value] of Object.entries(headers)) {
483
+ if (!names.includes(name)) out[name] = value
484
+ }
485
+ return out
486
+ }
487
+
488
+ function copyHeaders(headers: http.IncomingHttpHeaders): Record<string, string | string[]> {
489
+ const out: Record<string, string | string[]> = {}
490
+ for (const [name, value] of Object.entries(headers)) {
491
+ if (value !== undefined) out[name] = value
492
+ }
493
+ return out
494
+ }
495
+
496
+ function describeUrl(url: URL | string): string {
497
+ if (typeof url === 'string') {
498
+ try {
499
+ return describeUrl(new URL(url))
500
+ } catch {
501
+ return url.split('?')[0] ?? url
502
+ }
503
+ }
504
+ return `${url.origin}${url.pathname}`
505
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Which addresses an outbound request may connect to.
3
+ *
4
+ * Everything that is not a globally routable unicast address is refused:
5
+ * loopback, private and shared ranges, link-local (the cloud metadata
6
+ * endpoint `169.254.169.254` lives there), documentation and benchmarking
7
+ * ranges, multicast, and the IPv6 transition prefixes that tunnel to an IPv4
8
+ * address (IPv4-compatible, NAT64, 6to4, Teredo) — through those, a "public" IPv6 address can
9
+ * still reach `10.0.0.5`. An IPv4-mapped IPv6 address (`::ffff:10.0.0.5`) is
10
+ * judged by the IPv4 rules.
11
+ */
12
+
13
+ import { BlockList, isIP } from 'node:net'
14
+
15
+ const NON_PUBLIC_V4: ReadonlyArray<readonly [string, number]> = [
16
+ ['0.0.0.0', 8], // "this network"
17
+ ['10.0.0.0', 8], // private
18
+ ['100.64.0.0', 10], // carrier-grade NAT
19
+ ['127.0.0.0', 8], // loopback
20
+ ['169.254.0.0', 16], // link-local, cloud metadata
21
+ ['172.16.0.0', 12], // private
22
+ ['192.0.0.0', 24], // IETF protocol assignments
23
+ ['192.0.2.0', 24], // TEST-NET-1
24
+ ['192.88.99.0', 24], // 6to4 relay anycast
25
+ ['192.168.0.0', 16], // private
26
+ ['198.18.0.0', 15], // benchmarking
27
+ ['198.51.100.0', 24], // TEST-NET-2
28
+ ['203.0.113.0', 24], // TEST-NET-3
29
+ ['224.0.0.0', 4], // multicast
30
+ ['240.0.0.0', 4], // reserved, broadcast
31
+ ]
32
+
33
+ const NON_PUBLIC_V6: ReadonlyArray<readonly [string, number]> = [
34
+ ['::', 128], // unspecified
35
+ ['::1', 128], // loopback
36
+ ['::', 96], // IPv4-compatible (deprecated), embeds an IPv4 address
37
+ ['64:ff9b::', 96], // NAT64, embeds an IPv4 address
38
+ ['64:ff9b:1::', 48], // local-use NAT64
39
+ ['100::', 64], // discard
40
+ ['2001::', 23], // IETF protocol assignments, Teredo included
41
+ ['2001:db8::', 32], // documentation
42
+ ['2002::', 16], // 6to4, embeds an IPv4 address
43
+ ['fc00::', 7], // unique local
44
+ ['fe80::', 10], // link-local
45
+ ['fec0::', 10], // site-local (deprecated)
46
+ ['ff00::', 8], // multicast
47
+ ]
48
+
49
+ const nonPublic = new BlockList()
50
+ for (const [network, prefix] of NON_PUBLIC_V4) nonPublic.addSubnet(network, prefix, 'ipv4')
51
+ for (const [network, prefix] of NON_PUBLIC_V6) nonPublic.addSubnet(network, prefix, 'ipv6')
52
+
53
+ /** True for a globally routable unicast address; false for anything else, including a non-address. */
54
+ export function isPublicAddress(address: string): boolean {
55
+ const family = isIP(address)
56
+ if (family === 0) return false
57
+ return !nonPublic.check(address, family === 4 ? 'ipv4' : 'ipv6')
58
+ }
@@ -0,0 +1,141 @@
1
+ /**
2
+ * An {@link HttpClient} that never touches the network, for tests.
3
+ *
4
+ * It is a real client with the transport swapped: URL validation, the
5
+ * address-literal rule, the size limit and the redirect policy all still
6
+ * apply, so a test sees the same refusals production would. Only the wire is
7
+ * replaced — canned responses in, recorded requests out.
8
+ *
9
+ * const http = fakeHttp()
10
+ * http.on('GET', 'https://cms.example.com/api/posts', { json: [{ id: 1 }] })
11
+ * await new CmsReader(http).posts()
12
+ * expect(http.requests).toHaveLength(1)
13
+ */
14
+
15
+ import {
16
+ HttpClient,
17
+ HttpClientError,
18
+ type HttpClientOptions,
19
+ HttpResponse,
20
+ type OutgoingRequest,
21
+ } from './HttpClient.js'
22
+
23
+ /** What a faked endpoint answers. */
24
+ export interface FakeResponse {
25
+ /** Default 200. */
26
+ status?: number
27
+ headers?: Record<string, string | string[]>
28
+ body?: string | Uint8Array
29
+ /** Serialised as the body, with `content-type: application/json`. */
30
+ json?: unknown
31
+ }
32
+
33
+ /** A request the fake received. */
34
+ export interface RecordedRequest {
35
+ method: string
36
+ url: string
37
+ headers: Readonly<Record<string, string>>
38
+ body: Buffer | undefined
39
+ }
40
+
41
+ /** An exact URL (with or without its query string), a pattern, or a predicate. */
42
+ export type FakeMatcher = string | RegExp | ((request: RecordedRequest) => boolean)
43
+
44
+ interface FakeRoute {
45
+ method: string | undefined
46
+ match: FakeMatcher
47
+ respond: FakeResponse | ((request: RecordedRequest) => FakeResponse)
48
+ }
49
+
50
+ export class FakeHttpClient extends HttpClient {
51
+ /** Every request sent, in order — redirect hops included. */
52
+ readonly requests: RecordedRequest[] = []
53
+ readonly #routes: FakeRoute[] = []
54
+
55
+ /**
56
+ * Answer `method` + `match` with `response`. The most recent matching
57
+ * registration wins, so a test can override a default set up earlier.
58
+ */
59
+ on(
60
+ method: string,
61
+ match: FakeMatcher,
62
+ response: FakeResponse | ((request: RecordedRequest) => FakeResponse),
63
+ ): this {
64
+ this.#routes.push({ method: method.toUpperCase(), match, respond: response })
65
+ return this
66
+ }
67
+
68
+ /** Answer `match` whatever the method. */
69
+ onAny(
70
+ match: FakeMatcher,
71
+ response: FakeResponse | ((request: RecordedRequest) => FakeResponse),
72
+ ): this {
73
+ this.#routes.push({ method: undefined, match, respond: response })
74
+ return this
75
+ }
76
+
77
+ /** Forget the registered responses and the recorded requests. */
78
+ reset(): void {
79
+ this.#routes.length = 0
80
+ this.requests.length = 0
81
+ }
82
+
83
+ protected override async send(request: OutgoingRequest): Promise<HttpResponse> {
84
+ const recorded: RecordedRequest = {
85
+ method: request.method,
86
+ url: request.url.href,
87
+ headers: { ...request.headers },
88
+ body: request.body,
89
+ }
90
+ this.requests.push(recorded)
91
+
92
+ const route = this.#latestRoute(recorded, request.url)
93
+ if (route === undefined) {
94
+ throw new HttpClientError(
95
+ 'unreachable',
96
+ request.url,
97
+ `No fake response for ${recorded.method} ${recorded.url}.`,
98
+ )
99
+ }
100
+ const answer = typeof route.respond === 'function' ? route.respond(recorded) : route.respond
101
+ const headers: Record<string, string | string[]> = {}
102
+ const given: Record<string, string | string[]> = answer.headers ?? {}
103
+ for (const [name, value] of Object.entries(given)) {
104
+ headers[name.toLowerCase()] = value
105
+ }
106
+ let body: Buffer
107
+ if (answer.json !== undefined) {
108
+ headers['content-type'] ??= 'application/json'
109
+ body = Buffer.from(JSON.stringify(answer.json), 'utf8')
110
+ } else if (answer.body === undefined) {
111
+ body = Buffer.alloc(0)
112
+ } else {
113
+ body =
114
+ typeof answer.body === 'string'
115
+ ? Buffer.from(answer.body, 'utf8')
116
+ : Buffer.from(answer.body)
117
+ }
118
+ return new HttpResponse(answer.status ?? 200, headers, request.url.href, body)
119
+ }
120
+
121
+ #latestRoute(request: RecordedRequest, url: URL): FakeRoute | undefined {
122
+ for (let i = this.#routes.length - 1; i >= 0; i--) {
123
+ const candidate = this.#routes[i]
124
+ if (candidate === undefined) continue
125
+ if (candidate.method !== undefined && candidate.method !== request.method) continue
126
+ if (matches(candidate.match, request, url)) return candidate
127
+ }
128
+ return undefined
129
+ }
130
+ }
131
+
132
+ function matches(match: FakeMatcher, request: RecordedRequest, url: URL): boolean {
133
+ if (typeof match === 'function') return match(request)
134
+ if (match instanceof RegExp) return match.test(request.url)
135
+ return match === url.href || match === `${url.origin}${url.pathname}`
136
+ }
137
+
138
+ /** A fresh {@link FakeHttpClient}, with the same options a real client takes. */
139
+ export function fakeHttp(options: HttpClientOptions = {}): FakeHttpClient {
140
+ return new FakeHttpClient(options)
141
+ }