Reports
MEDIA[AUTENTICACIÓN]#3676308

CoinMate: el endpoint privado /api/bitcoinWithdrawalFees respondía sin autenticación

Un endpoint de la API de CoinMate documentado como operación de usuario devolvía las comisiones de retirada de bitcoin a cualquiera, sin comprobar la firma HMAC que exigen el resto de endpoints privados.

Resumen
Resumen en castellano de un reporte público, no una traducción literal. El código y los comandos se mantienen como en el original.

Resumen

La API de CoinMate divide sus endpoints en públicos y "USER OPERATION" (privados). Los privados exigen que cada petición vaya firmada con HMAC-SHA256 mediante los parámetros clientId, nonce, publicKey y signature. Uno de ellos, POST /api/bitcoinWithdrawalFees, no aplicaba esa comprobación: devolvía los datos a cualquiera que lo llamase, con o sin parámetros de autenticación, e incluso con una firma inventada.

El investigador demostró que el endpoint está pensado como privado con tres pruebas:

  • La documentación oficial de la API lo marca como USER OPERATION y su ejemplo de petición incluye los parámetros de firma:
## Bitcoin withdrawal fees [/bitcoinWithdrawalFees]
**USER OPERATION**

### POST [POST]
+ Request (application/x-www-form-urlencoded)

        clientId=1038&nonce=15270794730&signature=94933BF157B9405A1C2F330902987300B3A73DE620023E1782635AAF16984729
  • Las librerías cliente oficiales (Java, TypeScript y Python) lo invocan con postPrivate(), el mismo método que usan para las llamadas firmadas:
async getBitcoinWithdrawalFees(): Promise<CoinmateResponse<any>> {
    return this.httpClient.postPrivate('/bitcoinWithdrawalFees');
}
public JsonObject getBitcoinWithdrawalFees() throws IOException {
    return httpClient.postPrivate("/bitcoinWithdrawalFees", Map.of());
}
  • El resto de endpoints privados probados (14 o más) rechazan las peticiones sin firma. Era el único que no lo hacía:

| Endpoint | ¿Exige autenticación? | Respuesta sin firma | |:---------|:---------------------:|:--------------------| | POST /api/balances | Sí | "Invalid request" | | POST /api/openOrders | Sí | "Invalid request" | | POST /api/traderFees | Sí | "Invalid request" | | POST /api/bitcoinDepositAddresses | Sí | "Invalid request" | | POST /api/transactionHistory | Sí | "Invalid request" | | POST /api/tradeHistory | Sí | "Invalid request" | | POST /api/orderHistory | Sí | "Invalid request" | | POST /api/transferHistory | Sí | "Invalid request" | | POST /api/bitcoinWithdrawalFees | No | Devuelve los datos |

Según el investigador, la causa es que el filtro o middleware de autenticación no estaba aplicado a esta ruta concreta. Además, la respuesta incluía la cabecera Access-Control-Allow-Origin: *.

Pasos de reproducción

  1. Llamar al endpoint sin ningún parámetro:
curl -s -X POST https://coinmate.io/api/bitcoinWithdrawalFees

La respuesta es un HTTP 200 con los datos:

{
  "error": false,
  "errorMessage": null,
  "data": {
    "low": 0.000011,
    "high": 0.000019,
    "timestamp": 1776286189432
  }
}
  1. Comparar con otro endpoint privado, que sí rechaza la petición:
curl -s -X POST https://coinmate.io/api/balances
{
  "error": true,
  "errorMessage": "Invalid request",
  "data": null
}
  1. Repetir la llamada al endpoint vulnerable con credenciales falsas para confirmar que la firma ni siquiera se valida:
curl -s -X POST https://coinmate.io/api/bitcoinWithdrawalFees \
  -d "clientId=999&publicKey=FAKE_KEY&nonce=12345&signature=AAAAAAAAAAAAAAAAAAAAAA"

La respuesta vuelve a traer los datos:

{
  "error": false,
  "errorMessage": null,
  "data": {
    "low": 0.000011,
    "high": 0.000019,
    "timestamp": 1776286189615
  }
}
  1. Comprobar que el endpoint solo admite POST (un GET devuelve 405):
curl -s -X GET https://coinmate.io/api/bitcoinWithdrawalFees
{
  "type": "about:blank",
  "title": "Method Not Allowed",
  "status": 405,
  "detail": "Method 'GET' is not supported.",
  "instance": "/api/bitcoinWithdrawalFees"
}
  1. Por la cabecera Access-Control-Allow-Origin: *, cualquier web puede leer la respuesta desde el navegador del visitante:
// Any website can execute this:
fetch('https://coinmate.io/api/bitcoinWithdrawalFees', {method: 'POST'})
  .then(r => r.json())
  .then(d => console.log('Fee data:', d.data));

Impacto

Lo que queda expuesto son las comisiones de retirada de bitcoin calculadas en ese momento por el sistema de CoinMate: el mínimo (low), el máximo (high) y la marca de tiempo en milisegundos del último recálculo (timestamp). Son valores generales de la plataforma, no datos de una cuenta concreta: la respuesta no contiene saldos, direcciones ni información de ningún usuario.

El investigador lo plantea sobre todo como un fallo en la capa de control de acceso: un endpoint que debía exigir firma era accesible sin credenciales, y al ser el único caso entre los privados, apunta a un error de configuración que podría repetirse en endpoints añadidos o reorganizados más adelante. El reporte figura en HackerOne con severidad media.

Remediación

La recomendación fue aplicar a /api/bitcoinWithdrawalFees el mismo middleware de autenticación que usan los demás endpoints privados. CoinMate marcó el reporte como resuelto; se envió el 15 de abril de 2026 y se divulgó el 20 de mayo de 2026.