Seguridad de pagos y marketplace
Modelo STRIDE, mitigaciones reales, tabla ASVS/NIST/ISO y riesgos residuales.
En esta página
Modelo de amenazas STRIDE (Spoofing, Tampering, Repudiation, Information disclosure, Denial of service, Elevation of privilege) del sistema de pagos con PayPal y del marketplace de código de terceros. Cada mitigación listada existe en el código; la columna «Dónde» indica el archivo. Para el uso, ve a Guía de desarrollador, Guía de facturación y Configuración de PayPal. Contexto general en Seguridad y cifrado y Mapeo CIS / NIST / ISO.
Principios#
- PayPal es la autoridad: antes de actuar se reconsulta el estado a PayPal; el evento solo avisa.
- Dinero en enteros (centavos) y desde la BD; nunca floats ni importes del cliente.
- Fail-closed: sin firma, sin credenciales o sin tablas, no se concede nada.
- PCI fuera de alcance: PayPal hospeda el pago; BloomX nunca ve ni guarda datos de tarjeta.
- **CORS
*sin cookies es intencional:** la autenticación es por firma de cada petición, no por cookies, así que no hay CSRF clásico en estas rutas (Arquitectura). - Las variables de la plataforma (
PAYPAL_*,PAYMENTS_*,PLATFORM_*) no son visibles para las extensiones, ni por el fallback global de entorno.
STRIDE por superficie#
Creación de órdenes#
| Amenaza | Mitigación real | Dónde |
|---|---|---|
| S: un dominio sin identidad compra o ve órdenes ajenas | Solo dominios firmados Ed25519; modo legado recibe 403 signature_required. Dominio y usuario salen de la firma, no del cuerpo. Una orden ajena responde 404 indistinguible de inexistente. | route-auth.ts, orders.ts |
| T: manipular el importe o el reparto | El cliente solo envía extensionId; el importe sale de la BD y el reparto se calcula con enteros (floor, BigInt). | orders.ts, split.ts |
| T/R: crear la misma orden dos veces | PayPal-Request-Id idempotente en cada POST; providerOrderId UNIQUE. | paypal.ts, ddl.ts |
| D: inundar la creación de órdenes | Rate limit por dominio, cuerpo JSON acotado y timeout/reintentos acotados hacia PayPal. | handler.ts, paypal.ts |
| E: llegar a la ruta sin ser dueño nivel 4 | La instancia exige nivel 4 y step-up antes de firmar; el backend solo confía en lo firmado por la clave del dominio. | route-auth.ts |
Captura#
| Amenaza | Mitigación real | Dónde |
|---|---|---|
| T: capturar dos veces o conceder sin pagar | Claim atómico UPDATE condicional CREATED/APPROVED a CAPTURED; asientos idempotentes por refKey; providerCaptureId UNIQUE. Nunca se concede sin captura COMPLETED confirmada. | orders.ts, ledger.ts |
| R: negar un cobro o disputar | Libro mayor solo-INSERT (trigger bloquea UPDATE/DELETE) y PaymentAudit insert-only, sin PII. | ddl.ts, audit.ts |
| D: captura perdida si cae el retorno | El webhook y la conciliación (cron) terminan las capturas pendientes. | reconcile.ts |
Webhook#
| Amenaza | Mitigación real | Dónde |
|---|---|---|
| S: evento falso | verify-webhook-signature obligatorio con PAYPAL_WEBHOOK_ID; sin verificación válida 401; sin credenciales 503. Se rechaza un cert_url ajeno. | webhook.ts, paypal.ts |
| T: importes falsos en el evento | Importes y estados NO se toman del evento: se reconsulta a PayPal (orden, captura, reembolso) y se compara con la BD. | webhook.ts |
| T: repetición (replay) | Dedupe por id de evento (PaymentEvent, PK eventId); edad máxima del evento; transiciones idempotentes y tolerantes al desorden. | webhook.ts, ddl.ts |
| I: payload guardado | Solo se guardan id, tipo, id de recurso y hash; nunca el cuerpo. | webhook.ts |
| D: cuerpo enorme | Límite de 256 KiB. | webhook.ts |
Vínculo PayPal (OIDC)#
| Amenaza | Mitigación real | Dónde |
|---|---|---|
| S/T: CSRF o inyección de código en el vínculo | state de un solo uso (se guarda su hash, reclamo atómico, caduca en 10 min), nonce y PKCE S256 de nuestra parte; redirect_uri fija de configuración, jamás de la petición. | account.ts |
| T: redirección abierta tras el login | returnTo es el origen firmado del dominio fijado al iniciar; nada del callback decide el destino. Sin state válido el callback nunca redirige. | account.ts |
| I: robo de tokens | Los tokens se descartan al instante; solo se guardan payer_id y correo verificado. | account.ts |
| E: secuestro de sesión para desviar pagos | Cambiar de cuenta exige step-up, avisa por correo al dueño y a los super admins y los pagos pendientes siguen a la cuenta anterior 24 h. | account.ts |
Payouts#
| Amenaza | Mitigación real | Dónde |
|---|---|---|
| T/R: pago duplicado | Bloqueo consultivo de Postgres por desarrollador; Payout READY y asiento negativo en la misma transacción; sender_batch_id = id del Payout (idempotente en PayPal). | payouts.ts |
| T: pagar de más tras un reembolso | Se paga el saldo NETO; un saldo negativo se compensa en el siguiente pago; el fallo de un payout se repone con un ADJUSTMENT, nunca editando asientos. | payouts.ts, ledger.ts |
| D/I: pago ejecutado por un tercero | El cron exige PAYMENTS_CRON_SECRET (mínimo 24 caracteres) comparado en tiempo constante. | bloomx-backend/src/app/api/cron/payments/route.ts |
Suscripciones#
| Amenaza | Mitigación real | Dónde |
|---|---|---|
| T: cambiar el precio de un suscriptor sin su consentimiento | Planes de PayPal inmutables; el suscriptor conserva su precio hasta aprobar el nuevo en PayPal. | subscriptions.ts, pricing-store.ts |
| T: evento de suscripción manipulado | El importe esperado sale de Subscription.priceCents, no del evento; el estado se repara con conciliación periódica. | subscriptions.ts |
| E: acceso sin pagar | Derecho solo en TRIAL/ACTIVE, gracia de 3 días tras un fallo, o hasta el fin del periodo pagado si se cancela; luego la extensión se pausa. | subscriptions.ts, licensing.ts |
Marketplace (código de terceros)#
| Amenaza | Mitigación real | Dónde |
|---|---|---|
| S: suplantar a otro editor o a la plataforma | Id dev.<slug>.<nombre> con el slug del dominio firmado; core-* y reservados prohibidos; la primera submission fija la propiedad (409 extension_id_taken). | bloomx-backend/src/lib/marketplace/rules.ts |
| T: cambiar código ya aprobado | Versiones semver inmutables y crecientes; cada versión se re-revisa; diff de código y de permisos para el revisor; máquina de estados cerrada. | rules.ts, diff.ts, service.ts |
| I: exfiltración por red o SSRF | Red solo a network.hosts declarados, sin redirecciones automáticas y con safeFetch anti-SSRF; las variables de pagos de la plataforma no salen por el fallback global de entorno. | sandbox-policy.ts, hosts.ts |
| D: código que agota recursos | Límites más estrictos: memoria, timeout duro de 10 s, 2 ejecuciones simultáneas, 30/min por (dominio, extensión). | sandbox-policy.ts |
| E: extensión no aprobada llega a otros dominios | Estado private solo para el dueño; yanked/suspended no corre para nadie; sin fila DevExtension un dev.* no corre (fail-closed); extensión de pago sin Entitlement bloqueada, también con tablas ausentes. | licensing.ts |
| T: CSRF en el panel de revisión | Comprobación de origen (Origin = host o Sec-Fetch-Site) además de la protección nativa de Next y la cookie SameSite=Lax. | bloomx-backend/src/lib/marketplace/csrf.ts |
Tabla de cumplimiento#
Cada fila: requisito, cómo se cumple y el archivo o prueba que lo demuestra (rutas relativas a bloomx-backend/ salvo que se indique).
| Control | Requisito | Cómo se cumple | Evidencia |
|---|---|---|---|
| ASVS V4.1 | Control de acceso aplicado en un punto de confianza | Solo dominios firmados; dominio y usuario salen de la firma; nivel 4 y step-up en la instancia. | bloomx-backend/src/lib/payments/route-auth.ts; tests/payments-unit.test.mjs (403 signature_required, 401) |
| ASVS V4.2 | Sin acceso a objetos ajenos (IDOR) | Consultas filtradas por el dominio firmado; orden ajena = 404. | bloomx-backend/src/lib/payments/billing.ts, orders.ts; tests/pg/orders.pg.test.mjs |
| ASVS V4.3 | Funciones privilegiadas protegidas | Revisión solo por administradores de la plataforma; CSRF por origen; cron con secreto. | bloomx-backend/src/lib/marketplace/csrf.ts; app/api/cron/payments/route.ts; tests/payments-unit.test.mjs (cron sin secreto 401) |
| ASVS V8.1 | Datos sensibles mínimos y sin registrar | Sin tokens de PayPal; webhook guarda hash; auditoría redacta secretos, correos y payer ids. | bloomx-backend/src/lib/payments/audit.ts, account.ts; tests/payments-unit.test.mjs (auditoría sin secretos) |
| ASVS V8.3 | Protección de datos en respuestas | Respuestas Cache-Control: no-store; errores saneados sin filtrar secretos; CSV anti-fórmulas. | route-auth.ts; tests/payments-unit.test.mjs (CSV, errores de cliente PayPal) |
| ASVS V13.1 | Validación de entrada de la API | Cuerpo JSON acotado, rangos de fecha validados (máx. 366 días), límites de paginación, manifest y tamaños validados. | bloomx-backend/src/lib/payments/billing.ts, handler.ts; tests/marketplace-rules.test.mjs |
| ASVS V13.2 | Seguridad de la API REST (autenticación, límites, idempotencia) | Firma Ed25519 por petición; rate limit por dominio; idempotencia con PayPal-Request-Id y refKey; CORS * sin cookies a propósito. | handler.ts, paypal.ts; tests/pg/subscriptions.pg.test.mjs |
| NIST SC-8 | Confidencialidad e integridad en tránsito | Solo HTTPS hacia PayPal (hosts fijos sandbox/live); firma de webhook verificada; redirect_uri https fija. | bloomx-backend/src/lib/payments/config.ts, webhook.ts; tests/payments-unit.test.mjs (cert_url ajeno rechazado) |
| NIST AU-2 | Eventos auditables | Todo cambio de dinero o de cuenta genera línea JSON y fila PaymentAudit; libro mayor inmutable por trigger. | bloomx-backend/src/lib/payments/audit.ts, ddl.ts; tests/pg/payouts.pg.test.mjs |
| ISO 27001 A.8 (8.2, 8.3) | Privilegios y restricción de acceso a la información | Dueño nivel 4; revisor administrador de plataforma; variables de plataforma fuera del alcance de las extensiones. | bloomx-backend/src/lib/payments/route-auth.ts; tests/payments-unit.test.mjs (variables fuera del fallback) |
| ISO 27001 A.8 (8.15, 8.16) | Registro y monitorización | Auditoría a stdout (SIEM) y BD; conciliación (npm run payments:reconcile) detecta divergencias con PayPal. | audit.ts, reconcile.ts; scripts/payments-reconcile.mjs |
| ISO 27001 A.8 (8.26, 8.28) | Requisitos de seguridad y codificación segura de aplicaciones | Análisis estático de extensiones, sandbox con límites, revisión humana por versión. | bloomx-backend/src/lib/marketplace/analysis.ts, sandbox-policy.ts; tests/marketplace-analysis.test.mjs |
Las pruebas que usan Postgres (npm run test:pg) y el servidor PayPal falso se ejecutan en local; no sustituyen una auditoría externa ni una prueba con cuentas sandbox reales (verificar).
Riesgos residuales#
- Rate limit en memoria por instancia: cada instancia serverless lleva su contador; un atacante puede repartir peticiones entre instancias. Mitigan la firma obligatoria, la idempotencia y los límites de PayPal, pero no hay un límite distribuido.
- Sin PKCE garantizado por PayPal: enviamos
code_challenge, pero no tenemos garantía de que PayPal lo exija o lo valide; la protección efectiva es elstatede un solo uso, el nonce y laredirect_urifija. - Conciliación manual de `duplicate_unresolved`: si PayPal rechaza un
sender_batch_idrepetido y no indica el payout original, el pago queda READY con ese error y un operador debe comprobar en PayPal si ya se envió antes de reintentar. - El sandbox no es una frontera fuerte: el código de terceros corre en
worker_threadsconvm; la revisión humana y los límites reducen el riesgo, no lo eliminan (Extensiones). - El revisor es una persona: el análisis estático es consultivo y puede no detectar código ofuscado.
- Disponibilidad de PayPal (Payouts/Subscriptions según país) y los términos legales y obligaciones tributarias no los resuelve el software (Configuración).