diff --git a/backend/src/__tests__/integration/loanDisputeFlow.test.ts b/backend/src/__tests__/integration/loanDisputeFlow.test.ts index e57797305..824bd780a 100644 --- a/backend/src/__tests__/integration/loanDisputeFlow.test.ts +++ b/backend/src/__tests__/integration/loanDisputeFlow.test.ts @@ -108,7 +108,7 @@ describe('loan dispute resolution integration flow', () => { mockQuery .mockResolvedValueOnce(dbRows([{ address: TEST_PUBLIC_KEY }])) - .mockResolvedValueOnce(dbRows([{ loan_id: LOAN_ID }])) + .mockResolvedValueOnce(dbRows([{ loan_id: LOAN_ID, address: TEST_PUBLIC_KEY }])) .mockResolvedValueOnce(dbRows([{ id: DISPUTE_ID }])) .mockResolvedValueOnce(dbOk()); @@ -193,7 +193,7 @@ describe('loan dispute resolution integration flow', () => { mockQuery .mockResolvedValueOnce(dbRows([{ address: TEST_PUBLIC_KEY }])) - .mockResolvedValueOnce(dbRows([{ loan_id: LOAN_ID }])) + .mockResolvedValueOnce(dbRows([{ loan_id: LOAN_ID, address: TEST_PUBLIC_KEY }])) .mockResolvedValueOnce(dbRows([{ id: DISPUTE_ID }])) .mockResolvedValueOnce(dbOk()); diff --git a/backend/src/__tests__/loanDispute.test.ts b/backend/src/__tests__/loanDispute.test.ts index d6fe08dbb..0aa9ed049 100644 --- a/backend/src/__tests__/loanDispute.test.ts +++ b/backend/src/__tests__/loanDispute.test.ts @@ -133,13 +133,13 @@ describe('Loan Dispute/Appeal Mechanism', () => { * [1] SELECT address FROM contract_events WHERE loan_id = 42 → TEST_PUBLIC_KEY ✓ * * contestDefault: - * [2] SELECT contract_events WHERE event_type='LoanDefaulted' → found + * [2] SELECT contract_events WHERE event_type='LoanDefaulted' → found (address = borrower) * [3] INSERT loan_disputes RETURNING id → disputeId * [4] INSERT contract_events LoanDisputed */ mockQuery .mockResolvedValueOnce(dbRows([{ address: TEST_PUBLIC_KEY }])) // [1] loanAccess - .mockResolvedValueOnce(dbRows([{ loan_id: LOAN_ID }])) // [2] defaulted check + .mockResolvedValueOnce(dbRows([{ loan_id: LOAN_ID, address: TEST_PUBLIC_KEY }])) // [2] defaulted + ownership check .mockResolvedValueOnce(dbRows([{ id: DISPUTE_ID }])) // [3] dispute INSERT .mockResolvedValueOnce(dbOk()); // [4] LoanDisputed event @@ -154,6 +154,28 @@ describe('Loan Dispute/Appeal Mechanism', () => { disputeId = res.body.disputeId ?? disputeId; }); + it('should reject a contest when the defaulted loan belongs to another borrower', async () => { + /** + * POST /api/loans/42/contest-default + * requireLoanOwner: + * [1] SELECT address FROM loan_events WHERE loan_id = 42 → TEST_PUBLIC_KEY (middleware passes) + * + * contestDefault: + * [2] SELECT contract_events WHERE event_type='LoanDefaulted' → address = OTHER borrower → 403 + */ + const OTHER_PUBLIC_KEY = 'GBRPYHIL2CI3FNQ4BXLFMNDLFJUNPU2HY3ZMFSHONUCEOASW7QC7OX2H'; + mockQuery + .mockResolvedValueOnce(dbRows([{ address: TEST_PUBLIC_KEY }])) // [1] loanOwner + .mockResolvedValueOnce(dbRows([{ loan_id: LOAN_ID, address: OTHER_PUBLIC_KEY }])); // [2] defaulted event owned by another borrower + + const res = await request(app) + .post(`/api/loans/${defaultedLoanId}/contest-default`) + .set('Authorization', `Bearer ${authToken}`) + .send({ reason: 'Attempting to hijack another borrower default.' }); + + expect(res.status).toBe(403); + }); + it('should freeze penalty accrual during dispute', async () => { /** * GET /api/loans/42 diff --git a/backend/src/controllers/loanController.ts b/backend/src/controllers/loanController.ts index fd916093c..9b3a8ae15 100644 --- a/backend/src/controllers/loanController.ts +++ b/backend/src/controllers/loanController.ts @@ -164,13 +164,22 @@ export const contestDefault = asyncHandler( // Check loan exists and is defaulted const loanResult = await query( - `SELECT loan_id FROM contract_events WHERE loan_id = $1 AND event_type = 'LoanDefaulted' LIMIT 1`, + `SELECT address FROM contract_events WHERE loan_id = $1 AND event_type = 'LoanDefaulted' LIMIT 1`, [loanId], ); if (loanResult.rows.length === 0) { throw AppError.badRequest('Loan is not defaulted or does not exist'); } + // Ownership check (BOLA/IDOR): the `address` on the LoanDefaulted event is + // the loan's borrower, so only that wallet may contest the default. The + // route also runs `requireLoanOwner`; keeping the assertion here makes the + // handler safe wherever it is mounted. + const loanBorrower = loanResult.rows[0].address as string | undefined; + if (loanBorrower !== borrower) { + throw AppError.forbidden('You are not the borrower of this loan'); + } + // Insert dispute record and return disputeId const disputeResult = await query( `INSERT INTO loan_disputes (loan_id, borrower, reason, status) VALUES ($1, $2, $3, 'open') RETURNING id`,