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/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(); diff --git a/backend/routes.ts b/backend/routes.ts index ecca7e4b..5a033551 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"; @@ -13,10 +14,17 @@ 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 trustProxy = process.env.TRUST_PROXY || defaultTrustProxy; -console.log(`Setting trust proxy to: ${trustProxy}`); -app.set("trust proxy", trustProxy); +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)); app.use(logger("combined")); @@ -78,7 +86,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( "/", @@ -225,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/package-lock.json b/package-lock.json index a77ac571..7d42aacb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -40,8 +40,10 @@ "@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", + "@types/supertest": "^6.0.2", "@typescript-eslint/eslint-plugin": "^5.9.1", "@typescript-eslint/parser": "^5.9.1", "@vitejs/plugin-react": "^4.0.0", @@ -1683,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", @@ -1756,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", @@ -1790,6 +1806,16 @@ "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==", + "dev": true, + "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", @@ -1843,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 96ffb795..54a838a2 100644 --- a/package.json +++ b/package.json @@ -63,8 +63,10 @@ "@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", + "@types/supertest": "^6.0.2", "@typescript-eslint/eslint-plugin": "^5.9.1", "@typescript-eslint/parser": "^5.9.1", "@vitejs/plugin-react": "^4.0.0", 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"); + }); + }); +});