From 8b7f9c29d4d960f3571c86c0d76b2c91236a162e Mon Sep 17 00:00:00 2001 From: Marcelo Salloum Date: Wed, 21 Jan 2026 15:06:05 -0800 Subject: [PATCH 1/5] Configure trust proxy via CIDR list --- backend/routes.ts | 13 ++++++++++--- package-lock.json | 12 ++++++++++-- package.json | 1 + 3 files changed, 21 insertions(+), 5 deletions(-) diff --git a/backend/routes.ts b/backend/routes.ts index ecca7e4b..f9f2c03c 100644 --- a/backend/routes.ts +++ b/backend/routes.ts @@ -1,5 +1,6 @@ import express from "express"; import proxy from "express-http-proxy"; +import proxyAddr from "proxy-addr"; import logger from "morgan"; import path from "path"; import rateLimit from "express-rate-limit"; @@ -14,9 +15,15 @@ app.set("json spaces", 2); // Trust proxy to get real client IPs behind proxies/load balancers. const defaultTrustProxy = "loopback,linklocal,uniquelocal"; -const trustProxy = process.env.TRUST_PROXY || defaultTrustProxy; -console.log(`Setting trust proxy to: ${trustProxy}`); -app.set("trust proxy", trustProxy); +const trustProxyCidrs = (process.env.TRUST_PROXY || defaultTrustProxy) + .split(",") + .map((cidr) => cidr.trim()) + .filter(Boolean); + +console.log( + `Setting trust proxy to TRUST_PROXY: ${trustProxyCidrs.join(",")}`, +); +app.set("trust proxy", proxyAddr.compile(trustProxyCidrs)); app.use(logger("combined")); diff --git a/package-lock.json b/package-lock.json index a77ac571..9629aae3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "@google-cloud/bigquery": "^5.10.0", "@stellar/prettier-config": "^1.0.1", "@stellar/stellar-sdk": "^14.4.3", + "@types/proxy-addr": "^2.0.3", "axios": "^1.13.2", "bignumber.js": "^9.0.1", "bourbon": "^7.3.0", @@ -1777,7 +1778,6 @@ "version": "25.0.3", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.3.tgz", "integrity": "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==", - "dev": true, "license": "MIT", "dependencies": { "undici-types": "~7.16.0" @@ -1790,6 +1790,15 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/proxy-addr": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/proxy-addr/-/proxy-addr-2.0.3.tgz", + "integrity": "sha512-TgAHHO4tNG3HgLTUhB+hM4iwW6JUNeQHCLnF1DjaDA9c69PN+IasoFu2MYDhubFc+ZIw5c5t9DMtjvrD6R3Egg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/qs": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", @@ -8915,7 +8924,6 @@ "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", - "dev": true, "license": "MIT" }, "node_modules/unpipe": { diff --git a/package.json b/package.json index 96ffb795..84d931a7 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "@google-cloud/bigquery": "^5.10.0", "@stellar/prettier-config": "^1.0.1", "@stellar/stellar-sdk": "^14.4.3", + "@types/proxy-addr": "^2.0.3", "axios": "^1.13.2", "bignumber.js": "^9.0.1", "bourbon": "^7.3.0", From 1d3064b4e472d5dbc522050fc651f08ac0c35842 Mon Sep 17 00:00:00 2001 From: Marcelo Salloum Date: Wed, 21 Jan 2026 16:36:23 -0800 Subject: [PATCH 2/5] Move @types/proxy-addr to the dev dependencies --- backend/routes.ts | 6 ++---- package-lock.json | 5 ++++- package.json | 2 +- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/backend/routes.ts b/backend/routes.ts index f9f2c03c..0a8cc59a 100644 --- a/backend/routes.ts +++ b/backend/routes.ts @@ -20,9 +20,7 @@ const trustProxyCidrs = (process.env.TRUST_PROXY || defaultTrustProxy) .map((cidr) => cidr.trim()) .filter(Boolean); -console.log( - `Setting trust proxy to TRUST_PROXY: ${trustProxyCidrs.join(",")}`, -); +console.log(`Setting trust proxy to TRUST_PROXY: ${trustProxyCidrs.join(",")}`); app.set("trust proxy", proxyAddr.compile(trustProxyCidrs)); app.use(logger("combined")); @@ -85,7 +83,7 @@ const externalServiceLimiter = createRateLimit( // Apply general rate limiting to all API routes app.use("/api/", generalApiLimiter); -if (process.env.DEV) { +if (process.env.DEV === "true") { // Development: proxy to Vite dev server app.use( "/", diff --git a/package-lock.json b/package-lock.json index 9629aae3..43179b6a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,6 @@ "@google-cloud/bigquery": "^5.10.0", "@stellar/prettier-config": "^1.0.1", "@stellar/stellar-sdk": "^14.4.3", - "@types/proxy-addr": "^2.0.3", "axios": "^1.13.2", "bignumber.js": "^9.0.1", "bourbon": "^7.3.0", @@ -41,6 +40,7 @@ "@types/lodash": "^4.14.178", "@types/mocha": "^9.0.0", "@types/morgan": "^1.9.3", + "@types/proxy-addr": "^2.0.3", "@types/react": "^18.2.0", "@types/react-dom": "^18.2.0", "@typescript-eslint/eslint-plugin": "^5.9.1", @@ -1778,6 +1778,7 @@ "version": "25.0.3", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.3.tgz", "integrity": "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==", + "dev": true, "license": "MIT", "dependencies": { "undici-types": "~7.16.0" @@ -1794,6 +1795,7 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/@types/proxy-addr/-/proxy-addr-2.0.3.tgz", "integrity": "sha512-TgAHHO4tNG3HgLTUhB+hM4iwW6JUNeQHCLnF1DjaDA9c69PN+IasoFu2MYDhubFc+ZIw5c5t9DMtjvrD6R3Egg==", + "dev": true, "license": "MIT", "dependencies": { "@types/node": "*" @@ -8924,6 +8926,7 @@ "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, "license": "MIT" }, "node_modules/unpipe": { diff --git a/package.json b/package.json index 84d931a7..b0b055d1 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,6 @@ "@google-cloud/bigquery": "^5.10.0", "@stellar/prettier-config": "^1.0.1", "@stellar/stellar-sdk": "^14.4.3", - "@types/proxy-addr": "^2.0.3", "axios": "^1.13.2", "bignumber.js": "^9.0.1", "bourbon": "^7.3.0", @@ -64,6 +63,7 @@ "@types/lodash": "^4.14.178", "@types/mocha": "^9.0.0", "@types/morgan": "^1.9.3", + "@types/proxy-addr": "^2.0.3", "@types/react": "^18.2.0", "@types/react-dom": "^18.2.0", "@typescript-eslint/eslint-plugin": "^5.9.1", From b677381c8206d8595bf16b0ca89386d184400cba Mon Sep 17 00:00:00 2001 From: Marcelo Salloum Date: Thu, 22 Jan 2026 11:21:23 -0800 Subject: [PATCH 3/5] Add tests for trust proxy --- .eslintrc.js | 8 ++ backend/routes.ts | 22 ++-- test/tests/unit/trust-proxy.ts | 231 +++++++++++++++++++++++++++++++++ 3 files changed, 253 insertions(+), 8 deletions(-) create mode 100644 test/tests/unit/trust-proxy.ts diff --git a/.eslintrc.js b/.eslintrc.js index e718e3e0..7323e500 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -21,4 +21,12 @@ module.exports = { eqeqeq: "warn", }, ignorePatterns: ["node_modules/", "dist/", "*.min.js"], + overrides: [ + { + files: ["test/**/*.ts"], + env: { + mocha: true, + }, + }, + ], }; diff --git a/backend/routes.ts b/backend/routes.ts index 0a8cc59a..5a033551 100644 --- a/backend/routes.ts +++ b/backend/routes.ts @@ -14,12 +14,15 @@ app.set("port", process.env.PORT || 5000); app.set("json spaces", 2); // Trust proxy to get real client IPs behind proxies/load balancers. -const defaultTrustProxy = "loopback,linklocal,uniquelocal"; -const trustProxyCidrs = (process.env.TRUST_PROXY || defaultTrustProxy) - .split(",") - .map((cidr) => cidr.trim()) - .filter(Boolean); +export function parseTrustProxy(trustProxyEnv?: string): string[] { + const defaultTrustProxy = "loopback,linklocal,uniquelocal"; + return (trustProxyEnv || defaultTrustProxy) + .split(",") + .map((cidr) => cidr.trim()) + .filter(Boolean); +} +const trustProxyCidrs = parseTrustProxy(process.env.TRUST_PROXY); console.log(`Setting trust proxy to TRUST_PROXY: ${trustProxyCidrs.join(",")}`); app.set("trust proxy", proxyAddr.compile(trustProxyCidrs)); @@ -230,9 +233,12 @@ app.get( lumensV2V3.v3CirculatingSupplyHandler, ); -app.listen(app.get("port"), () => { - console.log("Listening on port", app.get("port")); -}); +// Start listening only when this file is executed directly (not when required by tests) +if (require.main === module && process.env.NODE_ENV !== "test") { + app.listen(app.get("port"), () => { + console.log("Listening on port", app.get("port")); + }); +} export async function updateLumensCache() { await lumens.updateApiLumens(); diff --git a/test/tests/unit/trust-proxy.ts b/test/tests/unit/trust-proxy.ts new file mode 100644 index 00000000..f849bde9 --- /dev/null +++ b/test/tests/unit/trust-proxy.ts @@ -0,0 +1,231 @@ +import chai from "chai"; +import request from "supertest"; +import express from "express"; +import proxyAddr from "proxy-addr"; +import { parseTrustProxy } from "../../../backend/routes"; + +describe("Trust Proxy Configuration", function () { + describe("TRUST_PROXY parsing", function () { + it("🟢should_use_default_values_when_TRUST_PROXY_not_set", function () { + const trustProxyCidrs = parseTrustProxy(undefined); + + chai.expect(trustProxyCidrs).to.deep.equal([ + "loopback", + "linklocal", + "uniquelocal", + ]); + }); + + it("🟢should_parse_comma_separated_CIDR_values", function () { + const trustProxyCidrs = parseTrustProxy("10.0.0.0/8,172.16.0.0/12,192.168.0.0/16"); + + chai.expect(trustProxyCidrs).to.deep.equal([ + "10.0.0.0/8", + "172.16.0.0/12", + "192.168.0.0/16", + ]); + }); + + it("🟢should_parse_named_tokens_loopback_linklocal_uniquelocal", function () { + const trustProxyCidrs = parseTrustProxy("loopback,linklocal,uniquelocal"); + + chai.expect(trustProxyCidrs).to.deep.equal([ + "loopback", + "linklocal", + "uniquelocal", + ]); + }); + + it("🟢should_handle_mixed_CIDR_and_named_tokens", function () { + const trustProxyCidrs = parseTrustProxy("loopback,10.0.0.0/8,linklocal"); + + chai.expect(trustProxyCidrs).to.deep.equal([ + "loopback", + "10.0.0.0/8", + "linklocal", + ]); + }); + + it("🟢should_trim_whitespace_from_values", function () { + const trustProxyCidrs = parseTrustProxy(" loopback , linklocal , uniquelocal "); + + chai.expect(trustProxyCidrs).to.deep.equal([ + "loopback", + "linklocal", + "uniquelocal", + ]); + }); + + it("🟢should_filter_empty_values", function () { + const trustProxyCidrs = parseTrustProxy("loopback,,linklocal,,,uniquelocal"); + + chai.expect(trustProxyCidrs).to.deep.equal([ + "loopback", + "linklocal", + "uniquelocal", + ]); + }); + + it("🟢should_handle_single_value", function () { + const trustProxyCidrs = parseTrustProxy("10.0.0.0/8"); + + chai.expect(trustProxyCidrs).to.deep.equal(["10.0.0.0/8"]); + }); + }); + + describe("proxyAddr.compile compatibility", function () { + it("🟢should_compile_default_trust_values_and_trust_loopback", function () { + const trustProxyCidrs = ["loopback", "linklocal", "uniquelocal"]; + const compiled = proxyAddr.compile(trustProxyCidrs); + + // Verify it's callable and trusts loopback (127.0.0.1) + chai.expect(compiled).to.be.a("function"); + chai.expect(compiled("127.0.0.1", 0)).to.be.true; + }); + + it("🟢should_compile_CIDR_notation_and_trust_private_ranges", function () { + const trustProxyCidrs = ["10.0.0.0/8", "172.16.0.0/12"]; + const compiled = proxyAddr.compile(trustProxyCidrs); + + chai.expect(compiled).to.be.a("function"); + // Verify it trusts IPs in the specified CIDR ranges + chai.expect(compiled("10.0.0.1", 0)).to.be.true; + chai.expect(compiled("172.16.0.1", 0)).to.be.true; + chai.expect(compiled("192.168.1.1", 0)).to.be.false; + }); + + it("🟢should_compile_mixed_values_and_trust_both_named_and_CIDR", function () { + const trustProxyCidrs = ["loopback", "10.0.0.0/8", "linklocal"]; + const compiled = proxyAddr.compile(trustProxyCidrs); + + chai.expect(compiled).to.be.a("function"); + // Verify it trusts both loopback and the CIDR range + chai.expect(compiled("127.0.0.1", 0)).to.be.true; + chai.expect(compiled("10.1.2.3", 0)).to.be.true; + }); + }); + + describe("Client IP resolution with trust proxy", function () { + it("🟢should_resolve_client_IP_when_trusting_loopback", async function () { + const app = express(); + app.set("trust proxy", proxyAddr.compile(["loopback"])); + + app.get("/test-ip", (req, res) => { + res.json({ ip: req.ip }); + }); + + const response = await request(app) + .get("/test-ip") + .set("X-Forwarded-For", "192.168.1.1, 203.0.113.1") + .expect(200); + + // When trusting loopback, should use X-Forwarded-For + chai.expect(response.body.ip).to.equal("203.0.113.1"); + }); + + it("🟢should_not_trust_unknown_proxy", async function () { + const app = express(); + // Trust only specific CIDR that doesn't include the test client + app.set("trust proxy", proxyAddr.compile(["10.0.0.0/8"])); + + app.get("/test-ip", (req, res) => { + res.json({ ip: req.ip }); + }); + + const response = await request(app) + .get("/test-ip") + .set("X-Forwarded-For", "203.0.113.1") + .expect(200); + + // Should not use X-Forwarded-For from untrusted proxy + chai.expect(response.body.ip).to.not.equal("203.0.113.1"); + }); + + it("🟢should_extract_client_IP_from_X_Forwarded_For_with_trusted_proxy", async function () { + const app = express(); + app.set("trust proxy", proxyAddr.compile(["loopback", "linklocal"])); + + app.get("/test-ip", (req, res) => { + res.json({ ip: req.ip, ips: req.ips }); + }); + + const response = await request(app) + .get("/test-ip") + .set("X-Forwarded-For", "203.0.113.1, 198.51.100.1, 192.0.2.1") + .expect(200); + + chai.expect(response.body.ip).to.equal("192.0.2.1"); + chai.expect(response.body.ips).to.deep.equal(["192.0.2.1"]); + }); + + it("🟢should_use_req_connection_remoteAddress_when_no_X_Forwarded_For", async function () { + const app = express(); + app.set("trust proxy", proxyAddr.compile(["loopback"])); + + app.get("/test-ip", (req, res) => { + res.json({ ip: req.ip }); + }); + + const response = await request(app).get("/test-ip").expect(200); + + // Should fall back to connection remote address (loopback) + chai.expect(response.body.ip).to.match(/^::ffff:127\.|^127\.|^::1$/); + }); + }); + + describe("Security edge cases", function () { + it("🔴should_not_trust_X_Forwarded_For_with_empty_trust_proxy", async function () { + const app = express(); + // No trust proxy set - should not trust X-Forwarded-For + app.set("trust proxy", false); + + app.get("/test-ip", (req, res) => { + res.json({ ip: req.ip }); + }); + + const response = await request(app) + .get("/test-ip") + .set("X-Forwarded-For", "203.0.113.1") + .expect(200); + + // Should use connection IP, not X-Forwarded-For + chai.expect(response.body.ip).to.not.equal("203.0.113.1"); + chai.expect(response.body.ip).to.match(/^::ffff:|^127\.|^::/); + }); + + it("🔴should_handle_malformed_X_Forwarded_For_gracefully", async function () { + const app = express(); + app.set("trust proxy", proxyAddr.compile(["loopback"])); + + app.get("/test-ip", (req, res) => { + res.json({ ip: req.ip }); + }); + + const response = await request(app) + .get("/test-ip") + .set("X-Forwarded-For", "not-an-ip, also-not-an-ip") + .expect(200); + + // Express parses any comma-separated values, even if not valid IPs + // It extracts "also-not-an-ip" as the rightmost value + chai.expect(response.body.ip).to.equal("also-not-an-ip"); + }); + + it("🟡should_handle_IPv6_addresses", async function () { + const app = express(); + app.set("trust proxy", proxyAddr.compile(["loopback", "uniquelocal"])); + + app.get("/test-ip", (req, res) => { + res.json({ ip: req.ip }); + }); + + const response = await request(app) + .get("/test-ip") + .set("X-Forwarded-For", "2001:db8::1") + .expect(200); + + // Should extract the IPv6 address from X-Forwarded-For + chai.expect(response.body.ip).to.equal("2001:db8::1"); + }); + }); +}); From 9dffaccbfa582fed09d7d8b9cfa27f54f492e258 Mon Sep 17 00:00:00 2001 From: Marcelo Salloum Date: Thu, 22 Jan 2026 11:28:35 -0800 Subject: [PATCH 4/5] Install missing package --- package-lock.json | 39 +++++++++++++++++++++++++++++++++++++++ package.json | 1 + 2 files changed, 40 insertions(+) diff --git a/package-lock.json b/package-lock.json index 43179b6a..7d42aacb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -43,6 +43,7 @@ "@types/proxy-addr": "^2.0.3", "@types/react": "^18.2.0", "@types/react-dom": "^18.2.0", + "@types/supertest": "^6.0.2", "@typescript-eslint/eslint-plugin": "^5.9.1", "@typescript-eslint/parser": "^5.9.1", "@vitejs/plugin-react": "^4.0.0", @@ -1684,6 +1685,13 @@ "@types/node": "*" } }, + "node_modules/@types/cookiejar": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz", + "integrity": "sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -1757,6 +1765,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/methods": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz", + "integrity": "sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/mocha": { "version": "9.1.1", "resolved": "https://registry.npmjs.org/@types/mocha/-/mocha-9.1.1.tgz", @@ -1854,6 +1869,30 @@ "@types/node": "*" } }, + "node_modules/@types/superagent": { + "version": "8.1.9", + "resolved": "https://registry.npmjs.org/@types/superagent/-/superagent-8.1.9.tgz", + "integrity": "sha512-pTVjI73witn+9ILmoJdajHGW2jkSaOzhiFYF1Rd3EQ94kymLqB9PjD9ISg7WaALC7+dCHT0FGe9T2LktLq/3GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/cookiejar": "^2.1.5", + "@types/methods": "^1.1.4", + "@types/node": "*", + "form-data": "^4.0.0" + } + }, + "node_modules/@types/supertest": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@types/supertest/-/supertest-6.0.3.tgz", + "integrity": "sha512-8WzXq62EXFhJ7QsH3Ocb/iKQ/Ty9ZVWnVzoTKc9tyyFRRF3a74Tk2+TLFgaFFw364Ere+npzHKEJ6ga2LzIL7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/methods": "^1.1.4", + "@types/superagent": "^8.1.0" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "5.62.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.62.0.tgz", diff --git a/package.json b/package.json index b0b055d1..54a838a2 100644 --- a/package.json +++ b/package.json @@ -66,6 +66,7 @@ "@types/proxy-addr": "^2.0.3", "@types/react": "^18.2.0", "@types/react-dom": "^18.2.0", + "@types/supertest": "^6.0.2", "@typescript-eslint/eslint-plugin": "^5.9.1", "@typescript-eslint/parser": "^5.9.1", "@vitejs/plugin-react": "^4.0.0", From 708cf22b90ff944b8898851efa3040a902c3448d Mon Sep 17 00:00:00 2001 From: Marcelo Salloum Date: Thu, 22 Jan 2026 15:26:47 -0800 Subject: [PATCH 5/5] Fix docker setup --- backend/app.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/backend/app.ts b/backend/app.ts index 18c4e095..c3a53dc9 100644 --- a/backend/app.ts +++ b/backend/app.ts @@ -1,7 +1,7 @@ import "dotenv/config"; // Run backend with cache updates. -import { updateLumensCache } from "./routes"; +import { app, updateLumensCache } from "./routes"; import { updateLedgers } from "./ledgers"; async function beginCacheUpdates() { @@ -15,4 +15,15 @@ async function beginCacheUpdates() { } } +function startServer() { + if (process.env.NODE_ENV === "test") { + return; + } + + app.listen(app.get("port"), () => { + console.log("Listening on port", app.get("port")); + }); +} + beginCacheUpdates(); +startServer();