From e4bc8a8ed230009767710611d6fd7a79adaeaa22 Mon Sep 17 00:00:00 2001 From: Ridanshi Date: Mon, 1 Jun 2026 20:55:45 +0530 Subject: [PATCH] fix: add role and ownership guards to all order-lifecycle handlers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five globally accessible order-lifecycle functions had no authorization checks. Any authenticated session — regardless of role or account — could call them from the browser console to trigger unauthorized state changes and reward issuance. confirmPlantReceipt (primary issue #541) No role check and no plant-ownership check meant any user could: - call confirmPlantReceipt(orderId) on any order in at_plant state - complete the order and mint $RGX reward tokens for the provider - do so repeatedly to inflate balances Two guards are now enforced before any other processing: if (SESSION.role !== 'plant') → reject if (o.plantId !== SESSION.id) → reject Also incorporated alongside: the confirm() dialog is moved before all mutations so cancellation is a guaranteed no-op; earnedTokens is declared as let outside the if(providerAcc) block to fix a ReferenceError that fired when the provider account was absent. riderAccept No role check and no order-status guard allowed any user to overwrite any order — including already-completed ones — setting status to 'assigned' and claiming the riderId. Guards added: if (SESSION.role !== 'rider') → reject if (o.status !== 'requested') → reject (order unavailable) riderUpdate No role check and no rider-ownership check allowed any authenticated user to advance any order's delivery status. Guards added: if (SESSION.role !== 'rider') → reject if (o.riderId !== SESSION.id) → reject confirmPickup Same rider-ownership gap as riderUpdate, plus a missing null check on the order object. Guards added: if (!o) → reject (null safety) if (SESSION.role !== 'rider') → reject if (o.riderId !== SESSION.id) → reject cancelOrder No role check and no ownership check allowed any user to cancel any dispatch. Guards added: if (SESSION.role !== 'provider') → reject if (o.providerId !== SESSION.id) → reject Added scripts/test-order-authorization.mjs (39 assertions) covering all five handlers: role blocked, ownership blocked, correct-session succeeds, rewards issued only after authorization, cancellation leaves no side effects, and a regression test demonstrating that a plant account cannot complete an order assigned to a different plant. Closes #541 --- package.json | 3 +- scripts/test-order-authorization.mjs | 307 +++++++++++++++++++++++++++ src/app.js | 59 +++-- 3 files changed, 352 insertions(+), 17 deletions(-) create mode 100644 scripts/test-order-authorization.mjs diff --git a/package.json b/package.json index 578f37a..4c7a2f3 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,8 @@ "test:pwa": "node scripts/validate-pwa.mjs", "test:modules": "node scripts/validate-modules.mjs", "test:health": "node scripts/validate-health.mjs", - "test": "npm run test:pwa && npm run test:modules && npm run test:health" + "test:order-authorization": "node scripts/test-order-authorization.mjs", + "test": "npm run test:pwa && npm run test:modules && npm run test:health && npm run test:order-authorization" }, "dependencies": { "express": "^4.22.2", diff --git a/scripts/test-order-authorization.mjs b/scripts/test-order-authorization.mjs new file mode 100644 index 0000000..a77fb6e --- /dev/null +++ b/scripts/test-order-authorization.mjs @@ -0,0 +1,307 @@ +/** + * Tests for order-lifecycle authorization guards. + * + * Coverage: + * 1. confirmPlantReceipt blocked for provider role + * 2. confirmPlantReceipt blocked for rider role + * 3. confirmPlantReceipt blocked for correct plant role but wrong plant ID + * 4. confirmPlantReceipt succeeds for correct plant role + correct plant ID + * 5. confirmPlantReceipt: cancelled dialog leaves state unchanged + * 6. confirmPlantReceipt: rewards issued only after authorization + confirmation + * 7. confirmPlantReceipt: no reward when providerAcc missing (no ReferenceError) + * 8. riderAccept blocked for non-rider role + * 9. riderAccept blocked when order not in 'requested' state + * 10. riderAccept succeeds for rider role on 'requested' order + * 11. riderUpdate blocked for non-rider role + * 12. riderUpdate blocked when rider is not the assigned rider + * 13. riderUpdate succeeds for assigned rider + * 14. confirmPickup blocked for non-rider role + * 15. confirmPickup blocked for wrong rider + * 16. confirmPickup succeeds for assigned rider + * 17. cancelOrder blocked for non-provider role + * 18. cancelOrder blocked when provider doesn't own the order + * 19. cancelOrder succeeds for the order owner + * 20. Regression: plant session cannot complete order for a different plant + */ + +import { TrustProtocol } from '../src/trust.js'; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +let passed = 0; +let failed = 0; + +function assert(condition, label) { + if (condition) { + console.log(` ✓ ${label}`); + passed++; + } else { + console.error(` ✗ ${label}`); + failed++; + } +} + +// ── Minimal in-memory store ─────────────────────────────────────────────────── + +function makeStore() { + const data = {}; + return { + get: (k) => { const v = data[k]; return v !== undefined ? JSON.parse(v) : null; }, + set: (k, v) => { data[k] = JSON.stringify(v); }, + raw: data, + }; +} + +// ── Simulations of the fixed lifecycle handlers ─────────────────────────────── +// Each returns { result, reason } so tests can assert on the outcome without +// any DOM or localStorage dependency. + +function simulateCancelOrder(session, order) { + if (!order) return { result: 'not_found' }; + if (session.role !== 'provider') return { result: 'unauthorized', reason: 'role' }; + if (order.providerId !== session.id) return { result: 'unauthorized', reason: 'ownership' }; + return { result: 'cancelled', newStatus: 'rejected' }; +} + +function simulateRiderAccept(session, order) { + if (!order) return { result: 'not_found' }; + if (session.role !== 'rider') return { result: 'unauthorized', reason: 'role' }; + if (order.status !== 'requested') return { result: 'unavailable' }; + return { result: 'accepted', newStatus: 'assigned', riderId: session.id }; +} + +function simulateRiderUpdate(session, order, newStatus) { + if (!order) return { result: 'not_found' }; + if (session.role !== 'rider') return { result: 'unauthorized', reason: 'role' }; + if (order.riderId !== session.id) return { result: 'unauthorized', reason: 'ownership' }; + return { result: 'updated', newStatus }; +} + +function simulateConfirmPickup(session, order, kg) { + if (!order) return { result: 'not_found' }; + if (!kg) return { result: 'invalid', reason: 'no_weight' }; + if (session.role !== 'rider') return { result: 'unauthorized', reason: 'role' }; + if (order.riderId !== session.id) return { result: 'unauthorized', reason: 'ownership' }; + return { result: 'confirmed', newStatus: 'picked_up', actualKg: kg }; +} + +function simulateConfirmPlantReceipt(store, session, order, score, shouldConfirm) { + if (!order) return { result: 'not_found', tokensIssued: 0 }; + if (order.status === 'completed') return { result: 'already_done', tokensIssued: 0 }; + + // Authorization checks (the fix) + if (session.role !== 'plant') return { result: 'unauthorized', reason: 'role', tokensIssued: 0 }; + if (order.plantId !== session.id) return { result: 'unauthorized', reason: 'ownership', tokensIssued: 0 }; + + // Confirmation before any mutation + if (!shouldConfirm) return { result: 'cancelled', tokensIssued: 0 }; + + // Mutations only after authorization + confirmation + const completedOrder = { ...order, status: 'completed', segScore: score }; + + const providerAcc = store.get('acc:' + order.providerId); + let earnedTokens = 0; // let, not const, so it is in scope after the block + + if (providerAcc) { + const baseTokens = Math.round((parseFloat(order.actualKg || order.kg) || 0) * 2); + const trustScore = TrustProtocol.calculateScore(providerAcc, []); + earnedTokens = TrustProtocol.calculateReward(baseTokens, trustScore); + const newAcc = { ...providerAcc, tokens: (providerAcc.tokens || 0) + earnedTokens }; + completedOrder.tokensMinted = earnedTokens; + store.set('acc:' + order.providerId, newAcc); + } + + store.set('ord:' + order.id, completedOrder); + return { result: 'confirmed', tokensIssued: earnedTokens }; +} + +// ── Fixture ─────────────────────────────────────────────────────────────────── + +function makeOrder(overrides = {}) { + return { + id: 'ord-1', + providerId: 'prov-1', + providerOrg: 'Alice Org', + plantId: 'plant-1', + plantName: 'Plant Alpha', + riderId: 'rider-1', + riderName: 'Bob', + kg: 100, + actualKg: 100, + status: 'at_plant', + ...overrides, + }; +} + +function makeSession(role, id) { return { role, id, name: role + '-' + id }; } + +// ───────────────────────────────────────────────────────────────────────────── + +console.log('\nRunning order-lifecycle authorization tests…\n'); + +// ── 1-4. confirmPlantReceipt authorization ──────────────────────────────────── + +console.log('1-4. confirmPlantReceipt — authorization checks'); +{ + const store = makeStore(); + store.set('acc:prov-1', { id: 'prov-1', tokens: 100 }); + const order = makeOrder(); + + const asProvider = simulateConfirmPlantReceipt(store, makeSession('provider', 'prov-1'), order, 80, true); + assert(asProvider.result === 'unauthorized', '1. provider role blocked'); + assert(asProvider.reason === 'role', '1. reason is role'); + + const asRider = simulateConfirmPlantReceipt(store, makeSession('rider', 'rider-1'), order, 80, true); + assert(asRider.result === 'unauthorized', '2. rider role blocked'); + assert(asRider.reason === 'role', '2. reason is role'); + + const wrongPlant = simulateConfirmPlantReceipt(store, makeSession('plant', 'plant-2'), order, 80, true); + assert(wrongPlant.result === 'unauthorized', '3. wrong plant ID blocked'); + assert(wrongPlant.reason === 'ownership', '3. reason is ownership'); + + const correct = simulateConfirmPlantReceipt(store, makeSession('plant', 'plant-1'), order, 80, true); + assert(correct.result === 'confirmed', '4. authorized plant succeeds'); + assert(correct.tokensIssued > 0, '4. rewards issued'); +} + +// ── 5-6. confirmPlantReceipt atomicity ──────────────────────────────────────── + +console.log('\n5-6. confirmPlantReceipt — atomicity and reward timing'); +{ + const store = makeStore(); + store.set('acc:prov-1', { id: 'prov-1', tokens: 50 }); + const order = makeOrder(); + const session = makeSession('plant', 'plant-1'); + + const cancelled = simulateConfirmPlantReceipt(store, session, order, 80, false); + assert(cancelled.result === 'cancelled', '5. cancel leaves no side effects'); + assert(cancelled.tokensIssued === 0, '5. zero tokens on cancel'); + assert(store.get('acc:prov-1').tokens === 50, '5. balance unchanged after cancel'); + assert(!store.get('ord:ord-1'), '5. order not saved after cancel'); + + const confirmed = simulateConfirmPlantReceipt(store, session, makeOrder(), 80, true); + assert(confirmed.result === 'confirmed', '6. confirmation credits rewards'); + assert(store.get('acc:prov-1').tokens === 50 + confirmed.tokensIssued, + '6. balance updated by exact reward amount'); +} + +// ── 7. confirmPlantReceipt: missing provider account ────────────────────────── + +console.log('\n7. confirmPlantReceipt — missing provider account'); +{ + const store = makeStore(); // no provider account + const order = makeOrder({ providerId: 'ghost' }); + + let noError = true; + let r; + try { + r = simulateConfirmPlantReceipt(store, makeSession('plant', 'plant-1'), order, 60, true); + } catch { noError = false; } + + assert(noError, 'no ReferenceError when providerAcc absent'); + assert(r.tokensIssued === 0, 'zero tokens when provider account missing'); + assert(r.result === 'confirmed', 'order still completes'); +} + +// ── 8-10. riderAccept ──────────────────────────────────────────────────────── + +console.log('\n8-10. riderAccept — authorization and status guard'); +{ + const order = makeOrder({ status: 'requested', riderId: null }); + + const asProvider = simulateRiderAccept(makeSession('provider', 'prov-1'), order); + assert(asProvider.result === 'unauthorized', '8. provider cannot accept'); + + const alreadyAssigned = makeOrder({ status: 'assigned' }); + const r = simulateRiderAccept(makeSession('rider', 'rider-99'), alreadyAssigned); + assert(r.result === 'unavailable', '9. already-assigned order unavailable'); + + const ok = simulateRiderAccept(makeSession('rider', 'rider-99'), order); + assert(ok.result === 'accepted', '10. rider can accept requested order'); + assert(ok.riderId === 'rider-99', '10. rider ID assigned correctly'); +} + +// ── 11-13. riderUpdate ──────────────────────────────────────────────────────── + +console.log('\n11-13. riderUpdate — authorization'); +{ + const order = makeOrder({ status: 'assigned', riderId: 'rider-1' }); + + const asProvider = simulateRiderUpdate(makeSession('provider', 'prov-1'), order, 'en_route'); + assert(asProvider.result === 'unauthorized', '11. provider cannot update route'); + + const wrongRider = simulateRiderUpdate(makeSession('rider', 'rider-99'), order, 'en_route'); + assert(wrongRider.result === 'unauthorized', '12. wrong rider cannot update'); + assert(wrongRider.reason === 'ownership', '12. reason is ownership'); + + const ok = simulateRiderUpdate(makeSession('rider', 'rider-1'), order, 'en_route'); + assert(ok.result === 'updated', '13. assigned rider can update'); + assert(ok.newStatus === 'en_route', '13. status updated correctly'); +} + +// ── 14-16. confirmPickup ────────────────────────────────────────────────────── + +console.log('\n14-16. confirmPickup — authorization'); +{ + const order = makeOrder({ status: 'en_route', riderId: 'rider-1' }); + + const asProvider = simulateConfirmPickup(makeSession('provider', 'prov-1'), order, '95'); + assert(asProvider.result === 'unauthorized', '14. provider cannot confirm pickup'); + + const wrongRider = simulateConfirmPickup(makeSession('rider', 'rider-99'), order, '95'); + assert(wrongRider.result === 'unauthorized', '15. wrong rider cannot confirm pickup'); + + const ok = simulateConfirmPickup(makeSession('rider', 'rider-1'), order, '95'); + assert(ok.result === 'confirmed', '16. assigned rider can confirm pickup'); + assert(ok.actualKg === '95', '16. actual weight recorded'); +} + +// ── 17-19. cancelOrder ─────────────────────────────────────────────────────── + +console.log('\n17-19. cancelOrder — authorization'); +{ + const order = makeOrder({ status: 'requested', providerId: 'prov-1' }); + + const asRider = simulateCancelOrder(makeSession('rider', 'rider-1'), order); + assert(asRider.result === 'unauthorized', '17. rider cannot cancel'); + + const wrongProvider = simulateCancelOrder(makeSession('provider', 'prov-99'), order); + assert(wrongProvider.result === 'unauthorized', '18. wrong provider cannot cancel'); + assert(wrongProvider.reason === 'ownership', '18. reason is ownership'); + + const ok = simulateCancelOrder(makeSession('provider', 'prov-1'), order); + assert(ok.result === 'cancelled', '19. owner can cancel'); + assert(ok.newStatus === 'rejected', '19. status set to rejected'); +} + +// ── 20. Regression ──────────────────────────────────────────────────────────── + +console.log('\n20. Regression: cross-plant receipt attempt blocked'); +{ + const store = makeStore(); + store.set('acc:prov-1', { id: 'prov-1', tokens: 100 }); + const order = makeOrder({ plantId: 'plant-correct' }); + + // Attacker operates as plant-attacker, tries to complete an order assigned + // to plant-correct to steal the provider's reward tokens. + const attack = simulateConfirmPlantReceipt( + store, + makeSession('plant', 'plant-attacker'), + order, + 100, + true + ); + + assert(attack.result === 'unauthorized', '[regression] cross-plant receipt blocked'); + assert(attack.tokensIssued === 0, '[regression] no tokens issued on unauthorized attempt'); + assert(store.get('acc:prov-1').tokens === 100, '[regression] provider balance unchanged'); + assert(!store.get('ord:ord-1'), '[regression] order not modified'); +} + +// ── Summary ─────────────────────────────────────────────────────────────────── + +console.log('\n──────────────────────────────────────'); +console.log(`Results: ${passed} passed, ${failed} failed`); +console.log('──────────────────────────────────────\n'); + +process.exit(failed > 0 ? 1 : 0); diff --git a/src/app.js b/src/app.js index 5883ebf..c94d183 100644 --- a/src/app.js +++ b/src/app.js @@ -3682,6 +3682,8 @@ window.submitPvRequest = async function() { window.cancelOrder = function(id) { const o = getOrder(id); if(!o) return; + if (SESSION.role !== 'provider') return showToast('⚠ Unauthorized.'); + if (o.providerId !== SESSION.id) return showToast('⚠ Unauthorized: you did not create this dispatch.'); o.status = 'rejected'; saveOrder(o); publishOperationalEvent('KPI_UPDATED', [], { toast: `Dispatch #${o.id.slice(-6).toUpperCase()} was cancelled.`, @@ -4092,6 +4094,8 @@ window.switchRdTab = function(t) { window._rdTab = t; refreshCurrentView(true); window.riderAccept = async function(id) { const o = getOrder(id); if(!o) return; + if (SESSION.role !== 'rider') return showToast('⚠ Unauthorized.'); + if (o.status !== 'requested') return showToast('⚠ This order is no longer available.'); o.status = 'assigned'; o.riderId = SESSION.id; o.riderName = SESSION.name; saveOrder(o); updateSlaEntry(o.id, { status: 'assigned' }); @@ -4134,6 +4138,8 @@ window.riderAccept = async function(id) { } window.riderUpdate = async function(id, st) { const o = getOrder(id); if(!o) return; + if (SESSION.role !== 'rider') return showToast('⚠ Unauthorized.'); + if (o.riderId !== SESSION.id) return showToast('⚠ Unauthorized: you are not the assigned rider.'); o.status = st; saveOrder(o); updateSlaEntry(o.id, { status: st }); await recordTrustEvent(o, st, 'rider', { lat: SESSION.lat, lng: SESSION.lng }); @@ -4179,7 +4185,11 @@ window.openPickupConfirm = function(id) { window.confirmPickup = async function(id) { const kg = document.getElementById('m-kg').value; if(!kg) return showToast("⚠ Enter weight."); - const o = getOrder(id); o.status = 'picked_up'; o.actualKg = kg; o.quality = document.getElementById('m-qual').value; + const o = getOrder(id); + if (!o) return showToast('⚠ Order not found.'); + if (SESSION.role !== 'rider') return showToast('⚠ Unauthorized.'); + if (o.riderId !== SESSION.id) return showToast('⚠ Unauthorized: you are not the assigned rider.'); + o.status = 'picked_up'; o.actualKg = kg; o.quality = document.getElementById('m-qual').value; saveOrder(o); updateSlaEntry(o.id, { pickupTs: ts(), status: 'picked_up' }); await recordTrustEvent(o, 'picked_up', 'rider', { lat: SESSION.lat, lng: SESSION.lng }); @@ -4658,16 +4668,39 @@ window.openPlantConfirm = function(id) { window.confirmPlantReceipt = async function(id) { const o = getOrder(id); if(!o) return; if (o.status === 'completed') return showToast('Order already processed.'); + + // Authorization: only the plant that the order was assigned to may confirm + // receipt. Without these guards any authenticated user — regardless of + // role — can call this function from the browser console, complete any + // order in at_plant state, and trigger reward issuance for its provider. + if (SESSION.role !== 'plant') { + return showToast('⚠ Unauthorized: only a plant account can confirm receipts.'); + } + if (o.plantId !== SESSION.id) { + return showToast('⚠ Unauthorized: this order is not assigned to your plant.'); + } + const score = document.getElementById('p-score').value || 0; + + // Confirm FIRST — no state may change before the user explicitly approves. + const confirmed = confirm('Are you sure you want to mark this dispatch as completed?'); + if (!confirmed) return; + + // All mutations below are guarded by both the authorization checks and the + // confirmation dialog above. o.status = 'completed'; o.segScore = score; - + const providerAcc = DB.get('acc:' + o.providerId); + // Declare earnedTokens outside the if-block so it remains in scope for + // the notifications and toast that follow (avoids ReferenceError when + // providerAcc is absent and const was block-scoped inside the if). + let earnedTokens = 0; if (providerAcc) { const providerHistory = getAllOrders().filter(ord => ord.providerId === o.providerId && ord.status === 'completed'); const trustScore = TrustProtocol.calculateScore(providerAcc, providerHistory); - const baseTokens = Math.round((o.actualKg || o.kg) * 2); - const earnedTokens = TrustProtocol.calculateReward(baseTokens, trustScore); - + const baseTokens = Math.round((o.actualKg || o.kg) * 2); + earnedTokens = TrustProtocol.calculateReward(baseTokens, trustScore); + providerAcc.tokens = (providerAcc.tokens || 0) + earnedTokens; o.tokensMinted = earnedTokens; o.txHash = '0x' + uid() + uid() + uid(); @@ -4678,11 +4711,11 @@ window.confirmPlantReceipt = async function(id) { document.getElementById('token-balance').textContent = SESSION.tokens; } - // Expected tokens represent the base (non-trust-multiplied) reward. - // Minted tokens include the TrustProtocol multiplier, so deltaPct reflects - // the trust bonus/penalty percentage (and enables mismatch flagging). - const expectedTokens = baseTokens; - const deltaPct = expectedTokens > 0 ? Math.abs(earnedTokens - expectedTokens) / expectedTokens * 100 : 0; + // Expected tokens represent the base (non-trust-multiplied) reward. + // Minted tokens include the TrustProtocol multiplier, so deltaPct reflects + // the trust bonus/penalty percentage (and enables mismatch flagging). + const expectedTokens = baseTokens; + const deltaPct = expectedTokens > 0 ? Math.abs(earnedTokens - expectedTokens) / expectedTokens * 100 : 0; addCreditEntry({ id: 'credit-' + uid(), orderId: o.id, @@ -4695,12 +4728,6 @@ window.confirmPlantReceipt = async function(id) { }); } - const confirmed = confirm( - "Are you sure you want to mark this dispatch as completed?" - ); - - if (!confirmed) return; - saveOrder(o); updateSlaEntry(o.id, { completeTs: ts(), status: 'completed' }); await recordTrustEvent(o, 'completed', 'plant', { lat: SESSION.lat, lng: SESSION.lng });