diff --git a/micopay/backend/src/routes/ramp.ts b/micopay/backend/src/routes/ramp.ts index e473e8e..edb624b 100644 --- a/micopay/backend/src/routes/ramp.ts +++ b/micopay/backend/src/routes/ramp.ts @@ -41,6 +41,35 @@ function ensureEtherfuseConfigured() { } } +/** + * order_id is a backend-generated UUID handed to Etherfuse, never guessable — + * but nothing previously stopped one authenticated user from polling another + * user's order if they learned its ID. This records ownership at creation and + * enforces it on every subsequent read (see docs/AUDIT_MOBILE_MAINNET.md, + * "IDOR ramp order"). Orders created before this migration have no row here — + * fail open for those (log + allow) rather than break in-flight orders. + */ +async function recordRampOrderOwner(orderId: string, userId: string): Promise { + await db.execute( + 'INSERT INTO ramp_orders (order_id, user_id) VALUES ($1, $2)', + [orderId, userId], + ); +} + +async function assertRampOrderOwnership(orderId: string, userId: string, request: { log: { warn: (obj: unknown, msg: string) => void } }): Promise { + const owner = await db.getOne<{ user_id: string }>( + 'SELECT user_id FROM ramp_orders WHERE order_id = $1', + [orderId], + ); + if (!owner) { + request.log.warn({ order_id: orderId, user_id: userId, category: 'ramp' }, '[ramp] Order has no recorded owner (pre-migration) — allowing'); + return; + } + if (owner.user_id !== userId) { + throw new ForbiddenError('No tienes permiso para ver esta orden'); + } +} + export async function rampRoutes(app: FastifyInstance): Promise { // Get quote: MXN->CETES (onramp) or CETES->MXN (offramp). app.post<{ Body: { type: 'onramp' | 'offramp'; sourceAmount: string } }>( @@ -114,16 +143,19 @@ export async function rampRoutes(app: FastifyInstance): Promise { } const user = await requireOnboardedUser(request.user.id); + const orderId = randomUUID(); try { const result = await createOrder({ - orderId: randomUUID(), + orderId, quoteId, bankAccountId: user.etherfuse_bank_account_id!, publicKey: user.stellar_address, useAnchor, }); + await recordRampOrderOwner(orderId, request.user.id); + return 'offramp' in result ? result.offramp : result.onramp; } catch (err: any) { throw new UpstreamError( @@ -141,6 +173,7 @@ export async function rampRoutes(app: FastifyInstance): Promise { { preHandler: [authMiddleware] }, async (request) => { const { orderId } = request.params; + await assertRampOrderOwnership(orderId, request.user.id, request); try { const order = await getOrder(orderId); return { orderId: order.orderId, status: order.status, type: order.orderType }; @@ -163,6 +196,7 @@ export async function rampRoutes(app: FastifyInstance): Promise { { preHandler: [authMiddleware] }, async (request, reply) => { const { orderId } = request.params; + await assertRampOrderOwnership(orderId, request.user.id, request); try { const { status, body } = await regenerateOrderTx(orderId); return reply.status(status).send(body ?? {}); diff --git a/micopay/frontend/.env.mainnet b/micopay/frontend/.env.mainnet index ab131df..08b203c 100644 --- a/micopay/frontend/.env.mainnet +++ b/micopay/frontend/.env.mainnet @@ -1,5 +1,10 @@ # Mainnet APK build — overrides .env base values -VITE_API_URL=https://micopay-api.onrender.com +# NOTA: api.micopay.app corre hoy en modo STELLAR_NETWORK=TESTNET (ver +# docs/AWS_MIGRATION_PLAN_2026-07.md §6, taskdef). Este build de mainnet +# apunta al mismo dominio porque Render se está apagando y no tiene caso +# dejarlo ahí — pero hasta que exista un backend real en modo MAINNET, +# las transacciones de este build seguirán siendo de prueba, no reales. +VITE_API_URL=https://api.micopay.app VITE_STELLAR_NETWORK=PUBLIC VITE_HORIZON_URL=https://horizon.stellar.org VITE_NETWORK_PASSPHRASE=Public Global Stellar Network ; September 2015 diff --git a/micopay/frontend/.env.testnet b/micopay/frontend/.env.testnet index a5aef7e..7d82431 100644 --- a/micopay/frontend/.env.testnet +++ b/micopay/frontend/.env.testnet @@ -1,5 +1,9 @@ # Testnet APK build — overrides .env base values -VITE_API_URL=https://micopay-api.onrender.com +VITE_API_URL=https://api.micopay.app +# CETES/Blend trading UI (see docs/AUDIT_MOBILE_MAINNET.md finding B2 — that +# code path doesn't move real user funds yet). Enabled here because testnet +# builds are internal/demo builds; keep this UNSET in .env.mainnet/.env.production. +VITE_ENABLE_DEFI_TRADING=true VITE_STELLAR_NETWORK=TESTNET VITE_ESCROW_CONTRACT_ID=CB4M5777YFQWKGDUULCX5W6PXEDJSJARDTMH4VV6FXC4W4UPANALO3HZ VITE_MXNE_CONTRACT_ID=CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC diff --git a/micopay/sql/migrations/20260702090000_ramp_order_ownership.down.sql b/micopay/sql/migrations/20260702090000_ramp_order_ownership.down.sql new file mode 100644 index 0000000..f01a322 --- /dev/null +++ b/micopay/sql/migrations/20260702090000_ramp_order_ownership.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS ramp_orders; diff --git a/micopay/sql/migrations/20260702090000_ramp_order_ownership.up.sql b/micopay/sql/migrations/20260702090000_ramp_order_ownership.up.sql new file mode 100644 index 0000000..1ca8391 --- /dev/null +++ b/micopay/sql/migrations/20260702090000_ramp_order_ownership.up.sql @@ -0,0 +1,12 @@ +-- SEC hardening: /defi/ramp/order/:orderId and its regenerate_tx sibling let +-- any authenticated user poll ANY orderId (order IDs are backend-generated +-- UUIDs, never persisted with an owner — see docs/AUDIT_MOBILE_MAINNET.md, +-- "IDOR ramp order"). This table records who created each order so the route +-- can enforce ownership going forward. +CREATE TABLE IF NOT EXISTS ramp_orders ( + order_id UUID PRIMARY KEY, + user_id UUID NOT NULL REFERENCES users(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_ramp_orders_user ON ramp_orders (user_id, created_at DESC);