@chidchanun/bcp 0.1.26 → 0.1.28

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.
@@ -0,0 +1,393 @@
1
+ import * as http from "node:http";
2
+
3
+ import {
4
+ applyProductionHttpServerHardening,
5
+ closeHttpServerGracefully,
6
+ getProductionHardeningConfig,
7
+ type ProductionHardeningConfig,
8
+ } from "./production-hardening.js";
9
+
10
+ export interface HardeningProxyOptions {
11
+ port: number;
12
+ hostname: string;
13
+ upstreamPort: number;
14
+ upstreamHostname?: string;
15
+ }
16
+
17
+ export function createHardeningProxy(
18
+ options: HardeningProxyOptions
19
+ ) {
20
+ const upstreamHostname =
21
+ options.upstreamHostname ??
22
+ "127.0.0.1";
23
+ const config =
24
+ getProductionHardeningConfig();
25
+ let server:
26
+ http.Server | null =
27
+ null;
28
+
29
+ async function start(): Promise<void> {
30
+ if (server) {
31
+ return;
32
+ }
33
+
34
+ server =
35
+ http.createServer(
36
+ (
37
+ req,
38
+ res
39
+ ) => {
40
+ proxyRequest(
41
+ req,
42
+ res,
43
+ upstreamHostname,
44
+ options.upstreamPort,
45
+ config
46
+ );
47
+ }
48
+ );
49
+
50
+ applyProductionHttpServerHardening(
51
+ server,
52
+ config
53
+ );
54
+
55
+ await listen(
56
+ server,
57
+ options.port,
58
+ options.hostname
59
+ );
60
+ }
61
+
62
+ async function stop(): Promise<void> {
63
+ if (!server) {
64
+ return;
65
+ }
66
+
67
+ const current =
68
+ server;
69
+ server =
70
+ null;
71
+
72
+ await closeHttpServerGracefully(
73
+ current,
74
+ {
75
+ timeoutMs:
76
+ config.shutdownTimeoutMs,
77
+ }
78
+ );
79
+ }
80
+
81
+ return {
82
+ start,
83
+ stop,
84
+ config,
85
+ };
86
+ }
87
+
88
+ function proxyRequest(
89
+ req: http.IncomingMessage,
90
+ res: http.ServerResponse,
91
+ upstreamHostname: string,
92
+ upstreamPort: number,
93
+ config: ProductionHardeningConfig
94
+ ): void {
95
+ const headers =
96
+ createForwardedHeaders(
97
+ req,
98
+ config.trustProxy
99
+ );
100
+ const upstream =
101
+ http.request({
102
+ hostname:
103
+ upstreamHostname,
104
+ port:
105
+ upstreamPort,
106
+ method:
107
+ req.method ??
108
+ "GET",
109
+ path:
110
+ req.url ??
111
+ "/",
112
+ headers,
113
+ timeout:
114
+ config.requestTimeoutMs,
115
+ });
116
+
117
+ upstream.on(
118
+ "timeout",
119
+ () => {
120
+ upstream.destroy(
121
+ new Error(
122
+ "BCP Framework: upstream request timed out."
123
+ )
124
+ );
125
+ }
126
+ );
127
+
128
+ upstream.on(
129
+ "response",
130
+ (upstreamResponse) => {
131
+ for (
132
+ const [
133
+ name,
134
+ value,
135
+ ]
136
+ of Object.entries(
137
+ upstreamResponse.headers
138
+ )
139
+ ) {
140
+ if (
141
+ value !== undefined
142
+ ) {
143
+ res.setHeader(
144
+ name,
145
+ value
146
+ );
147
+ }
148
+ }
149
+
150
+ res.statusCode =
151
+ upstreamResponse.statusCode ??
152
+ 502;
153
+
154
+ if (
155
+ upstreamResponse.statusMessage
156
+ ) {
157
+ res.statusMessage =
158
+ upstreamResponse.statusMessage;
159
+ }
160
+
161
+ upstreamResponse.pipe(
162
+ res
163
+ );
164
+ }
165
+ );
166
+
167
+ upstream.on(
168
+ "error",
169
+ (error) => {
170
+ if (
171
+ res.headersSent
172
+ ) {
173
+ res.destroy(
174
+ error
175
+ );
176
+ return;
177
+ }
178
+
179
+ res.writeHead(
180
+ 502,
181
+ {
182
+ "Content-Type":
183
+ "text/plain; charset=utf-8",
184
+ "Cache-Control":
185
+ "no-store",
186
+ }
187
+ );
188
+ res.end(
189
+ "Bad Gateway"
190
+ );
191
+ }
192
+ );
193
+
194
+ req.on(
195
+ "aborted",
196
+ () => {
197
+ upstream.destroy();
198
+ }
199
+ );
200
+ req.on(
201
+ "error",
202
+ (error) => {
203
+ upstream.destroy(
204
+ error
205
+ );
206
+ }
207
+ );
208
+
209
+ req.pipe(
210
+ upstream
211
+ );
212
+ }
213
+
214
+ function createForwardedHeaders(
215
+ req: http.IncomingMessage,
216
+ trustProxy: boolean
217
+ ): http.OutgoingHttpHeaders {
218
+ const headers:
219
+ http.OutgoingHttpHeaders = {
220
+ ...req.headers,
221
+ };
222
+ const remoteAddress =
223
+ normalizeRemoteAddress(
224
+ req.socket.remoteAddress
225
+ );
226
+ const host =
227
+ firstHeader(
228
+ req.headers.host
229
+ );
230
+
231
+ if (!trustProxy) {
232
+ delete headers.forwarded;
233
+ delete headers[
234
+ "x-forwarded-for"
235
+ ];
236
+ delete headers[
237
+ "x-forwarded-host"
238
+ ];
239
+ delete headers[
240
+ "x-forwarded-proto"
241
+ ];
242
+ delete headers[
243
+ "x-real-ip"
244
+ ];
245
+ }
246
+
247
+ if (remoteAddress) {
248
+ const existingForwardedFor =
249
+ trustProxy
250
+ ? firstHeader(
251
+ req.headers[
252
+ "x-forwarded-for"
253
+ ]
254
+ )
255
+ : undefined;
256
+
257
+ headers[
258
+ "x-forwarded-for"
259
+ ] =
260
+ existingForwardedFor
261
+ ? `${existingForwardedFor}, ${remoteAddress}`
262
+ : remoteAddress;
263
+ headers[
264
+ "x-real-ip"
265
+ ] =
266
+ trustProxy &&
267
+ firstHeader(
268
+ req.headers[
269
+ "x-real-ip"
270
+ ]
271
+ )
272
+ ? firstHeader(
273
+ req.headers[
274
+ "x-real-ip"
275
+ ]
276
+ )
277
+ : remoteAddress;
278
+ }
279
+
280
+ if (host) {
281
+ headers[
282
+ "x-forwarded-host"
283
+ ] =
284
+ trustProxy &&
285
+ firstHeader(
286
+ req.headers[
287
+ "x-forwarded-host"
288
+ ]
289
+ )
290
+ ? firstHeader(
291
+ req.headers[
292
+ "x-forwarded-host"
293
+ ]
294
+ )
295
+ : host;
296
+ }
297
+
298
+ headers[
299
+ "x-forwarded-proto"
300
+ ] =
301
+ trustProxy &&
302
+ firstHeader(
303
+ req.headers[
304
+ "x-forwarded-proto"
305
+ ]
306
+ )
307
+ ? firstHeader(
308
+ req.headers[
309
+ "x-forwarded-proto"
310
+ ]
311
+ )
312
+ : "http";
313
+
314
+ return headers;
315
+ }
316
+
317
+ function normalizeRemoteAddress(
318
+ value: string | undefined
319
+ ): string | undefined {
320
+ if (!value) {
321
+ return undefined;
322
+ }
323
+
324
+ return value.startsWith(
325
+ "::ffff:"
326
+ )
327
+ ? value.slice(
328
+ "::ffff:".length
329
+ )
330
+ : value;
331
+ }
332
+
333
+ function firstHeader(
334
+ value:
335
+ string |
336
+ string[] |
337
+ undefined
338
+ ): string | undefined {
339
+ if (
340
+ Array.isArray(
341
+ value
342
+ )
343
+ ) {
344
+ return value[0];
345
+ }
346
+
347
+ return value;
348
+ }
349
+
350
+ function listen(
351
+ server: http.Server,
352
+ port: number,
353
+ hostname: string
354
+ ): Promise<void> {
355
+ return new Promise(
356
+ (
357
+ resolve,
358
+ reject
359
+ ) => {
360
+ const onError =
361
+ (error: Error) => {
362
+ server.off(
363
+ "listening",
364
+ onListening
365
+ );
366
+ reject(
367
+ error
368
+ );
369
+ };
370
+ const onListening =
371
+ () => {
372
+ server.off(
373
+ "error",
374
+ onError
375
+ );
376
+ resolve();
377
+ };
378
+
379
+ server.once(
380
+ "error",
381
+ onError
382
+ );
383
+ server.once(
384
+ "listening",
385
+ onListening
386
+ );
387
+ server.listen(
388
+ port,
389
+ hostname
390
+ );
391
+ }
392
+ );
393
+ }