@tumbaland/backend-core 1.24.0 → 1.25.0
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.
|
@@ -18,6 +18,12 @@ export interface CreateBaseAppOptions {
|
|
|
18
18
|
tracing?: boolean;
|
|
19
19
|
/** Use requestLoggerWithMetrics (adds Prometheus histograms) instead of the plain requestLogger. Default true. */
|
|
20
20
|
metrics?: boolean;
|
|
21
|
+
/**
|
|
22
|
+
* Value for Express's `trust proxy`. Defaults to trusting private/loopback
|
|
23
|
+
* addresses — see the note on tumbaland-proxy below. Pass `false` for a
|
|
24
|
+
* service exposed directly to clients with no proxy in front of it.
|
|
25
|
+
*/
|
|
26
|
+
trustProxy?: Parameters<Express['set']>[1];
|
|
21
27
|
}
|
|
22
28
|
/**
|
|
23
29
|
* Assembles the middleware stack every service was hand-rolling in its own
|
|
@@ -39,6 +45,24 @@ export interface CreateBaseAppOptions {
|
|
|
39
45
|
* Services with non-standard body parsing (a webhook needing the raw body,
|
|
40
46
|
* or conditional parsing per-route) should pass `parseJson`/`parseCookies:
|
|
41
47
|
* false` and mount those themselves at the exact point they need to.
|
|
48
|
+
*
|
|
49
|
+
* `trust proxy` is on by default because nothing here is reached directly:
|
|
50
|
+
* every service sits behind tumbaland-proxy, which sets `X-Forwarded-For`.
|
|
51
|
+
* Left at Express's default (`false`) that header is ignored, which
|
|
52
|
+
* express-rate-limit reports as `ERR_ERL_UNEXPECTED_X_FORWARDED_FOR` — and
|
|
53
|
+
* it is right to: the limiter would then key every request in the cluster on
|
|
54
|
+
* the proxy's own container IP, so one busy client could exhaust the window
|
|
55
|
+
* for everyone.
|
|
56
|
+
*
|
|
57
|
+
* The value is `loopback, linklocal, uniquelocal` (private ranges) rather
|
|
58
|
+
* than a hop count, because the chain length differs per ingress path: a
|
|
59
|
+
* direct hit arrives as `client` + the proxy's own socket address, while
|
|
60
|
+
* traffic through the Cloudflare tunnel arrives as `client, 127.0.0.1` (the
|
|
61
|
+
* local cloudflared) + that same socket address. Express walks the chain
|
|
62
|
+
* right-to-left and stops at the first address it does not trust, so both
|
|
63
|
+
* paths land on the real client; a fixed number would be wrong for one of
|
|
64
|
+
* them. All the intermediaries are private addresses and the client is not,
|
|
65
|
+
* which is exactly the split this preset encodes.
|
|
42
66
|
*/
|
|
43
67
|
export declare function createBaseApp(options?: CreateBaseAppOptions): Express;
|
|
44
68
|
//# sourceMappingURL=createBaseApp.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"createBaseApp.d.ts","sourceRoot":"","sources":["../../src/app/createBaseApp.ts"],"names":[],"mappings":"AAAA,OAAgB,EAAE,OAAO,EAAE,cAAc,EAAE,MAAM,SAAS,CAAC;AAK3D,OAAO,EAAwB,qBAAqB,EAAE,MAAM,8BAA8B,CAAC;AAI3F,MAAM,WAAW,oBAAoB;IACnC,8CAA8C;IAC9C,WAAW,CAAC,EAAE,qBAAqB,CAAC;IACpC;;;;;OAKG;IACH,WAAW,CAAC,EAAE,cAAc,GAAG,KAAK,CAAC;IACrC,4IAA4I;IAC5I,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,6FAA6F;IAC7F,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,6CAA6C;IAC7C,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,kHAAkH;IAClH,OAAO,CAAC,EAAE,OAAO,CAAC;
|
|
1
|
+
{"version":3,"file":"createBaseApp.d.ts","sourceRoot":"","sources":["../../src/app/createBaseApp.ts"],"names":[],"mappings":"AAAA,OAAgB,EAAE,OAAO,EAAE,cAAc,EAAE,MAAM,SAAS,CAAC;AAK3D,OAAO,EAAwB,qBAAqB,EAAE,MAAM,8BAA8B,CAAC;AAI3F,MAAM,WAAW,oBAAoB;IACnC,8CAA8C;IAC9C,WAAW,CAAC,EAAE,qBAAqB,CAAC;IACpC;;;;;OAKG;IACH,WAAW,CAAC,EAAE,cAAc,GAAG,KAAK,CAAC;IACrC,4IAA4I;IAC5I,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,6FAA6F;IAC7F,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,6CAA6C;IAC7C,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,kHAAkH;IAClH,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB;;;;OAIG;IACH,UAAU,CAAC,EAAE,UAAU,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;CAC5C;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsCG;AACH,wBAAgB,aAAa,CAAC,OAAO,GAAE,oBAAyB,GAAG,OAAO,CA2BzE"}
|
|
@@ -32,10 +32,29 @@ const healthController_1 = require("../health/healthController");
|
|
|
32
32
|
* Services with non-standard body parsing (a webhook needing the raw body,
|
|
33
33
|
* or conditional parsing per-route) should pass `parseJson`/`parseCookies:
|
|
34
34
|
* false` and mount those themselves at the exact point they need to.
|
|
35
|
+
*
|
|
36
|
+
* `trust proxy` is on by default because nothing here is reached directly:
|
|
37
|
+
* every service sits behind tumbaland-proxy, which sets `X-Forwarded-For`.
|
|
38
|
+
* Left at Express's default (`false`) that header is ignored, which
|
|
39
|
+
* express-rate-limit reports as `ERR_ERL_UNEXPECTED_X_FORWARDED_FOR` — and
|
|
40
|
+
* it is right to: the limiter would then key every request in the cluster on
|
|
41
|
+
* the proxy's own container IP, so one busy client could exhaust the window
|
|
42
|
+
* for everyone.
|
|
43
|
+
*
|
|
44
|
+
* The value is `loopback, linklocal, uniquelocal` (private ranges) rather
|
|
45
|
+
* than a hop count, because the chain length differs per ingress path: a
|
|
46
|
+
* direct hit arrives as `client` + the proxy's own socket address, while
|
|
47
|
+
* traffic through the Cloudflare tunnel arrives as `client, 127.0.0.1` (the
|
|
48
|
+
* local cloudflared) + that same socket address. Express walks the chain
|
|
49
|
+
* right-to-left and stops at the first address it does not trust, so both
|
|
50
|
+
* paths land on the real client; a fixed number would be wrong for one of
|
|
51
|
+
* them. All the intermediaries are private addresses and the client is not,
|
|
52
|
+
* which is exactly the split this preset encodes.
|
|
35
53
|
*/
|
|
36
54
|
function createBaseApp(options = {}) {
|
|
37
|
-
const { corsOptions, rateLimiter = security_1.standardRateLimiter, parseJson = true, parseCookies = true, tracing = true, metrics = true } = options;
|
|
55
|
+
const { corsOptions, rateLimiter = security_1.standardRateLimiter, parseJson = true, parseCookies = true, tracing = true, metrics = true, trustProxy = 'loopback, linklocal, uniquelocal' } = options;
|
|
38
56
|
const app = (0, express_1.default)();
|
|
57
|
+
app.set('trust proxy', trustProxy);
|
|
39
58
|
app.get('/health/live', healthController_1.healthLive);
|
|
40
59
|
app.use(security_1.securityHeaders);
|
|
41
60
|
if (rateLimiter)
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"createBaseApp.js","sourceRoot":"","sources":["../../src/app/createBaseApp.ts"],"names":[],"mappings":";;;;;
|
|
1
|
+
{"version":3,"file":"createBaseApp.js","sourceRoot":"","sources":["../../src/app/createBaseApp.ts"],"names":[],"mappings":";;;;;AA0EA,sCA2BC;AArGD,sDAA2D;AAC3D,kEAAyC;AACzC,sDAA6D;AAC7D,wCAA+C;AAC/C,+DAAsF;AACtF,iEAA2F;AAC3F,qDAA8E;AAC9E,iEAAwD;AA4BxD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsCG;AACH,SAAgB,aAAa,CAAC,UAAgC,EAAE;IAC9D,MAAM,EACJ,WAAW,EACX,WAAW,GAAG,8BAAmB,EACjC,SAAS,GAAG,IAAI,EAChB,YAAY,GAAG,IAAI,EACnB,OAAO,GAAG,IAAI,EACd,OAAO,GAAG,IAAI,EACd,UAAU,GAAG,kCAAkC,EAChD,GAAG,OAAO,CAAC;IAEZ,MAAM,GAAG,GAAG,IAAA,iBAAO,GAAE,CAAC;IAEtB,GAAG,CAAC,GAAG,CAAC,aAAa,EAAE,UAAU,CAAC,CAAC;IAEnC,GAAG,CAAC,GAAG,CAAC,cAAc,EAAE,6BAAU,CAAC,CAAC;IAEpC,GAAG,CAAC,GAAG,CAAC,0BAAe,CAAC,CAAC;IACzB,IAAI,WAAW;QAAE,GAAG,CAAC,GAAG,CAAC,WAAW,CAAC,CAAC;IACtC,GAAG,CAAC,GAAG,CAAC,mCAAqB,CAAC,CAAC;IAC/B,IAAI,OAAO;QAAE,GAAG,CAAC,GAAG,CAAC,2BAAiB,CAAC,CAAC;IACxC,GAAG,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,wCAAwB,CAAC,CAAC,CAAC,6BAAa,CAAC,CAAC;IAC5D,GAAG,CAAC,GAAG,CAAC,IAAA,qCAAoB,EAAC,WAAW,CAAC,CAAC,CAAC;IAC3C,IAAI,YAAY;QAAE,GAAG,CAAC,GAAG,CAAC,IAAA,uBAAY,GAAE,CAAC,CAAC;IAC1C,IAAI,SAAS;QAAE,GAAG,CAAC,GAAG,CAAC,iBAAO,CAAC,IAAI,EAAE,CAAC,CAAC;IAEvC,OAAO,GAAG,CAAC;AACb,CAAC"}
|
package/package.json
CHANGED
|
@@ -63,6 +63,37 @@ describe('createBaseApp', () => {
|
|
|
63
63
|
expect(res.status).toBe(200);
|
|
64
64
|
});
|
|
65
65
|
|
|
66
|
+
it('trusts the proxy, so X-Forwarded-For identifies the client rather than being ignored', async () => {
|
|
67
|
+
const app = createBaseApp();
|
|
68
|
+
app.get('/whoami', (req, res) => res.json({ ip: req.ip }));
|
|
69
|
+
|
|
70
|
+
const res = await request(app).get('/whoami').set('X-Forwarded-For', '203.0.113.7');
|
|
71
|
+
|
|
72
|
+
expect(res.body.ip).toBe('203.0.113.7');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('keeps rate-limit buckets per client, not one shared bucket behind the proxy', async () => {
|
|
76
|
+
const app = createBaseApp({ rateLimiter: (await import('../middleware/security')).createRateLimiter({ windowMs: 60_000, max: 1 }) });
|
|
77
|
+
app.get('/ping', (_req, res) => res.json({ ok: true }));
|
|
78
|
+
|
|
79
|
+
const clientA = await request(app).get('/ping').set('X-Forwarded-For', '203.0.113.7');
|
|
80
|
+
const clientB = await request(app).get('/ping').set('X-Forwarded-For', '198.51.100.4');
|
|
81
|
+
const clientAAgain = await request(app).get('/ping').set('X-Forwarded-For', '203.0.113.7');
|
|
82
|
+
|
|
83
|
+
expect(clientA.status).toBe(200);
|
|
84
|
+
expect(clientB.status).toBe(200); // a different client is unaffected by A exhausting its window
|
|
85
|
+
expect(clientAAgain.status).toBe(429);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('ignores X-Forwarded-For when trustProxy is disabled', async () => {
|
|
89
|
+
const app = createBaseApp({ trustProxy: false });
|
|
90
|
+
app.get('/whoami', (req, res) => res.json({ ip: req.ip }));
|
|
91
|
+
|
|
92
|
+
const res = await request(app).get('/whoami').set('X-Forwarded-For', '203.0.113.7');
|
|
93
|
+
|
|
94
|
+
expect(res.body.ip).not.toBe('203.0.113.7');
|
|
95
|
+
});
|
|
96
|
+
|
|
66
97
|
it('mounts /health/live ahead of the rate limiter, so it never gets 429s a busy service would', async () => {
|
|
67
98
|
const app = createBaseApp({ rateLimiter: (await import('../middleware/security')).createRateLimiter({ windowMs: 60_000, max: 1 }) });
|
|
68
99
|
app.get('/ping', (_req, res) => res.json({ ok: true }));
|
package/src/app/createBaseApp.ts
CHANGED
|
@@ -25,6 +25,12 @@ export interface CreateBaseAppOptions {
|
|
|
25
25
|
tracing?: boolean;
|
|
26
26
|
/** Use requestLoggerWithMetrics (adds Prometheus histograms) instead of the plain requestLogger. Default true. */
|
|
27
27
|
metrics?: boolean;
|
|
28
|
+
/**
|
|
29
|
+
* Value for Express's `trust proxy`. Defaults to trusting private/loopback
|
|
30
|
+
* addresses — see the note on tumbaland-proxy below. Pass `false` for a
|
|
31
|
+
* service exposed directly to clients with no proxy in front of it.
|
|
32
|
+
*/
|
|
33
|
+
trustProxy?: Parameters<Express['set']>[1];
|
|
28
34
|
}
|
|
29
35
|
|
|
30
36
|
/**
|
|
@@ -47,6 +53,24 @@ export interface CreateBaseAppOptions {
|
|
|
47
53
|
* Services with non-standard body parsing (a webhook needing the raw body,
|
|
48
54
|
* or conditional parsing per-route) should pass `parseJson`/`parseCookies:
|
|
49
55
|
* false` and mount those themselves at the exact point they need to.
|
|
56
|
+
*
|
|
57
|
+
* `trust proxy` is on by default because nothing here is reached directly:
|
|
58
|
+
* every service sits behind tumbaland-proxy, which sets `X-Forwarded-For`.
|
|
59
|
+
* Left at Express's default (`false`) that header is ignored, which
|
|
60
|
+
* express-rate-limit reports as `ERR_ERL_UNEXPECTED_X_FORWARDED_FOR` — and
|
|
61
|
+
* it is right to: the limiter would then key every request in the cluster on
|
|
62
|
+
* the proxy's own container IP, so one busy client could exhaust the window
|
|
63
|
+
* for everyone.
|
|
64
|
+
*
|
|
65
|
+
* The value is `loopback, linklocal, uniquelocal` (private ranges) rather
|
|
66
|
+
* than a hop count, because the chain length differs per ingress path: a
|
|
67
|
+
* direct hit arrives as `client` + the proxy's own socket address, while
|
|
68
|
+
* traffic through the Cloudflare tunnel arrives as `client, 127.0.0.1` (the
|
|
69
|
+
* local cloudflared) + that same socket address. Express walks the chain
|
|
70
|
+
* right-to-left and stops at the first address it does not trust, so both
|
|
71
|
+
* paths land on the real client; a fixed number would be wrong for one of
|
|
72
|
+
* them. All the intermediaries are private addresses and the client is not,
|
|
73
|
+
* which is exactly the split this preset encodes.
|
|
50
74
|
*/
|
|
51
75
|
export function createBaseApp(options: CreateBaseAppOptions = {}): Express {
|
|
52
76
|
const {
|
|
@@ -55,11 +79,14 @@ export function createBaseApp(options: CreateBaseAppOptions = {}): Express {
|
|
|
55
79
|
parseJson = true,
|
|
56
80
|
parseCookies = true,
|
|
57
81
|
tracing = true,
|
|
58
|
-
metrics = true
|
|
82
|
+
metrics = true,
|
|
83
|
+
trustProxy = 'loopback, linklocal, uniquelocal'
|
|
59
84
|
} = options;
|
|
60
85
|
|
|
61
86
|
const app = express();
|
|
62
87
|
|
|
88
|
+
app.set('trust proxy', trustProxy);
|
|
89
|
+
|
|
63
90
|
app.get('/health/live', healthLive);
|
|
64
91
|
|
|
65
92
|
app.use(securityHeaders);
|