diff --git a/.github/workflows/security-audit.yml b/.github/workflows/security-audit.yml index f1e3de0..f002d22 100644 --- a/.github/workflows/security-audit.yml +++ b/.github/workflows/security-audit.yml @@ -47,7 +47,10 @@ jobs: uses: actions/checkout@v4 - name: Run Rust Security Audit Check - uses: rustsec/audit-check-action@v2.0.0 + uses: taiki-e/install-action@v2 with: - token: ${{ secrets.GITHUB_TOKEN }} - working-directory: ./contracts/soroban + tool: cargo-audit + + - name: Audit Rust dependencies + working-directory: ./contracts/soroban + run: cargo audit diff --git a/backend/jest.config.js b/backend/jest.config.js index e20ad0b..877542d 100644 --- a/backend/jest.config.js +++ b/backend/jest.config.js @@ -1,5 +1,6 @@ module.exports = { testEnvironment: 'node', + setupFiles: ['/tests/setup.js'], coverageDirectory: 'coverage', collectCoverageFrom: [ '**/*.js', diff --git a/backend/middleware/geoBlock.js b/backend/middleware/geoBlock.js new file mode 100644 index 0000000..9e2f122 --- /dev/null +++ b/backend/middleware/geoBlock.js @@ -0,0 +1,23 @@ +const blockedCountries = () => new Set( + String(process.env.BLOCKED_COUNTRIES || '') + .split(',').map((country) => country.trim().toUpperCase()).filter(Boolean), +); + +function clientIp(req) { + const forwarded = req.headers['x-forwarded-for']; + return String(forwarded || req.headers['cf-connecting-ip'] || req.ip || '').split(',')[0].trim(); +} + +function geoBlock(req, res, next) { + const country = String( + req.headers['cf-ipcountry'] || req.headers['x-vercel-ip-country'] || req.headers['x-country-code'] || '', + ).toUpperCase(); + if (country && blockedCountries().has(country)) { + return res.status(451).json({ error: 'This region is not permitted to access wagered gameplay.' }); + } + req.clientIp = clientIp(req); + req.clientCountry = country || null; + return next(); +} + +module.exports = { geoBlock, clientIp }; diff --git a/backend/package-lock.json b/backend/package-lock.json index fdcad95..df9157e 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -18,6 +18,7 @@ "helmet": "^8.0.0", "jsonwebtoken": "^9.0.3", "pg": "^8.23.0", + "prom-client": "^15.1.3", "socket.io": "^4.8.3", "stellar-sdk": "^13.3.0" }, @@ -1062,6 +1063,15 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@opentelemetry/api": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", + "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==", + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/@paralleldrive/cuid2": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", @@ -2166,6 +2176,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/bintrees": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bintrees/-/bintrees-1.0.2.tgz", + "integrity": "sha512-VOMgTMwjAaUG580SXn3LacVgjurrbMme7ZZNYGSSV7mmtY6QQRh0Eg3pwIcntQ77DErK1L0NxkbetjcoXzVwKw==", + "license": "MIT" + }, "node_modules/body-parser": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", @@ -5592,6 +5608,20 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/prom-client": { + "version": "15.1.3", + "resolved": "https://registry.npmjs.org/prom-client/-/prom-client-15.1.3.tgz", + "integrity": "sha512-6ZiOBfCywsD4k1BN9IX0uZhF+tJkV8q8llP64G5Hajs4JOeVLPCwpPVcpXy3BwYiUGgyJzsJJQeOIv7+hDSq8g==", + "deprecated": "prom-client has been replaced by @prometheus-io/client", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api": "^1.4.0", + "tdigest": "^0.1.1" + }, + "engines": { + "node": "^16 || ^18 || >=20" + } + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -6479,6 +6509,15 @@ "url": "https://opencollective.com/synckit" } }, + "node_modules/tdigest": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/tdigest/-/tdigest-0.1.3.tgz", + "integrity": "sha512-zbRt+lT+/H4fRItHshczHErVCQnitJk8MfMT24MqFJf3YL7SJJPqGIGeuOdvxXxM/AHFzKBl7WoyaYwqO9s3Kw==", + "license": "MIT", + "dependencies": { + "bintrees": "1.0.2" + } + }, "node_modules/test-exclude": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", diff --git a/backend/package.json b/backend/package.json index c9daaed..2357ad6 100644 --- a/backend/package.json +++ b/backend/package.json @@ -24,11 +24,12 @@ "@supabase/supabase-js": "^2.95.3", "axios": "^1.6.0", "cors": "^2.8.5", - "helmet": "^8.0.0", "dotenv": "^16.4.5", "express": "^5.2.1", + "helmet": "^8.0.0", "jsonwebtoken": "^9.0.3", "pg": "^8.23.0", + "prom-client": "^15.1.3", "socket.io": "^4.8.3", "stellar-sdk": "^13.3.0" }, diff --git a/backend/server.js b/backend/server.js index e53f0d9..195a48b 100644 --- a/backend/server.js +++ b/backend/server.js @@ -18,6 +18,7 @@ const cronService = require("./services/cronService"); const supabase = require("./config/supabase"); const logger = require("./utils/logger"); const { errorHandler, installGlobalHandlers } = require("./middleware/errorHandler"); +const { geoBlock } = require("./middleware/geoBlock"); const { createRateLimiter } = require("./middleware/rateLimiter"); const { sanitizeInput } = require("./middleware/sanitizeInput"); @@ -67,6 +68,7 @@ app.use( }), ); app.use(express.json()); +app.use(geoBlock); app.use(createRateLimiter({ windowMs: 60_000, max: 100 })); app.use(sanitizeInput); @@ -215,4 +217,4 @@ cronService.start(); server.listen(PORT, "0.0.0.0", () => { console.log(`Chesster backend running on port ${PORT}`); -}); \ No newline at end of file +}); diff --git a/backend/tests/escrowService.horizon.test.js b/backend/tests/escrowService.horizon.test.js index 687e0f9..427b7a2 100644 --- a/backend/tests/escrowService.horizon.test.js +++ b/backend/tests/escrowService.horizon.test.js @@ -2,6 +2,10 @@ * Tests for Stellar Horizon Transaction Indexer */ +jest.mock("@stellar/stellar-sdk", () => ({ + Networks: { TESTNET: "Test SDF Network ; September 2015" }, +})); + const escrowService = require("../services/escrowService"); jest.mock("axios"); diff --git a/backend/tests/healthRoutes.test.js b/backend/tests/healthRoutes.test.js index 1b01562..ca225f6 100644 --- a/backend/tests/healthRoutes.test.js +++ b/backend/tests/healthRoutes.test.js @@ -4,12 +4,25 @@ const request = require("supertest"); const express = require("express"); + +// Keep route tests independent of the SDK's ESM-only transitive hash module. +jest.mock("@stellar/stellar-sdk", () => ({ + Networks: { TESTNET: "Test SDF Network ; September 2015" }, + rpc: { Server: jest.fn(() => ({ getLatestLedger: jest.fn().mockResolvedValue({ sequence: 1 }) })) }, +})); + const healthRoutes = require("../routes/healthRoutes"); describe("Health Check Routes", () => { let app; beforeEach(() => { + // Other suites exercise missing configuration and may mutate process.env. + // Restore the route test's safe configuration for every test in this file. + process.env.SUPABASE_URL = "http://localhost"; + process.env.SUPABASE_ANON_KEY = "test-anon-key"; + process.env.SUPABASE_KEY = "test-service-key"; + process.env.SOROBAN_RPC_URL = "http://localhost"; app = express(); app.use(express.json()); app.use("/api", healthRoutes); diff --git a/backend/tests/setup.js b/backend/tests/setup.js new file mode 100644 index 0000000..ca6b1de --- /dev/null +++ b/backend/tests/setup.js @@ -0,0 +1,5 @@ +// Keep tests hermetic: production configuration remains fail-closed, while +// modules imported by isolated route/service tests get safe placeholders. +process.env.SUPABASE_URL ||= 'http://localhost'; +process.env.SUPABASE_ANON_KEY ||= 'test-anon-key'; +process.env.SUPABASE_KEY ||= 'test-service-key';