diff --git a/bot/start.ts b/bot/start.ts index f9b4a27a..43467845 100644 --- a/bot/start.ts +++ b/bot/start.ts @@ -339,24 +339,58 @@ const initialize = ( if (!order) return; + // freezeorder settles the hold invoice, so it must only run on orders + // that still have funds in escrow. Refuse terminal/paid states. + const freezableStatuses = ['ACTIVE', 'FIAT_SENT', 'DISPUTE']; + if (!freezableStatuses.includes(order.status)) { + logger.warning( + `freezeorder ${order._id}: refused, status ${order.status} is not freezable`, + ); + return await messages.notAuthorized(ctx); + } + + // We look for a dispute for this order + const dispute = await Dispute.findOne({ order_id: order._id }); + // We check if this is a solver, the order must be from the same community if (!ctx.admin.admin) { if (!order.community_id) { return await messages.notAuthorized(ctx); } - if (order.community_id != ctx.admin.default_community_id) { + if ( + String(order.community_id) !== String(ctx.admin.default_community_id) + ) { + return await messages.notAuthorized(ctx); + } + + // SECURITY: a community solver may only act on the dispute they were + // assigned to (parity with settleorder/cancelorder). + if (dispute && String(dispute.solver_id) !== String(ctx.admin._id)) { + return await messages.notAuthorized(ctx); + } + + // SECURITY: a solver must never resolve an order they are a party to. + if ( + String(order.buyer_id) === String(ctx.admin._id) || + String(order.seller_id) === String(ctx.admin._id) + ) { + logger.warning( + `freezeorder ${order._id}: @${ctx.admin.username} is a party to this order`, + ); return await messages.notAuthorized(ctx); } } + // Settle the hold invoice first; only persist the FROZEN status + // if the settlement succeeded. + if (order.secret) await settleHoldInvoice({ secret: order.secret }); + order.is_frozen = true; order.status = 'FROZEN'; order.action_by = ctx.admin._id; await order.save(); - if (order.secret) await settleHoldInvoice({ secret: order.secret }); - await ctx.reply(ctx.i18n.t('order_frozen')); } catch (error) { logger.error(error); @@ -1002,13 +1036,36 @@ const initialize = ( return await ctx.reply(ctx.i18n.t('paytobuyer_only_frozen_orders')); } + // We look for a dispute for this order + const dispute = await Dispute.findOne({ order_id: order._id }); + // We check if this is a solver, the order must be from the same community if (!ctx.admin.admin) { if (!order.community_id) { return await messages.notAuthorized(ctx); } - if (order.community_id != ctx.admin.default_community_id) { + if ( + String(order.community_id) !== String(ctx.admin.default_community_id) + ) { + return await messages.notAuthorized(ctx); + } + + // SECURITY: a community solver may only act on the dispute they were + // assigned to (parity with settleorder/cancelorder). + if (dispute && String(dispute.solver_id) !== String(ctx.admin._id)) { + return await messages.notAuthorized(ctx); + } + + // SECURITY: a solver must never push the payout of an order they are + // a party to. + if ( + String(order.buyer_id) === String(ctx.admin._id) || + String(order.seller_id) === String(ctx.admin._id) + ) { + logger.warning( + `paytobuyer ${order._id}: @${ctx.admin.username} is a party to this order`, + ); return await messages.notAuthorized(ctx); } } diff --git a/ln/hold_invoice.ts b/ln/hold_invoice.ts index 0bdf51de..e7ee94db 100644 --- a/ln/hold_invoice.ts +++ b/ln/hold_invoice.ts @@ -45,6 +45,9 @@ const settleHoldInvoice = async ({ secret }: { secret: string }) => { await lightning.settleHodlInvoice({ lnd, secret }); } catch (error) { logger.error(error); + // Callers must not mark an order as settled/frozen if the + // invoice settlement did not happen. + throw error; } };