Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 35 additions & 1 deletion micopay/backend/src/routes/ramp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
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<void> {
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<void> {
// Get quote: MXN->CETES (onramp) or CETES->MXN (offramp).
app.post<{ Body: { type: 'onramp' | 'offramp'; sourceAmount: string } }>(
Expand Down Expand Up @@ -114,16 +143,19 @@ export async function rampRoutes(app: FastifyInstance): Promise<void> {
}

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(
Expand All @@ -141,6 +173,7 @@ export async function rampRoutes(app: FastifyInstance): Promise<void> {
{ 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 };
Expand All @@ -163,6 +196,7 @@ export async function rampRoutes(app: FastifyInstance): Promise<void> {
{ 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 ?? {});
Expand Down
7 changes: 6 additions & 1 deletion micopay/frontend/.env.mainnet
Original file line number Diff line number Diff line change
@@ -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
Expand Down
6 changes: 5 additions & 1 deletion micopay/frontend/.env.testnet
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
DROP TABLE IF EXISTS ramp_orders;
12 changes: 12 additions & 0 deletions micopay/sql/migrations/20260702090000_ramp_order_ownership.up.sql
Original file line number Diff line number Diff line change
@@ -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);
Loading