fix: Security hardening pass 2 - body limit, CIP-8 bypass, pagination, cbor2 bump
- Fix #11: Request body size limit (64KB) on /v1/tx/submit via middleware - Fix #12: CIP-8 empty payload bypass - reject empty payloads explicitly - Fix #13: Pagination on /v1/address/{addr}/tokens and /v1/asset/{policy_id}/info - Fix #14: Bump cbor2 to >=5.6.5 (CVE-2024-26134 tag decoding DoS) - Fix #15: Fixed holder count query (was using GROUP BY + COUNT DISTINCT incorrectly) - Fix #16: Async lock for protocol params cache to prevent stampede
This commit is contained in:
parent
abaa19b79e
commit
8a4e59894b
2 changed files with 152 additions and 66 deletions
206
main.py
206
main.py
|
|
@ -33,6 +33,14 @@ Security hardening applied 2026-03-21:
|
||||||
- Fix #8: Input validation regex for addresses, tx hashes, policy IDs
|
- Fix #8: Input validation regex for addresses, tx hashes, policy IDs
|
||||||
- Fix #9: Correct tx hash calculation (blake2b of tx body, not full tx)
|
- Fix #9: Correct tx hash calculation (blake2b of tx body, not full tx)
|
||||||
- Fix #10: Enforce key expiry globally in get_api_key_info
|
- Fix #10: Enforce key expiry globally in get_api_key_info
|
||||||
|
|
||||||
|
Security hardening pass 2 (2026-03-21):
|
||||||
|
- Fix #11: Request body size limit (64KB) on /v1/tx/submit
|
||||||
|
- Fix #12: CIP-8 empty payload bypass fixed
|
||||||
|
- Fix #13: Pagination on /v1/address/{addr}/tokens and /v1/asset/{policy_id}/info
|
||||||
|
- Fix #14: cbor2 bumped to >=5.6.5 (CVE-2024-26134)
|
||||||
|
- Fix #15: Fixed holder count query (was using GROUP BY + COUNT DISTINCT incorrectly)
|
||||||
|
- Fix #16: Async lock for protocol params cache to prevent stampede
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
|
@ -52,6 +60,7 @@ from contextlib import asynccontextmanager
|
||||||
|
|
||||||
from fastapi import FastAPI, Request, HTTPException, Query, Header, Depends, BackgroundTasks
|
from fastapi import FastAPI, Request, HTTPException, Query, Header, Depends, BackgroundTasks
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
|
from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
import asyncpg
|
import asyncpg
|
||||||
from asyncpg.exceptions import UndefinedTableError, PostgresError
|
from asyncpg.exceptions import UndefinedTableError, PostgresError
|
||||||
|
|
@ -132,6 +141,9 @@ db_pool: Optional[asyncpg.Pool] = None
|
||||||
redis_client: Optional[redis.Redis] = None
|
redis_client: Optional[redis.Redis] = None
|
||||||
protocol_params_cache: dict = {"data": None, "expires": 0}
|
protocol_params_cache: dict = {"data": None, "expires": 0}
|
||||||
|
|
||||||
|
# Fix #16: Async lock for protocol params cache to prevent stampede
|
||||||
|
_params_lock = asyncio.Lock()
|
||||||
|
|
||||||
|
|
||||||
# ============ Input Validation (Fix #8) ============
|
# ============ Input Validation (Fix #8) ============
|
||||||
|
|
||||||
|
|
@ -264,18 +276,22 @@ def verify_cip8_signature(address: str, nonce: str, signature_hex: str, key_hex:
|
||||||
|
|
||||||
protected, unprotected, payload, sig = cose_sign1
|
protected, unprotected, payload, sig = cose_sign1
|
||||||
|
|
||||||
|
# Fix #12: Reject empty payloads - nonce verification must happen
|
||||||
|
if not payload:
|
||||||
|
logger.warning("CIP-8 verification rejected: empty payload")
|
||||||
|
return False
|
||||||
|
|
||||||
# The payload should contain our nonce
|
# The payload should contain our nonce
|
||||||
if payload:
|
payload_decoded = payload if isinstance(payload, bytes) else bytes(payload)
|
||||||
payload_decoded = payload if isinstance(payload, bytes) else bytes(payload)
|
# Payload might be hex-encoded nonce or raw bytes
|
||||||
# Payload might be hex-encoded nonce or raw bytes
|
try:
|
||||||
try:
|
if payload_decoded.hex() != nonce and payload_decoded.decode('utf-8') != nonce:
|
||||||
if payload_decoded.hex() != nonce and payload_decoded.decode('utf-8') != nonce:
|
logger.warning("Payload doesn't match nonce")
|
||||||
logger.warning("Payload doesn't match nonce")
|
return False
|
||||||
return False
|
except:
|
||||||
except:
|
if payload_decoded.hex() != nonce:
|
||||||
if payload_decoded.hex() != nonce:
|
logger.warning("Payload doesn't match nonce (hex check)")
|
||||||
logger.warning("Payload doesn't match nonce (hex check)")
|
return False
|
||||||
return False
|
|
||||||
|
|
||||||
# Build the Sig_structure for verification
|
# Build the Sig_structure for verification
|
||||||
# Sig_structure = ["Signature1", protected, external_aad, payload]
|
# Sig_structure = ["Signature1", protected, external_aad, payload]
|
||||||
|
|
@ -417,11 +433,31 @@ async def lifespan(app: FastAPI):
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="Cardano Chain Data API",
|
title="Cardano Chain Data API",
|
||||||
description="REST API for querying Cardano blockchain data via db-sync and cardano-node",
|
description="REST API for querying Cardano blockchain data via db-sync and cardano-node",
|
||||||
version="2.1.0", # Bumped for security fixes
|
version="2.2.0", # Bumped for security hardening pass 2
|
||||||
lifespan=lifespan
|
lifespan=lifespan
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ============ Fix #11: Request Body Size Limit Middleware ============
|
||||||
|
|
||||||
|
class LimitBodySizeMiddleware(BaseHTTPMiddleware):
|
||||||
|
"""Limit request body size on tx submit to prevent DoS. Cardano max tx is ~16KB."""
|
||||||
|
MAX_TX_SIZE = 65536 # 64KB - generous limit
|
||||||
|
|
||||||
|
async def dispatch(self, request: Request, call_next):
|
||||||
|
if request.url.path == "/v1/tx/submit":
|
||||||
|
content_length = request.headers.get("content-length")
|
||||||
|
if content_length and int(content_length) > self.MAX_TX_SIZE:
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=413,
|
||||||
|
content={"error": "payload_too_large", "message": "Transaction exceeds maximum size of 64KB"}
|
||||||
|
)
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
|
||||||
|
app.add_middleware(LimitBodySizeMiddleware)
|
||||||
|
|
||||||
|
|
||||||
# ============ Exception Handlers ============
|
# ============ Exception Handlers ============
|
||||||
|
|
||||||
@app.exception_handler(UndefinedTableError)
|
@app.exception_handler(UndefinedTableError)
|
||||||
|
|
@ -968,44 +1004,46 @@ async def get_protocol_params(auth: dict = Depends(require_standard_tier)):
|
||||||
"""
|
"""
|
||||||
global protocol_params_cache
|
global protocol_params_cache
|
||||||
|
|
||||||
# Check cache
|
# Fix #16: Use async lock to prevent cache stampede
|
||||||
if protocol_params_cache["data"] and protocol_params_cache["expires"] > time.time():
|
async with _params_lock:
|
||||||
return protocol_params_cache["data"]
|
# Check cache inside the lock
|
||||||
|
if protocol_params_cache["data"] and protocol_params_cache["expires"] > time.time():
|
||||||
|
return protocol_params_cache["data"]
|
||||||
|
|
||||||
# Query from node
|
# Query from node
|
||||||
success, stdout, stderr = run_cardano_cli([
|
success, stdout, stderr = run_cardano_cli([
|
||||||
"query", "protocol-parameters",
|
"query", "protocol-parameters",
|
||||||
"--mainnet"
|
"--mainnet"
|
||||||
])
|
])
|
||||||
|
|
||||||
if not success:
|
if not success:
|
||||||
if "Network.Socket.connect" in stderr or "does not exist" in stderr:
|
if "Network.Socket.connect" in stderr or "does not exist" in stderr:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=503,
|
||||||
|
detail={"error": "node_unavailable", "message": "Cardano node not available"}
|
||||||
|
)
|
||||||
|
# Fix #5: Don't leak stderr
|
||||||
|
logger.error(f"protocol-params query failed: {stderr}")
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=503,
|
status_code=500,
|
||||||
detail={"error": "node_unavailable", "message": "Cardano node not available"}
|
detail={"error": "node_error", "message": "Node command failed"}
|
||||||
)
|
)
|
||||||
# Fix #5: Don't leak stderr
|
|
||||||
logger.error(f"protocol-params query failed: {stderr}")
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=500,
|
|
||||||
detail={"error": "node_error", "message": "Node command failed"}
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
params = json.loads(stdout)
|
params = json.loads(stdout)
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=500,
|
status_code=500,
|
||||||
detail={"error": "parse_error", "message": "Failed to parse protocol parameters"}
|
detail={"error": "parse_error", "message": "Failed to parse protocol parameters"}
|
||||||
)
|
)
|
||||||
|
|
||||||
# Cache for 5 minutes
|
# Cache for 5 minutes
|
||||||
protocol_params_cache = {
|
protocol_params_cache = {
|
||||||
"data": params,
|
"data": params,
|
||||||
"expires": time.time() + CACHE_TTLS["protocol_params"]
|
"expires": time.time() + CACHE_TTLS["protocol_params"]
|
||||||
}
|
}
|
||||||
|
|
||||||
return params
|
return params
|
||||||
|
|
||||||
|
|
||||||
# ============ Auth Endpoints (TRP-Gated) ============
|
# ============ Auth Endpoints (TRP-Gated) ============
|
||||||
|
|
@ -1327,8 +1365,13 @@ async def get_address_balance(address: str, auth: dict = Depends(get_auth_contex
|
||||||
|
|
||||||
|
|
||||||
@app.get("/v1/address/{address}/tokens")
|
@app.get("/v1/address/{address}/tokens")
|
||||||
async def get_address_tokens(address: str, auth: dict = Depends(get_auth_context)):
|
async def get_address_tokens(
|
||||||
"""Get native tokens held by an address."""
|
address: str,
|
||||||
|
page: int = Query(1, ge=1, description="Page number"),
|
||||||
|
limit: int = Query(100, ge=1, le=1000, description="Results per page (max 1000)"),
|
||||||
|
auth: dict = Depends(get_auth_context)
|
||||||
|
):
|
||||||
|
"""Get native tokens held by an address. Fix #13: Now paginated."""
|
||||||
# Fix #8: Validate address
|
# Fix #8: Validate address
|
||||||
if not validate_address(address):
|
if not validate_address(address):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|
@ -1336,12 +1379,26 @@ async def get_address_tokens(address: str, auth: dict = Depends(get_auth_context
|
||||||
detail={"error": "invalid_address", "message": "Invalid Cardano address format"}
|
detail={"error": "invalid_address", "message": "Invalid Cardano address format"}
|
||||||
)
|
)
|
||||||
|
|
||||||
cache_key = f"tokens_{address}"
|
offset = (page - 1) * limit
|
||||||
|
cache_key = f"tokens_{address}_{page}_{limit}"
|
||||||
cached = await get_cached(cache_key)
|
cached = await get_cached(cache_key)
|
||||||
if cached:
|
if cached:
|
||||||
return cached
|
return cached
|
||||||
|
|
||||||
async with db_pool.acquire() as conn:
|
async with db_pool.acquire() as conn:
|
||||||
|
# Get total count for pagination info
|
||||||
|
count_result = await conn.fetchrow("""
|
||||||
|
SELECT COUNT(DISTINCT ma.id) as total
|
||||||
|
FROM ma_tx_out mto
|
||||||
|
JOIN multi_asset ma ON ma.id = mto.ident
|
||||||
|
JOIN tx_out txo ON txo.id = mto.tx_out_id
|
||||||
|
LEFT JOIN tx_in txi ON txi.tx_out_id = txo.tx_id AND txi.tx_out_index = txo.index
|
||||||
|
WHERE txo.address = $1 AND txi.id IS NULL
|
||||||
|
""", address)
|
||||||
|
|
||||||
|
total_count = count_result["total"] if count_result else 0
|
||||||
|
|
||||||
|
# Fix #13: Add LIMIT and OFFSET for pagination
|
||||||
tokens = await conn.fetch("""
|
tokens = await conn.fetch("""
|
||||||
SELECT
|
SELECT
|
||||||
encode(ma.policy, 'hex') as policy_id,
|
encode(ma.policy, 'hex') as policy_id,
|
||||||
|
|
@ -1357,10 +1414,15 @@ async def get_address_tokens(address: str, auth: dict = Depends(get_auth_context
|
||||||
GROUP BY ma.id, ma.policy, ma.name, ma.fingerprint
|
GROUP BY ma.id, ma.policy, ma.name, ma.fingerprint
|
||||||
HAVING SUM(mto.quantity) > 0
|
HAVING SUM(mto.quantity) > 0
|
||||||
ORDER BY quantity DESC
|
ORDER BY quantity DESC
|
||||||
""", address)
|
LIMIT $2 OFFSET $3
|
||||||
|
""", address, limit, offset)
|
||||||
|
|
||||||
result = {
|
result = {
|
||||||
"address": address,
|
"address": address,
|
||||||
|
"page": page,
|
||||||
|
"limit": limit,
|
||||||
|
"total_count": total_count,
|
||||||
|
"total_pages": (total_count + limit - 1) // limit if total_count > 0 else 0,
|
||||||
"tokens": [
|
"tokens": [
|
||||||
{
|
{
|
||||||
"policy_id": t["policy_id"],
|
"policy_id": t["policy_id"],
|
||||||
|
|
@ -1547,8 +1609,13 @@ async def get_transaction(tx_hash: str, auth: dict = Depends(get_auth_context)):
|
||||||
# ============ Asset Endpoints ============
|
# ============ Asset Endpoints ============
|
||||||
|
|
||||||
@app.get("/v1/asset/{policy_id}/info")
|
@app.get("/v1/asset/{policy_id}/info")
|
||||||
async def get_asset_info(policy_id: str, auth: dict = Depends(get_auth_context)):
|
async def get_asset_info(
|
||||||
"""Get info about all assets under a policy ID."""
|
policy_id: str,
|
||||||
|
page: int = Query(1, ge=1, description="Page number"),
|
||||||
|
limit: int = Query(100, ge=1, le=500, description="Results per page (max 500)"),
|
||||||
|
auth: dict = Depends(get_auth_context)
|
||||||
|
):
|
||||||
|
"""Get info about all assets under a policy ID. Fix #13: Now paginated."""
|
||||||
# Fix #8: Validate policy ID
|
# Fix #8: Validate policy ID
|
||||||
if not validate_policy_id(policy_id):
|
if not validate_policy_id(policy_id):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|
@ -1556,7 +1623,8 @@ async def get_asset_info(policy_id: str, auth: dict = Depends(get_auth_context))
|
||||||
detail={"error": "invalid_policy_id", "message": "Invalid policy ID format (expected 56 hex chars)"}
|
detail={"error": "invalid_policy_id", "message": "Invalid policy ID format (expected 56 hex chars)"}
|
||||||
)
|
)
|
||||||
|
|
||||||
cache_key = f"asset_info_{policy_id}"
|
offset = (page - 1) * limit
|
||||||
|
cache_key = f"asset_info_{policy_id}_{page}_{limit}"
|
||||||
cached = await get_cached(cache_key)
|
cached = await get_cached(cache_key)
|
||||||
if cached:
|
if cached:
|
||||||
return cached
|
return cached
|
||||||
|
|
@ -1564,6 +1632,16 @@ async def get_asset_info(policy_id: str, auth: dict = Depends(get_auth_context))
|
||||||
clean_policy = policy_id.lower().replace("0x", "")
|
clean_policy = policy_id.lower().replace("0x", "")
|
||||||
|
|
||||||
async with db_pool.acquire() as conn:
|
async with db_pool.acquire() as conn:
|
||||||
|
# Get total count for pagination
|
||||||
|
count_result = await conn.fetchrow("""
|
||||||
|
SELECT COUNT(*) as total
|
||||||
|
FROM multi_asset ma
|
||||||
|
WHERE ma.policy = decode($1, 'hex')
|
||||||
|
""", clean_policy)
|
||||||
|
|
||||||
|
total_count = count_result["total"] if count_result else 0
|
||||||
|
|
||||||
|
# Fix #13: Add LIMIT and OFFSET for pagination
|
||||||
assets = await conn.fetch("""
|
assets = await conn.fetch("""
|
||||||
SELECT
|
SELECT
|
||||||
encode(ma.name, 'hex') as asset_name_hex,
|
encode(ma.name, 'hex') as asset_name_hex,
|
||||||
|
|
@ -1573,13 +1651,19 @@ async def get_asset_info(policy_id: str, auth: dict = Depends(get_auth_context))
|
||||||
(SELECT COUNT(*) FROM ma_tx_mint WHERE ident = ma.id AND quantity > 0) as mint_count
|
(SELECT COUNT(*) FROM ma_tx_mint WHERE ident = ma.id AND quantity > 0) as mint_count
|
||||||
FROM multi_asset ma
|
FROM multi_asset ma
|
||||||
WHERE ma.policy = decode($1, 'hex')
|
WHERE ma.policy = decode($1, 'hex')
|
||||||
""", clean_policy)
|
ORDER BY ma.id
|
||||||
|
LIMIT $2 OFFSET $3
|
||||||
|
""", clean_policy, limit, offset)
|
||||||
|
|
||||||
if not assets:
|
if not assets and page == 1:
|
||||||
raise HTTPException(status_code=404, detail={"error": "not_found", "message": f"Policy {policy_id} not found"})
|
raise HTTPException(status_code=404, detail={"error": "not_found", "message": f"Policy {policy_id} not found"})
|
||||||
|
|
||||||
result = {
|
result = {
|
||||||
"policy_id": policy_id,
|
"policy_id": policy_id,
|
||||||
|
"page": page,
|
||||||
|
"limit": limit,
|
||||||
|
"total_count": total_count,
|
||||||
|
"total_pages": (total_count + limit - 1) // limit if total_count > 0 else 0,
|
||||||
"assets": [
|
"assets": [
|
||||||
{
|
{
|
||||||
"asset_name": a["asset_name"] or a["asset_name_hex"],
|
"asset_name": a["asset_name"] or a["asset_name_hex"],
|
||||||
|
|
@ -1641,15 +1725,17 @@ async def get_asset_holders(
|
||||||
LIMIT $2
|
LIMIT $2
|
||||||
""", asset["id"], limit)
|
""", asset["id"], limit)
|
||||||
|
|
||||||
# Count total holders
|
# Fix #15: Correct holder count query using subquery
|
||||||
holder_count = await conn.fetchrow("""
|
holder_count = await conn.fetchrow("""
|
||||||
SELECT COUNT(DISTINCT txo.address) as count
|
SELECT COUNT(*) as count FROM (
|
||||||
FROM ma_tx_out mto
|
SELECT txo.address
|
||||||
JOIN tx_out txo ON txo.id = mto.tx_out_id
|
FROM ma_tx_out mto
|
||||||
LEFT JOIN tx_in txi ON txi.tx_out_id = txo.tx_id AND txi.tx_out_index = txo.index
|
JOIN tx_out txo ON txo.id = mto.tx_out_id
|
||||||
WHERE mto.ident = $1 AND txi.id IS NULL
|
LEFT JOIN tx_in txi ON txi.tx_out_id = txo.tx_id AND txi.tx_out_index = txo.index
|
||||||
GROUP BY txo.address
|
WHERE mto.ident = $1 AND txi.id IS NULL
|
||||||
HAVING SUM(mto.quantity) > 0
|
GROUP BY txo.address
|
||||||
|
HAVING SUM(mto.quantity) > 0
|
||||||
|
) sub
|
||||||
""", asset["id"])
|
""", asset["id"])
|
||||||
|
|
||||||
result = {
|
result = {
|
||||||
|
|
|
||||||
|
|
@ -5,5 +5,5 @@ redis==5.0.0
|
||||||
pydantic==2.9.0
|
pydantic==2.9.0
|
||||||
python-dotenv==1.0.0
|
python-dotenv==1.0.0
|
||||||
pycardano==0.11.0
|
pycardano==0.11.0
|
||||||
cbor2==5.6.0
|
cbor2>=5.6.5
|
||||||
PyNaCl==1.5.0
|
PyNaCl==1.5.0
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue