From f45b03baf6454cebeca9df29e83251133eca6bfa Mon Sep 17 00:00:00 2001 From: codeX-james Date: Sat, 27 Jun 2026 12:31:36 +0100 Subject: [PATCH] fix: resolve issues #1115, #1116, #1117, #1118 Closes #1115 Closes #1116 Closes #1117 Closes #1118 - Frontend Dockerfile: use production-only deps, add non-root user, add HEALTHCHECK - Oracle tests: add MockRateOracle contract and exercise the oracle interest-rate code path including in-bounds, below-min, above-max, get/set, and event emission - ENVIRONMENT.md: document ADMIN_WALLETS, LENDER_WALLETS, EXPOSE_STACK_TRACES, JWT_COOKIE_NAME; add them to .env.example for CI drift check - webhooks.md: fix signature docs to match actual sha256= header format, correct secret source to per-subscription secret, align with webhook-signatures.md --- backend/.env.example | 11 ++++ contracts/loan_manager/src/test.rs | 99 +++++++++++++++++++++++++++--- docs/ENVIRONMENT.md | 4 ++ docs/webhooks.md | 42 ++++++------- docs/wiki/webhook-signatures.md | 5 +- frontend/Dockerfile | 14 ++++- 6 files changed, 140 insertions(+), 35 deletions(-) diff --git a/backend/.env.example b/backend/.env.example index 22ccfbe6..3e3dc38b 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -66,8 +66,19 @@ SCORE_RECONCILIATION_AUTOCORRECT_THRESHOLD=50 # Authentication JWT_SECRET=your-super-secret-jwt-key-change-in-production +JWT_COOKIE_NAME=remitlend_jwt INTERNAL_API_KEY=change-me +# RBAC — comma-separated Stellar public keys (G...) +# Wallets listed in ADMIN_WALLETS receive the admin role (admin:all scope). +# Wallets listed in LENDER_WALLETS receive the lender role. +# Any unlisted wallet defaults to borrower. +ADMIN_WALLETS= +LENDER_WALLETS= + +# Debugging (never enable in production) +EXPOSE_STACK_TRACES=false + # Webhooks WEBHOOK_REQUEST_TIMEOUT_MS=30000 diff --git a/contracts/loan_manager/src/test.rs b/contracts/loan_manager/src/test.rs index 70653be0..1dc12302 100644 --- a/contracts/loan_manager/src/test.rs +++ b/contracts/loan_manager/src/test.rs @@ -3,7 +3,22 @@ use lending_pool::{LendingPool, LendingPoolClient}; use remittance_nft::{RemittanceNFT, RemittanceNFTClient}; use soroban_sdk::testutils::{Events, Ledger as _}; use soroban_sdk::token::{Client as TokenClient, StellarAssetClient}; -use soroban_sdk::{testutils::Address as _, Address, BytesN, Env, FromVal, String}; +use soroban_sdk::{contract, contractimpl, testutils::Address as _, Address, BytesN, Env, FromVal, String}; + +// Mock RateOracle contract for testing the oracle interest-rate code path. +#[contract] +pub struct MockRateOracle; + +#[contractimpl] +impl MockRateOracle { + pub fn get_rate(_env: Env, _borrower: Address, _amount: i128, _score: u32) -> u32 { + _env.storage().instance().get(&"rate").unwrap_or(1200) + } + + pub fn set_rate(env: Env, rate: u32) { + env.storage().instance().set(&"rate", &rate); + } +} fn setup_test<'a>( env: &Env, @@ -2291,12 +2306,20 @@ fn test_oracle_rate_within_bounds_accepted() { let stellar_token = StellarAssetClient::new(&env, &token_id); stellar_token.mint(&pool_client, &10_000); - // Request loan - should use default rate since no oracle is set + // Deploy mock oracle returning 800 BPS (within default bounds 1..100_000) + let oracle_id = env.register(MockRateOracle, ()); + let oracle_client = MockRateOracleClient::new(&env, &oracle_id); + oracle_client.set_rate(&800); + + // Set the oracle on the loan manager + manager.set_rate_oracle(&oracle_id); + + // Request loan — the oracle branch should be taken let loan_id = manager.request_loan(&borrower, &1000, &17280); let loan = manager.get_loan(&loan_id); - // Default rate should be 1200 BPS (12%) - assert_eq!(loan.interest_rate_bps, 1200); + // Should use the oracle rate (800 BPS), not the default (1200 BPS) + assert_eq!(loan.interest_rate_bps, 800); } #[test] @@ -2456,14 +2479,19 @@ fn test_oracle_rate_below_min_falls_back_to_default() { let stellar_token = StellarAssetClient::new(&env, &token_id); stellar_token.mint(&pool_client, &10_000); - // Set min rate to 500 BPS + // Deploy mock oracle returning 100 BPS (below the min we will set) + let oracle_id = env.register(MockRateOracle, ()); + let oracle_client = MockRateOracleClient::new(&env, &oracle_id); + oracle_client.set_rate(&100); + + manager.set_rate_oracle(&oracle_id); manager.set_min_rate_bps(&500); - // Request loan - should use default rate (1200) since no oracle is set + // Request loan — oracle returns 100 which is below min_rate_bps=500 let loan_id = manager.request_loan(&borrower, &1000, &17280); let loan = manager.get_loan(&loan_id); - // Should use default rate (1200 BPS) which is within bounds + // Should fall back to default rate (1200 BPS) assert_eq!(loan.interest_rate_bps, 1200); } @@ -2487,14 +2515,19 @@ fn test_oracle_rate_above_max_falls_back_to_default() { let stellar_token = StellarAssetClient::new(&env, &token_id); stellar_token.mint(&pool_client, &10_000); - // Set max rate to 2000 BPS (20%) + // Deploy mock oracle returning 5000 BPS (above the max we will set) + let oracle_id = env.register(MockRateOracle, ()); + let oracle_client = MockRateOracleClient::new(&env, &oracle_id); + oracle_client.set_rate(&5000); + + manager.set_rate_oracle(&oracle_id); manager.set_max_rate_bps(&2_000); - // Request loan - should use default rate (1200) since no oracle is set + // Request loan — oracle returns 5000 which is above max_rate_bps=2000 let loan_id = manager.request_loan(&borrower, &1000, &17280); let loan = manager.get_loan(&loan_id); - // Should use default rate (1200 BPS) which is within bounds + // Should fall back to default rate (1200 BPS) assert_eq!(loan.interest_rate_bps, 1200); } @@ -3588,3 +3621,49 @@ fn test_get_loan_health_matches_liquidation_state() { assert_eq!(pending_debt, 0); assert_eq!(pending_ratio, 0); } + +// ── Oracle get/set and event tests ────────────────────────────────────────── + +#[test] +fn test_get_rate_oracle_returns_set_address() { + let env = Env::default(); + env.mock_all_auths_allowing_non_root_auth(); + + let (manager, _nft_client, _pool_client, _token_id, _admin) = setup_test(&env); + + // Initially no oracle is set + assert_eq!(manager.get_rate_oracle(), None); + + // Deploy and set a mock oracle + let oracle_id = env.register(MockRateOracle, ()); + manager.set_rate_oracle(&oracle_id); + + // get_rate_oracle should return the address we just set + assert_eq!(manager.get_rate_oracle(), Some(oracle_id)); +} + +#[test] +fn test_set_rate_oracle_emits_rate_oracle_updated_event() { + let env = Env::default(); + env.mock_all_auths_allowing_non_root_auth(); + + let (manager, _nft_client, _pool_client, _token_id, _admin) = setup_test(&env); + + let oracle_id = env.register(MockRateOracle, ()); + manager.set_rate_oracle(&oracle_id); + + let events = env.events().all(); + let has_oracle_event = events.iter().any(|(_contract_id, topics, _data)| { + topics.len() == 1 + && topics + .get(0) + .map(|t| { + t == soroban_sdk::Val::from_val( + &env, + &soroban_sdk::Symbol::new(&env, "RateOracleUpdated"), + ) + }) + .unwrap_or(false) + }); + assert!(has_oracle_event, "RateOracleUpdated event should be emitted"); +} diff --git a/docs/ENVIRONMENT.md b/docs/ENVIRONMENT.md index 629d2976..81595a6a 100644 --- a/docs/ENVIRONMENT.md +++ b/docs/ENVIRONMENT.md @@ -49,6 +49,10 @@ This document lists every environment variable used by the RemitLend platform. E | `SCORE_RECONCILIATION_AUTOCORRECT_THRESHOLD` | ✓ | ✓ | ✓ | `50` | Max points auto-corrected per run | `backend/src/config/scores.ts` | | `JWT_SECRET` | ✓ | ✓ | ✓ | `your-super-secret-jwt-key-change-in-production` | JWT signing/verification secret | `backend/src/middleware/jwtAuth.ts` | | `INTERNAL_API_KEY` | ✓ | ✓ | ✓ | `change-me` | API key for internal endpoints | `backend/src/middleware/auth.ts` | +| `ADMIN_WALLETS` | ✓ | ✓ | ✓ | — | Comma-separated Stellar public keys granted the `admin` role (`admin:all` scope). **Security-critical**: any wallet listed here receives full admin privileges. Unlisted wallets default to `borrower`. | `backend/src/auth/rbac.ts` | +| `LENDER_WALLETS` | ✓ | ✓ | ✓ | — | Comma-separated Stellar public keys granted the `lender` role (`read:loans`, `read:pool` scopes). Unlisted wallets default to `borrower`. | `backend/src/auth/rbac.ts` | +| `EXPOSE_STACK_TRACES` | — | — | — | `false` | When `"true"`, include stack traces in error responses. **Never enable in production.** | `backend/src/middleware/errorHandler.ts` | +| `JWT_COOKIE_NAME` | ✓ | ✓ | ✓ | `remitlend_jwt` | Name of the HTTP cookie used to transport the JWT token | `backend/src/middleware/jwtAuth.ts` | | `WEBHOOK_REQUEST_TIMEOUT_MS` | ✓ | ✓ | ✓ | `30000` | Outgoing webhook request timeout | `backend/src/services/webhookService.ts` | | `SENTRY_DSN` | — | ✓ | ✓ | — | Sentry DSN for backend error tracking | `backend/src/app.ts` | | `NOTIFICATION_RETENTION_DAYS` | ✓ | ✓ | ✓ | `90` | Days to keep unread notifications | `backend/src/services/notificationService.ts` | diff --git a/docs/webhooks.md b/docs/webhooks.md index 7bfa8428..ea38c553 100644 --- a/docs/webhooks.md +++ b/docs/webhooks.md @@ -232,15 +232,15 @@ While deactivated: ## Verifying HMAC Signatures Each delivery includes an `X-RemitLend-Signature` header containing an -HMAC-SHA256 signature of the raw request body. +HMAC-SHA256 signature of the **raw request body**. **Header format:** ``` -X-RemitLend-Signature: t=1745827200,v1=abc123def456... +X-RemitLend-Signature: sha256= ``` -- `t` — Unix timestamp of when the signature was generated -- `v1` — Hexadecimal HMAC-SHA256 digest +The value is `sha256=` followed by the lowercase hex-encoded HMAC-SHA256 +digest computed over the raw request body (no timestamp prefix). ### Verification snippet (Node.js) @@ -248,23 +248,18 @@ X-RemitLend-Signature: t=1745827200,v1=abc123def456... import { createHmac, timingSafeEqual } from "node:crypto"; function verifyWebhookSignature( - body: string, + rawBody: string, signatureHeader: string, secret: string, ): boolean { - const parts = signatureHeader.split(","); - const timestamp = parts.find((p) => p.startsWith("t="))?.slice(2); - const digest = parts.find((p) => p.startsWith("v1="))?.slice(3); - - if (!timestamp || !digest) return false; - - const payload = `${timestamp}.${body}`; - const expected = createHmac("sha256", secret) - .update(payload) - .digest("hex"); - - if (expected.length !== digest.length) return false; - return timingSafeEqual(Buffer.from(expected), Buffer.from(digest)); + const expected = + "sha256=" + + createHmac("sha256", secret).update(rawBody).digest("hex"); + + const a = Buffer.from(expected); + const b = Buffer.from(signatureHeader ?? ""); + if (a.length !== b.length) return false; + return timingSafeEqual(a, b); } ``` @@ -274,9 +269,14 @@ function verifyWebhookSignature( ### Obtaining your secret -The signing secret is the `WEBHOOK_SIGNING_SECRET` environment variable -configured on the RemitLend server. Contact the RemitLend team to obtain -your shared secret. +The signing secret is the **per-subscription secret** returned in the +response when you register the webhook subscription (see +[Creating a Subscription](#creating-a-subscription)). It is **not** a +global environment variable. Store it securely on your server and use it +to verify each incoming delivery. + +See also: [docs/wiki/webhook-signatures.md](wiki/webhook-signatures.md) +for additional language examples. --- diff --git a/docs/wiki/webhook-signatures.md b/docs/wiki/webhook-signatures.md index 11fadb85..87d893ad 100644 --- a/docs/wiki/webhook-signatures.md +++ b/docs/wiki/webhook-signatures.md @@ -33,10 +33,11 @@ function verifySignature(secret, rawBody, signatureHeader) { return crypto.timingSafeEqual(a, b); } -// Express example +// Express example — YOUR_SUBSCRIPTION_SECRET is the per-subscription +// secret returned when you registered the webhook, not an env var. app.post("/webhook", express.raw({ type: "application/json" }), (req, res) => { const sig = req.headers["x-remitlend-signature"]; - if (!verifySignature(process.env.WEBHOOK_SECRET, req.body, sig)) { + if (!verifySignature(YOUR_SUBSCRIPTION_SECRET, req.body, sig)) { return res.status(401).send("Invalid signature"); } // process req.body … diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 8c16eb6b..46cb1db0 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -24,11 +24,21 @@ WORKDIR /app ENV NODE_ENV=production COPY --from=builder /app/public ./public COPY --from=builder /app/.next ./.next -COPY --from=builder /app/node_modules ./node_modules COPY --from=builder /app/package.json ./package.json +COPY --from=builder /app/package-lock.json ./package-lock.json + +# Install production-only dependencies +RUN npm ci --omit=dev && rm -f package-lock.json + +# Non-root user for security +RUN addgroup -S appgroup && adduser -S appuser -G appgroup +USER appuser EXPOSE 3000 ENV PORT=3000 ENV HOSTNAME="0.0.0.0" -CMD ["npm", "run", "start"] +HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \ + CMD node -e "fetch('http://127.0.0.1:3000/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" + +CMD ["npm", "run", "start"]