@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;CACnB;AAED;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAgB,aAAa,CAAC,OAAO,GAAE,oBAAyB,GAAG,OAAO,CAwBzE"}
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":";;;;;AAkDA,sCAwBC;AA1ED,sDAA2D;AAC3D,kEAAyC;AACzC,sDAA6D;AAC7D,wCAA+C;AAC/C,+DAAsF;AACtF,iEAA2F;AAC3F,qDAA8E;AAC9E,iEAAwD;AAsBxD;;;;;;;;;;;;;;;;;;;;GAoBG;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,EACf,GAAG,OAAO,CAAC;IAEZ,MAAM,GAAG,GAAG,IAAA,iBAAO,GAAE,CAAC;IAEtB,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"}
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tumbaland/backend-core",
3
- "version": "1.24.0",
3
+ "version": "1.25.0",
4
4
  "description": "Core shared functionality for Tumbaland backend services",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -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 }));
@@ -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);