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
- 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
}
}
- 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
}
- 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
}
}
- 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"
}
- 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.