Security hardening: fix 10 vulnerabilities
CRITICAL: - Fix #1: Atomic nonce GETDEL to prevent race conditions in verify_auth() HIGH: - Fix #2: X-Forwarded-For only trusted from TRUSTED_PROXIES set - Fix #3: TRP tier refresh every 10min (was 60min) + 48h key expiry - Fix #4: SHA-256 hashed key storage in Redis (raw key never stored) MEDIUM: - Fix #5: Generic error messages - no internal detail leakage - Fix #6: Auth refresh is self-service only (documented) - Fix #7: CBOR validation before tx submit - Fix #8: Input validation regex for addresses, tx hashes, policy IDs LOW: - Fix #9: Correct tx hash calculation (blake2b of tx body, not full tx) - Fix #10: Enforce key expiry globally in get_api_key_info Version bumped to 2.1.0
This commit is contained in:
parent
d5fbec496f
commit
dac26ea1ba
1 changed files with 246 additions and 58 deletions
304
main.py
304
main.py
|
|
@ -21,9 +21,22 @@ Access Tiers (strictly enforced):
|
|||
|
||||
Node endpoints (/v1/address/{addr}/utxos, /v1/protocol-params, /v1/tx/submit)
|
||||
return HTTP 403 for insufficient tier.
|
||||
|
||||
Security hardening applied 2026-03-21:
|
||||
- Fix #1: Atomic nonce GETDEL to prevent race conditions
|
||||
- Fix #2: X-Forwarded-For only trusted from known proxies
|
||||
- Fix #3: TRP refresh every 10 min + 48h key expiry for TRP-gated keys
|
||||
- Fix #4: SHA-256 hashed key storage in Redis
|
||||
- Fix #5: Generic error messages (no internal detail leakage)
|
||||
- Fix #6: Auth refresh is self-service only (key refreshes itself)
|
||||
- Fix #7: CBOR validation before tx submit
|
||||
- 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 #10: Enforce key expiry globally in get_api_key_info
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import json
|
||||
import hashlib
|
||||
import secrets
|
||||
|
|
@ -102,14 +115,50 @@ CACHE_TTLS = {
|
|||
"utxos": 10 # Short cache for UTxOs
|
||||
}
|
||||
|
||||
# TRP-gated key expiry (48 hours)
|
||||
TRP_KEY_EXPIRY_HOURS = 48
|
||||
|
||||
# Fix #2: Trusted proxies for X-Forwarded-For
|
||||
TRUSTED_PROXIES = {"127.0.0.1", "::1", "172.22.0.1", "172.17.0.1"}
|
||||
|
||||
# Fix #8: Input validation regexes
|
||||
ADDR_RE = re.compile(r'^addr1[a-z0-9]{50,120}$')
|
||||
ADDR_TEST_RE = re.compile(r'^addr_test1[a-z0-9]{50,120}$')
|
||||
HEX64_RE = re.compile(r'^[a-fA-F0-9]{64}$')
|
||||
POLICY_RE = re.compile(r'^[a-fA-F0-9]{56}$')
|
||||
|
||||
# Global connections
|
||||
db_pool: Optional[asyncpg.Pool] = None
|
||||
redis_client: Optional[redis.Redis] = None
|
||||
protocol_params_cache: dict = {"data": None, "expires": 0}
|
||||
|
||||
|
||||
# ============ Input Validation (Fix #8) ============
|
||||
|
||||
def validate_address(address: str) -> bool:
|
||||
"""Validate Cardano address format."""
|
||||
return bool(ADDR_RE.match(address) or ADDR_TEST_RE.match(address))
|
||||
|
||||
|
||||
def validate_tx_hash(tx_hash: str) -> bool:
|
||||
"""Validate transaction hash format (64 hex chars)."""
|
||||
clean = tx_hash.lower().replace("0x", "")
|
||||
return bool(HEX64_RE.match(clean))
|
||||
|
||||
|
||||
def validate_policy_id(policy_id: str) -> bool:
|
||||
"""Validate policy ID format (56 hex chars)."""
|
||||
clean = policy_id.lower().replace("0x", "")
|
||||
return bool(POLICY_RE.match(clean))
|
||||
|
||||
|
||||
# ============ Helper Functions ============
|
||||
|
||||
def hash_api_key(key: str) -> str:
|
||||
"""Hash an API key for storage/lookup. Fix #4."""
|
||||
return hashlib.sha256(key.encode()).hexdigest()
|
||||
|
||||
|
||||
def run_cardano_cli(args: list[str], timeout: int = 30) -> tuple[bool, str, str]:
|
||||
"""Run cardano-cli command and return (success, stdout, stderr)."""
|
||||
cmd = ["cardano-cli"] + args
|
||||
|
|
@ -130,7 +179,8 @@ def run_cardano_cli(args: list[str], timeout: int = 30) -> tuple[bool, str, str]
|
|||
except FileNotFoundError:
|
||||
return False, "", "cardano-cli not found"
|
||||
except Exception as e:
|
||||
return False, "", str(e)
|
||||
logger.error(f"cardano-cli error: {e}")
|
||||
return False, "", "Node command failed"
|
||||
|
||||
|
||||
def decode_hex_or_base64(data: str) -> bytes:
|
||||
|
|
@ -255,10 +305,10 @@ def verify_cip8_signature(address: str, nonce: str, signature_hex: str, key_hex:
|
|||
# ============ Lifespan ============
|
||||
|
||||
async def refresh_trp_tiers_task():
|
||||
"""Background task to refresh TRP tiers for all gated keys every hour."""
|
||||
"""Background task to refresh TRP tiers for all gated keys every 10 minutes. Fix #3."""
|
||||
while True:
|
||||
try:
|
||||
await asyncio.sleep(3600) # Run every hour
|
||||
await asyncio.sleep(600) # Run every 10 minutes (was 3600)
|
||||
await refresh_all_trp_tiers()
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
|
|
@ -367,7 +417,7 @@ async def lifespan(app: FastAPI):
|
|||
app = FastAPI(
|
||||
title="Cardano Chain Data API",
|
||||
description="REST API for querying Cardano blockchain data via db-sync and cardano-node",
|
||||
version="2.0.0",
|
||||
version="2.1.0", # Bumped for security fixes
|
||||
lifespan=lifespan
|
||||
)
|
||||
|
||||
|
|
@ -386,11 +436,11 @@ async def handle_undefined_table(request: Request, exc: UndefinedTableError):
|
|||
|
||||
@app.exception_handler(PostgresError)
|
||||
async def handle_postgres_error(request: Request, exc: PostgresError):
|
||||
"""Handle general postgres errors."""
|
||||
"""Handle general postgres errors. Fix #5: Don't leak internal details."""
|
||||
logger.error(f"Database error: {exc}")
|
||||
return JSONResponse(
|
||||
status_code=503,
|
||||
content={"error": "database_error", "message": str(exc)}
|
||||
content={"error": "database_error", "message": "Internal database error"}
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -445,11 +495,34 @@ class TxSubmitResponse(BaseModel):
|
|||
# ============ Helpers ============
|
||||
|
||||
async def get_api_key_info(key: str) -> Optional[dict]:
|
||||
"""Get API key info from Redis."""
|
||||
"""
|
||||
Get API key info from Redis using hashed key lookup. Fix #4 and Fix #10.
|
||||
Also enforces key expiry for TRP-gated keys.
|
||||
"""
|
||||
if not redis_client:
|
||||
return None
|
||||
data = await redis_client.hgetall(f"apikey:{key}")
|
||||
return data if data else None
|
||||
|
||||
# Hash the key for lookup (Fix #4)
|
||||
key_hash = hash_api_key(key)
|
||||
data = await redis_client.hgetall(f"apikey:{key_hash}")
|
||||
|
||||
if not data:
|
||||
return None
|
||||
|
||||
# Fix #10: Check expiry for TRP-gated keys
|
||||
expires_at = data.get("expires_at")
|
||||
if expires_at:
|
||||
try:
|
||||
expiry_time = datetime.fromisoformat(expires_at.replace('Z', '+00:00'))
|
||||
if datetime.now(timezone.utc) > expiry_time:
|
||||
# Key expired, delete it and return None
|
||||
logger.info(f"API key expired for owner {data.get('owner', 'unknown')}")
|
||||
await redis_client.delete(f"apikey:{key_hash}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Error parsing expires_at: {e}")
|
||||
|
||||
return data
|
||||
|
||||
|
||||
async def check_rate_limit(identifier: str, tier: str, limit_type: str = "general") -> tuple[bool, int]:
|
||||
|
|
@ -488,11 +561,18 @@ async def set_cached(cache_key: str, data: dict, ttl: int):
|
|||
|
||||
|
||||
def get_client_ip(request: Request) -> str:
|
||||
"""Extract client IP from request."""
|
||||
forwarded = request.headers.get("X-Forwarded-For")
|
||||
if forwarded:
|
||||
return forwarded.split(",")[0].strip()
|
||||
return request.client.host if request.client else "unknown"
|
||||
"""
|
||||
Extract client IP from request. Fix #2: Only trust X-Forwarded-For from known proxies.
|
||||
"""
|
||||
client_host = request.client.host if request.client else "unknown"
|
||||
|
||||
# Only trust X-Forwarded-For if connecting IP is a trusted proxy
|
||||
if client_host in TRUSTED_PROXIES:
|
||||
forwarded = request.headers.get("X-Forwarded-For")
|
||||
if forwarded:
|
||||
return forwarded.split(",")[0].strip()
|
||||
|
||||
return client_host
|
||||
|
||||
|
||||
# ============ Auth Dependency ============
|
||||
|
|
@ -514,9 +594,10 @@ async def get_auth_context(
|
|||
if key_info:
|
||||
return {
|
||||
"tier": key_info.get("tier", "standard"),
|
||||
"identifier": key,
|
||||
"identifier": hash_api_key(key), # Use hash as identifier
|
||||
"label": key_info.get("label", "unknown"),
|
||||
"owner": key_info.get("owner")
|
||||
"owner": key_info.get("owner"),
|
||||
"raw_key": key # Keep raw key for refresh endpoint
|
||||
}
|
||||
|
||||
return {"tier": "anonymous", "identifier": client_ip, "label": None}
|
||||
|
|
@ -595,13 +676,13 @@ async def rate_limit_middleware(request: Request, call_next):
|
|||
key_info = await get_api_key_info(key)
|
||||
if key_info:
|
||||
tier = key_info.get("tier", "standard")
|
||||
identifier = key
|
||||
identifier = hash_api_key(key) # Use hash as identifier
|
||||
label = key_info.get("label", "unknown")
|
||||
|
||||
# Check rate limit (general)
|
||||
allowed, retry_after = await check_rate_limit(identifier, tier)
|
||||
if not allowed:
|
||||
logger.warning(f"Rate limit exceeded: {identifier} ({tier})")
|
||||
logger.warning(f"Rate limit exceeded: {identifier[:16]}... ({tier})")
|
||||
return JSONResponse(
|
||||
status_code=429,
|
||||
content={"error": "rate_limit_exceeded", "retry_after": retry_after}
|
||||
|
|
@ -610,9 +691,10 @@ async def rate_limit_middleware(request: Request, call_next):
|
|||
# Process request
|
||||
response = await call_next(request)
|
||||
|
||||
# Log request
|
||||
# Log request (don't log full key hash for security)
|
||||
elapsed = int((time.time() - start_time) * 1000)
|
||||
logger.info(f"{request.method} {request.url.path} | {label or client_ip} | {elapsed}ms | {response.status_code}")
|
||||
log_id = label or client_ip
|
||||
logger.info(f"{request.method} {request.url.path} | {log_id} | {elapsed}ms | {response.status_code}")
|
||||
|
||||
return response
|
||||
|
||||
|
|
@ -688,6 +770,13 @@ async def get_address_utxos(address: str, auth: dict = Depends(require_standard_
|
|||
Requires: standard tier (50+ TRP) or higher.
|
||||
Anonymous users should use /v1/address/{address}/balance (db-sync) instead.
|
||||
"""
|
||||
# Fix #8: Validate address format
|
||||
if not validate_address(address):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={"error": "invalid_address", "message": "Invalid Cardano address format"}
|
||||
)
|
||||
|
||||
cache_key = f"utxos_{address}"
|
||||
cached = await get_cached(cache_key)
|
||||
if cached:
|
||||
|
|
@ -707,9 +796,11 @@ async def get_address_utxos(address: str, auth: dict = Depends(require_standard_
|
|||
status_code=503,
|
||||
detail={"error": "node_unavailable", "message": "Cardano node not available"}
|
||||
)
|
||||
# Fix #5: Don't leak stderr details
|
||||
logger.error(f"cardano-cli utxo query failed: {stderr}")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail={"error": "node_error", "message": stderr}
|
||||
detail={"error": "node_error", "message": "Node command failed"}
|
||||
)
|
||||
|
||||
try:
|
||||
|
|
@ -789,6 +880,27 @@ async def submit_transaction(
|
|||
detail={"error": "invalid_encoding", "message": str(e)}
|
||||
)
|
||||
|
||||
# Fix #7: Validate CBOR before proceeding
|
||||
try:
|
||||
tx_cbor = cbor2.loads(tx_bytes)
|
||||
except Exception:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={"error": "invalid_tx", "message": "Transaction is not valid CBOR"}
|
||||
)
|
||||
|
||||
# Fix #9: Calculate correct tx hash from tx body (index 0), not full tx
|
||||
try:
|
||||
if isinstance(tx_cbor, (list, tuple)) and len(tx_cbor) > 0:
|
||||
tx_body_cbor = cbor2.dumps(tx_cbor[0])
|
||||
tx_hash = hashlib.blake2b(tx_body_cbor, digest_size=32).hexdigest()
|
||||
else:
|
||||
# Fallback if structure is unexpected
|
||||
tx_hash = hashlib.blake2b(tx_bytes, digest_size=32).hexdigest()
|
||||
except Exception as e:
|
||||
logger.error(f"Error calculating tx hash: {e}")
|
||||
tx_hash = hashlib.blake2b(tx_bytes, digest_size=32).hexdigest()
|
||||
|
||||
# Write to temp file for cardano-cli
|
||||
with tempfile.NamedTemporaryFile(suffix=".signed", delete=False) as f:
|
||||
f.write(tx_bytes)
|
||||
|
|
@ -808,7 +920,7 @@ async def submit_transaction(
|
|||
status_code=503,
|
||||
detail={"error": "node_unavailable", "message": "Cardano node not available"}
|
||||
)
|
||||
# Parse common errors
|
||||
# Parse common errors (these are safe to expose)
|
||||
if "OutsideValidityIntervalUTxO" in stderr:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
|
|
@ -824,21 +936,14 @@ async def submit_transaction(
|
|||
status_code=400,
|
||||
detail={"error": "value_not_conserved", "message": "Input/output value mismatch"}
|
||||
)
|
||||
# Fix #5: Don't leak full stderr for other errors
|
||||
logger.error(f"tx submit failed: {stderr}")
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={"error": "submit_failed", "message": stderr.strip()}
|
||||
detail={"error": "submit_failed", "message": "Node command failed"}
|
||||
)
|
||||
|
||||
# Calculate tx hash from the CBOR
|
||||
# The hash is blake2b-256 of the transaction body
|
||||
tx_hash = hashlib.blake2b(tx_bytes, digest_size=32).hexdigest()
|
||||
|
||||
# Try to get actual hash from output
|
||||
if "Transaction successfully submitted" in stdout or success:
|
||||
# cardano-cli doesn't output the hash, but we calculated it
|
||||
pass
|
||||
|
||||
logger.info(f"Transaction submitted: {tx_hash[:16]}... by {auth['identifier']}")
|
||||
logger.info(f"Transaction submitted: {tx_hash[:16]}... by {auth['identifier'][:16]}...")
|
||||
|
||||
return TxSubmitResponse(
|
||||
tx_hash=tx_hash,
|
||||
|
|
@ -879,9 +984,11 @@ async def get_protocol_params(auth: dict = Depends(require_standard_tier)):
|
|||
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(
|
||||
status_code=500,
|
||||
detail={"error": "node_error", "message": stderr}
|
||||
detail={"error": "node_error", "message": "Node command failed"}
|
||||
)
|
||||
|
||||
try:
|
||||
|
|
@ -911,8 +1018,8 @@ async def create_auth_challenge(request: AuthChallengeRequest):
|
|||
"""
|
||||
address = request.address
|
||||
|
||||
# Validate address format
|
||||
if not address.startswith(("addr1", "addr_test1")):
|
||||
# Fix #8: Validate address format
|
||||
if not validate_address(address):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={"error": "invalid_address", "message": "Invalid Cardano address format"}
|
||||
|
|
@ -944,22 +1051,32 @@ async def verify_auth(request: AuthVerifyRequest):
|
|||
- 0 TRP: anonymous (no key issued)
|
||||
- 50+ TRP: standard (100 req/min)
|
||||
- 500+ TRP: elevated (1000 req/min)
|
||||
|
||||
TRP-gated keys expire after 48 hours and must be re-authenticated.
|
||||
"""
|
||||
address = request.address
|
||||
nonce = request.nonce
|
||||
|
||||
# Check nonce exists and hasn't expired
|
||||
challenge_key = f"auth_challenge:{address}:{nonce}"
|
||||
challenge_data = await redis_client.get(challenge_key)
|
||||
|
||||
if not challenge_data:
|
||||
# Fix #8: Validate address
|
||||
if not validate_address(address):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={"error": "invalid_nonce", "message": "Nonce expired or invalid"}
|
||||
detail={"error": "invalid_address", "message": "Invalid Cardano address format"}
|
||||
)
|
||||
|
||||
# Delete nonce (one-time use)
|
||||
await redis_client.delete(challenge_key)
|
||||
# Fix #1: Atomic nonce check-and-delete using pipeline
|
||||
challenge_key = f"auth_challenge:{address}:{nonce}"
|
||||
|
||||
async with redis_client.pipeline(transaction=True) as pipe:
|
||||
await pipe.get(challenge_key)
|
||||
await pipe.delete(challenge_key)
|
||||
challenge_data, deleted = await pipe.execute()
|
||||
|
||||
if not challenge_data or not deleted:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={"error": "invalid_nonce", "message": "Nonce expired or already used"}
|
||||
)
|
||||
|
||||
# Verify CIP-8 signature
|
||||
if not verify_cip8_signature(address, nonce, request.signature, request.key):
|
||||
|
|
@ -987,17 +1104,24 @@ async def verify_auth(request: AuthVerifyRequest):
|
|||
# Generate API key
|
||||
new_key = f"capi_{secrets.token_hex(24)}"
|
||||
|
||||
# Store key info
|
||||
await redis_client.hset(f"apikey:{new_key}", mapping={
|
||||
# Fix #3: Set expiry 48 hours from now for TRP-gated keys
|
||||
expires_at = datetime.now(timezone.utc) + timedelta(hours=TRP_KEY_EXPIRY_HOURS)
|
||||
|
||||
# Fix #4: Store using hashed key, not raw key
|
||||
key_hash = hash_api_key(new_key)
|
||||
|
||||
await redis_client.hset(f"apikey:{key_hash}", mapping={
|
||||
"label": f"TRP-gated:{address[:20]}...",
|
||||
"tier": tier,
|
||||
"owner": address,
|
||||
"trp_balance": str(trp_balance),
|
||||
"created_at": datetime.now(timezone.utc).isoformat()
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
"expires_at": expires_at.isoformat() # Fix #3: Add expiry
|
||||
})
|
||||
|
||||
logger.info(f"Issued {tier} API key for {address[:20]}... (TRP: {trp_balance})")
|
||||
logger.info(f"Issued {tier} API key for {address[:20]}... (TRP: {trp_balance}, expires: {expires_at.isoformat()})")
|
||||
|
||||
# Return the raw key to the user (only time it's exposed)
|
||||
return AuthVerifyResponse(
|
||||
api_key=new_key,
|
||||
tier=tier,
|
||||
|
|
@ -1012,6 +1136,10 @@ async def refresh_auth(
|
|||
):
|
||||
"""
|
||||
Re-check TRP balance and upgrade/downgrade tier for an existing key.
|
||||
|
||||
Fix #6: This endpoint is self-service only. The API key in the header
|
||||
is both the authentication AND the key being refreshed. You cannot
|
||||
use key A to refresh key B.
|
||||
"""
|
||||
if auth["tier"] == "anonymous":
|
||||
raise HTTPException(
|
||||
|
|
@ -1037,10 +1165,17 @@ async def refresh_auth(
|
|||
new_tier = get_tier_from_trp_balance(trp_balance)
|
||||
old_tier = auth["tier"]
|
||||
|
||||
# Fix #3: Reset expiry on refresh
|
||||
expires_at = datetime.now(timezone.utc) + timedelta(hours=TRP_KEY_EXPIRY_HOURS)
|
||||
|
||||
# Use hashed key for storage (Fix #4)
|
||||
key_hash = hash_api_key(x_api_key)
|
||||
|
||||
# Update key info
|
||||
await redis_client.hset(f"apikey:{x_api_key}", mapping={
|
||||
await redis_client.hset(f"apikey:{key_hash}", mapping={
|
||||
"tier": new_tier,
|
||||
"trp_balance": str(trp_balance)
|
||||
"trp_balance": str(trp_balance),
|
||||
"expires_at": expires_at.isoformat() # Reset expiry
|
||||
})
|
||||
|
||||
tier_changed = new_tier != old_tier
|
||||
|
|
@ -1052,6 +1187,7 @@ async def refresh_auth(
|
|||
"previous_tier": old_tier,
|
||||
"trp_balance": trp_balance,
|
||||
"tier_changed": tier_changed,
|
||||
"expires_at": expires_at.isoformat(),
|
||||
"message": message
|
||||
}
|
||||
|
||||
|
|
@ -1129,6 +1265,13 @@ async def get_block(block_no: int, auth: dict = Depends(get_auth_context)):
|
|||
@app.get("/v1/address/{address}/balance")
|
||||
async def get_address_balance(address: str, auth: dict = Depends(get_auth_context)):
|
||||
"""Get address balance including native tokens."""
|
||||
# Fix #8: Validate address
|
||||
if not validate_address(address):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={"error": "invalid_address", "message": "Invalid Cardano address format"}
|
||||
)
|
||||
|
||||
cache_key = f"balance_{address}"
|
||||
cached = await get_cached(cache_key)
|
||||
if cached:
|
||||
|
|
@ -1186,6 +1329,13 @@ async def get_address_balance(address: str, auth: dict = Depends(get_auth_contex
|
|||
@app.get("/v1/address/{address}/tokens")
|
||||
async def get_address_tokens(address: str, auth: dict = Depends(get_auth_context)):
|
||||
"""Get native tokens held by an address."""
|
||||
# Fix #8: Validate address
|
||||
if not validate_address(address):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={"error": "invalid_address", "message": "Invalid Cardano address format"}
|
||||
)
|
||||
|
||||
cache_key = f"tokens_{address}"
|
||||
cached = await get_cached(cache_key)
|
||||
if cached:
|
||||
|
|
@ -1236,6 +1386,13 @@ async def get_address_transactions(
|
|||
auth: dict = Depends(get_auth_context)
|
||||
):
|
||||
"""Get transactions for an address."""
|
||||
# Fix #8: Validate address
|
||||
if not validate_address(address):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={"error": "invalid_address", "message": "Invalid Cardano address format"}
|
||||
)
|
||||
|
||||
cache_key = f"txs_{address}_{page}_{limit}_{order}"
|
||||
cached = await get_cached(cache_key)
|
||||
if cached:
|
||||
|
|
@ -1304,6 +1461,13 @@ async def get_address_transactions(
|
|||
@app.get("/v1/tx/{tx_hash}")
|
||||
async def get_transaction(tx_hash: str, auth: dict = Depends(get_auth_context)):
|
||||
"""Get transaction details by hash."""
|
||||
# Fix #8: Validate tx hash
|
||||
if not validate_tx_hash(tx_hash):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={"error": "invalid_tx_hash", "message": "Invalid transaction hash format (expected 64 hex chars)"}
|
||||
)
|
||||
|
||||
cache_key = f"tx_{tx_hash}"
|
||||
cached = await get_cached(cache_key)
|
||||
if cached:
|
||||
|
|
@ -1385,6 +1549,13 @@ async def get_transaction(tx_hash: str, auth: dict = Depends(get_auth_context)):
|
|||
@app.get("/v1/asset/{policy_id}/info")
|
||||
async def get_asset_info(policy_id: str, auth: dict = Depends(get_auth_context)):
|
||||
"""Get info about all assets under a policy ID."""
|
||||
# Fix #8: Validate policy ID
|
||||
if not validate_policy_id(policy_id):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={"error": "invalid_policy_id", "message": "Invalid policy ID format (expected 56 hex chars)"}
|
||||
)
|
||||
|
||||
cache_key = f"asset_info_{policy_id}"
|
||||
cached = await get_cached(cache_key)
|
||||
if cached:
|
||||
|
|
@ -1433,6 +1604,13 @@ async def get_asset_holders(
|
|||
auth: dict = Depends(get_auth_context)
|
||||
):
|
||||
"""Get top holders of a specific asset."""
|
||||
# Fix #8: Validate policy ID
|
||||
if not validate_policy_id(policy_id):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={"error": "invalid_policy_id", "message": "Invalid policy ID format (expected 56 hex chars)"}
|
||||
)
|
||||
|
||||
cache_key = f"holders_{policy_id}_{asset_name}_{limit}"
|
||||
cached = await get_cached(cache_key)
|
||||
if cached:
|
||||
|
|
@ -1542,19 +1720,24 @@ async def get_pool_info(pool_id: str, auth: dict = Depends(get_auth_context)):
|
|||
|
||||
@app.post("/admin/keys")
|
||||
async def create_api_key(key_data: APIKeyCreate, auth: dict = Depends(require_master_key)):
|
||||
"""Create a new API key."""
|
||||
"""Create a new API key (admin-created keys don't expire unless explicitly set)."""
|
||||
new_key = f"capi_{secrets.token_hex(24)}"
|
||||
|
||||
await redis_client.hset(f"apikey:{new_key}", mapping={
|
||||
# Fix #4: Store using hashed key
|
||||
key_hash = hash_api_key(new_key)
|
||||
|
||||
# Admin-created keys don't expire by default (Fix #10)
|
||||
await redis_client.hset(f"apikey:{key_hash}", mapping={
|
||||
"label": key_data.label,
|
||||
"tier": key_data.tier,
|
||||
"owner": key_data.owner or "",
|
||||
"trp_balance": str(key_data.trp_balance or 0),
|
||||
"created_at": datetime.now(timezone.utc).isoformat()
|
||||
# No expires_at for admin-created keys
|
||||
})
|
||||
|
||||
return APIKeyResponse(
|
||||
key=new_key,
|
||||
key=new_key, # Return raw key only once
|
||||
label=key_data.label,
|
||||
tier=key_data.tier,
|
||||
owner=key_data.owner,
|
||||
|
|
@ -1565,15 +1748,17 @@ async def create_api_key(key_data: APIKeyCreate, auth: dict = Depends(require_ma
|
|||
@app.delete("/admin/keys/{key}")
|
||||
async def revoke_api_key(key: str, auth: dict = Depends(require_master_key)):
|
||||
"""Revoke an API key."""
|
||||
deleted = await redis_client.delete(f"apikey:{key}")
|
||||
# Fix #4: Hash the key for lookup
|
||||
key_hash = hash_api_key(key)
|
||||
deleted = await redis_client.delete(f"apikey:{key_hash}")
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=404, detail={"error": "not_found", "message": "API key not found"})
|
||||
return {"status": "revoked", "key": key}
|
||||
return {"status": "revoked", "key": key[:16] + "..."}
|
||||
|
||||
|
||||
@app.get("/admin/keys")
|
||||
async def list_api_keys(auth: dict = Depends(require_master_key)):
|
||||
"""List all API keys."""
|
||||
"""List all API keys (shows metadata, not raw keys since we only store hashes)."""
|
||||
keys = []
|
||||
cursor = 0
|
||||
while True:
|
||||
|
|
@ -1581,13 +1766,16 @@ async def list_api_keys(auth: dict = Depends(require_master_key)):
|
|||
for key in found_keys:
|
||||
data = await redis_client.hgetall(key)
|
||||
if data:
|
||||
# Key is stored as hash, so we show the hash (last 8 chars) for identification
|
||||
key_hash = key.replace("apikey:", "")
|
||||
keys.append({
|
||||
"key": key.replace("apikey:", ""),
|
||||
"key_hash_suffix": f"...{key_hash[-8:]}",
|
||||
"label": data.get("label"),
|
||||
"tier": data.get("tier"),
|
||||
"owner": data.get("owner") or None,
|
||||
"trp_balance": int(data.get("trp_balance", 0)),
|
||||
"created_at": data.get("created_at")
|
||||
"created_at": data.get("created_at"),
|
||||
"expires_at": data.get("expires_at")
|
||||
})
|
||||
if cursor == 0:
|
||||
break
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue