Security fixes: wildcard CORS, JWT auth, zero-address fallback

Phase 1 security remediation from codebase analysis:

CORS fixes:
- Replace wildcard CORS with safe localhost defaults in agent-coordinator
- Replace wildcard CORS with safe localhost defaults in marketplace
- Fix 8 additional wildcard CORS instances in coordinator-api apps:
  - hermes_enhanced_app.py
  - api_gateway.py
  - modality_optimization_app.py
  - multimodal_app.py
  - gpu_multimodal_app.py
  - marketplace_enhanced_app.py
  - advanced_ai_service.py
  - adaptive_learning_app.py
- Add CORS configuration security tests

Blockchain-node auth fixes:
- JWT authentication now fails closed with clear error message
- X-Wallet-Address already gated behind TRUST_X_WALLET_ADDRESS env var
- Remove zero-address fallback from arbitration vote submission
- Add regression test for zero-address rejection in arbitration

Tests:
- Update dispute auth tests to reflect new JWT error message
- Add test_arbitration_vote_zero_address_rejected
- Add test_cors_configuration.py with 5 CORS validation tests
This commit is contained in:
aitbc
2026-05-24 19:31:26 +02:00
parent 494bd962b4
commit 13ada12b49
13 changed files with 342 additions and 63 deletions

View File

@@ -4,7 +4,7 @@ from fastapi.middleware.cors import CORSMiddleware
from aitbc.rate_limiting import RateLimitMiddleware
from .config import settings
from .config import settings, validated_cors_origins
from .exceptions import register_exception_handlers
from .lifespan import lifespan
from .middleware import register_middleware
@@ -21,7 +21,7 @@ def create_app() -> FastAPI:
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_origins=_validated_cors_origins(settings.cors_origins),
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],

View File

@@ -10,7 +10,7 @@ import uuid
from typing import Any, Dict, Optional, List
from datetime import datetime, timezone, timedelta
from fastapi import APIRouter, HTTPException, status, Request
from fastapi import APIRouter, Depends, HTTPException, status, Request
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from pydantic import BaseModel, Field, model_validator
from sqlmodel import select, delete
@@ -52,34 +52,32 @@ def get_authenticated_address(request: Request, credentials: Optional[HTTPAuthor
# Check for X-Wallet-Address header (API key authentication)
wallet_address = request.headers.get("X-Wallet-Address")
if wallet_address:
# Validate address format (basic check)
if not wallet_address.startswith("0x") or len(wallet_address) != 42:
_logger.warning(f"Invalid wallet address format in X-Wallet-Address header: {wallet_address}")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid wallet address format"
)
if os.getenv("TRUST_X_WALLET_ADDRESS", "false").lower() != "true":
_logger.warning("Rejected untrusted X-Wallet-Address header")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="X-Wallet-Address header is not trusted without explicit server configuration"
)
_logger.debug(f"Authenticated via X-Wallet-Address header: {wallet_address}")
return wallet_address
# Check for JWT Bearer token
if credentials and credentials.scheme == "Bearer":
# In a full implementation, this would validate the JWT token
# For now, we'll extract a wallet address from the token if present
# This is a placeholder for proper JWT validation
token = credentials.credentials
_logger.debug(f"JWT token provided (validation not yet implemented)")
# TODO: Implement proper JWT validation and address extraction
# For now, raise an error to require proper implementation
_logger.warning("JWT authentication attempted but not supported")
raise HTTPException(
status_code=status.HTTP_501_NOT_IMPLEMENTED,
detail="JWT authentication not yet implemented. Use X-Wallet-Address header."
status_code=status.HTTP_401_UNAUTHORIZED,
detail="JWT authentication is not supported. Use X-Wallet-Address header with TRUST_X_WALLET_ADDRESS=true for trusted internal requests."
)
# Development mode fallback
if os.getenv("DEV_MODE", "false").lower() == "true":
_logger.warning("Using development mode fallback for authentication - returning zero address")
return "0x0000000000000000000000000000000000000000"
_logger.warning("Rejected unauthenticated request in development mode")
# No valid authentication found
raise HTTPException(
@@ -1740,16 +1738,21 @@ async def submit_arbitration_vote(
# Get authenticated address from request
arbitrator_address = get_authenticated_address(http_request, credentials)
# TODO: Implement actual smart contract interaction with arbitrator authorization check
# For now, validate that we have a real address (not zero address unless in dev mode)
# Reject zero address in all modes - this is a sensitive arbitration operation
if arbitrator_address == "0x0000000000000000000000000000000000000000":
_logger.warning("Vote submission attempted with zero address - may be in dev mode")
_logger.error("Vote submission attempted with zero address - rejected")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Zero address is not allowed for arbitration operations"
)
return SubmitArbitrationVoteResponse(
success=True,
status="Submitted",
message=f"Vote submitted successfully for dispute {request.dispute_id}"
)
except HTTPException:
raise
except Exception as e:
_logger.error(f"Error submitting arbitration vote: {e}")
raise HTTPException(status_code=500, detail=f"Failed to submit vote: {str(e)}")

View File

@@ -22,7 +22,16 @@ app = FastAPI(
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_origins=[
"http://localhost:8001",
"http://localhost:8011",
"http://localhost:8016",
"http://localhost:9001",
"http://127.0.0.1:8001",
"http://127.0.0.1:8011",
"http://127.0.0.1:8016",
"http://127.0.0.1:9001",
],
allow_credentials=True,
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
allow_headers=["*"],

View File

@@ -20,7 +20,16 @@ app = FastAPI(
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_origins=[
"http://localhost:8001",
"http://localhost:8011",
"http://localhost:8016",
"http://localhost:9001",
"http://127.0.0.1:8001",
"http://127.0.0.1:8011",
"http://127.0.0.1:8016",
"http://127.0.0.1:9001",
],
allow_credentials=True,
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
allow_headers=["*"],

View File

@@ -21,7 +21,16 @@ app = FastAPI(
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_origins=[
"http://localhost:8001",
"http://localhost:8011",
"http://localhost:8016",
"http://localhost:9001",
"http://127.0.0.1:8001",
"http://127.0.0.1:8011",
"http://127.0.0.1:8016",
"http://127.0.0.1:9001",
],
allow_credentials=True,
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
allow_headers=["*"],

View File

@@ -74,7 +74,16 @@ app = FastAPI(
# CORS middleware
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_origins=[
"http://localhost:8001",
"http://localhost:8011",
"http://localhost:8016",
"http://localhost:9001",
"http://127.0.0.1:8001",
"http://127.0.0.1:8011",
"http://127.0.0.1:8016",
"http://127.0.0.1:9001",
],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],

View File

@@ -466,7 +466,16 @@ app = FastAPI(
# CORS middleware
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_origins=[
"http://localhost:8001",
"http://localhost:8011",
"http://localhost:8016",
"http://localhost:9001",
"http://127.0.0.1:8001",
"http://127.0.0.1:8011",
"http://127.0.0.1:8016",
"http://127.0.0.1:9001",
],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],

View File

@@ -21,7 +21,16 @@ app = FastAPI(
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_origins=[
"http://localhost:8001",
"http://localhost:8011",
"http://localhost:8016",
"http://localhost:9001",
"http://127.0.0.1:8001",
"http://127.0.0.1:8011",
"http://127.0.0.1:8016",
"http://127.0.0.1:9001",
],
allow_credentials=True,
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
allow_headers=["*"],

View File

@@ -21,7 +21,16 @@ app = FastAPI(
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_origins=[
"http://localhost:8001",
"http://localhost:8011",
"http://localhost:8016",
"http://localhost:9001",
"http://127.0.0.1:8001",
"http://127.0.0.1:8011",
"http://127.0.0.1:8016",
"http://127.0.0.1:9001",
],
allow_credentials=True,
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
allow_headers=["*"],

View File

@@ -21,7 +21,16 @@ app = FastAPI(
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_origins=[
"http://localhost:8001",
"http://localhost:8011",
"http://localhost:8016",
"http://localhost:9001",
"http://127.0.0.1:8001",
"http://127.0.0.1:8011",
"http://127.0.0.1:8016",
"http://127.0.0.1:9001",
],
allow_credentials=True,
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
allow_headers=["*"],

View File

@@ -5,6 +5,7 @@ Miners register GPU offerings, choose chains, and confirm deals
"""
import json
import os
import uuid
from datetime import datetime, timedelta
from typing import Dict, List, Any, Optional
@@ -14,6 +15,28 @@ from fastapi.responses import JSONResponse
from pydantic import BaseModel
import uvicorn
DEFAULT_CORS_ORIGINS = [
"http://localhost:8001",
"http://localhost:8011",
"http://localhost:8016",
"http://localhost:9001",
"http://127.0.0.1:8001",
"http://127.0.0.1:8011",
"http://127.0.0.1:8016",
"http://127.0.0.1:9001",
]
def get_cors_origins() -> List[str]:
raw_origins = os.getenv("AITBC_MARKETPLACE_CORS_ORIGINS")
if not raw_origins:
return DEFAULT_CORS_ORIGINS
origins = [origin.strip() for origin in raw_origins.split(",") if origin.strip()]
if "*" in origins:
raise ValueError("Wildcard CORS origins are not allowed when credentials are enabled")
return origins
app = FastAPI(
title="AITBC Agent-First GPU Marketplace",
description="GPU trading marketplace where miners register offerings and confirm deals",
@@ -23,7 +46,7 @@ app = FastAPI(
# Add CORS middleware
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_origins=get_cors_origins(),
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],

View File

@@ -5,18 +5,26 @@ Tests for missing authentication, unauthorized access, and invalid tokens.
import pytest
import os
import sys
from pathlib import Path
import pytest_asyncio
from httpx import AsyncClient, ASGITransport
from fastapi import status
repo_root = Path(__file__).resolve().parents[2]
blockchain_src = repo_root / "apps" / "blockchain-node" / "src"
if str(blockchain_src) not in sys.path:
sys.path.insert(0, str(blockchain_src))
@pytest.mark.asyncio
class TestDisputeAuthentication:
"""Test authentication requirements for dispute endpoints"""
@pytest.fixture
@pytest_asyncio.fixture
async def client(self):
"""Create test client for blockchain node RPC"""
from apps.blockchain_node.src.aitbc_chain.rpc.router import router
from aitbc_chain.rpc.router import router
from fastapi import FastAPI
app = FastAPI()
@@ -24,7 +32,9 @@ class TestDisputeAuthentication:
# Set DEV_MODE to false for production-like testing
original_dev_mode = os.getenv("DEV_MODE")
original_trust_header = os.getenv("TRUST_X_WALLET_ADDRESS")
os.environ["DEV_MODE"] = "false"
os.environ["TRUST_X_WALLET_ADDRESS"] = "false"
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
@@ -41,7 +51,7 @@ class TestDisputeAuthentication:
response = await client.post(
"/rpc/disputes/file",
json={
"agreement_id": "test_agreement_1",
"agreement_id": 1,
"respondent": "0x1234567890123456789012345678901234567890",
"dispute_type": "payment_dispute",
"reason": "Test dispute",
@@ -57,7 +67,7 @@ class TestDisputeAuthentication:
response = await client.post(
"/rpc/disputes/file",
json={
"agreement_id": "test_agreement_1",
"agreement_id": 1,
"respondent": "0x1234567890123456789012345678901234567890",
"dispute_type": "payment_dispute",
"reason": "Test dispute",
@@ -128,10 +138,11 @@ class TestDisputeAuthentication:
async def test_file_dispute_with_valid_wallet_address(self, client):
"""Test that filing a dispute with valid wallet address header succeeds (or returns expected error)"""
os.environ["TRUST_X_WALLET_ADDRESS"] = "true"
response = await client.post(
"/rpc/disputes/file",
json={
"agreement_id": "test_agreement_1",
"agreement_id": 1,
"respondent": "0x1234567890123456789012345678901234567890",
"dispute_type": "payment_dispute",
"reason": "Test dispute",
@@ -144,12 +155,29 @@ class TestDisputeAuthentication:
# May be 500 if dispute service is not available, which is acceptable
assert response.status_code != status.HTTP_401_UNAUTHORIZED
async def test_jwt_token_not_implemented(self, client):
"""Test that JWT token authentication returns 501 (not yet implemented)"""
async def test_valid_wallet_address_header_is_not_trusted_by_default(self, client):
"""Test that filing a dispute with an untrusted wallet address header returns 401"""
response = await client.post(
"/rpc/disputes/file",
json={
"agreement_id": "test_agreement_1",
"agreement_id": 1,
"respondent": "0x1234567890123456789012345678901234567890",
"dispute_type": "payment_dispute",
"reason": "Test dispute",
"evidence_hash": "0xabcdef"
},
headers={"X-Wallet-Address": "0x1234567890123456789012345678901234567890"}
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
assert "not trusted" in response.json()["detail"]
async def test_jwt_token_not_configured(self, client):
"""Test that JWT token authentication fails closed with clear error"""
response = await client.post(
"/rpc/disputes/file",
json={
"agreement_id": 1,
"respondent": "0x1234567890123456789012345678901234567890",
"dispute_type": "payment_dispute",
"reason": "Test dispute",
@@ -158,18 +186,18 @@ class TestDisputeAuthentication:
headers={"Authorization": "Bearer test_token"}
)
assert response.status_code == status.HTTP_501_NOT_IMPLEMENTED
assert "JWT authentication not yet implemented" in response.json()["detail"]
assert response.status_code == status.HTTP_401_UNAUTHORIZED
assert "JWT authentication is not supported" in response.json()["detail"]
@pytest.mark.asyncio
class TestDisputeAuthDevMode:
"""Test authentication behavior in development mode"""
@pytest.fixture
@pytest_asyncio.fixture
async def dev_client(self):
"""Create test client with DEV_MODE enabled"""
from apps.blockchain_node.src.aitbc_chain.rpc.router import router
from aitbc_chain.rpc.router import router
from fastapi import FastAPI
app = FastAPI()
@@ -177,7 +205,9 @@ class TestDisputeAuthDevMode:
# Set DEV_MODE to true
original_dev_mode = os.getenv("DEV_MODE")
original_trust_header = os.getenv("TRUST_X_WALLET_ADDRESS")
os.environ["DEV_MODE"] = "true"
os.environ["TRUST_X_WALLET_ADDRESS"] = "false"
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
@@ -189,12 +219,12 @@ class TestDisputeAuthDevMode:
else:
os.environ["DEV_MODE"] = original_dev_mode
async def test_file_dispute_dev_mode_fallback(self, dev_client):
"""Test that in dev mode, missing auth uses zero address fallback"""
async def test_file_dispute_dev_mode_fails_closed(self, dev_client):
"""Test that dev mode no longer uses a zero address fallback"""
response = await dev_client.post(
"/rpc/disputes/file",
json={
"agreement_id": "test_agreement_1",
"agreement_id": 1,
"respondent": "0x1234567890123456789012345678901234567890",
"dispute_type": "payment_dispute",
"reason": "Test dispute",
@@ -202,6 +232,22 @@ class TestDisputeAuthDevMode:
}
)
# In dev mode, should not return 401 (uses zero address fallback)
# May return 500 if dispute service is not available
assert response.status_code != status.HTTP_401_UNAUTHORIZED
assert response.status_code == status.HTTP_401_UNAUTHORIZED
assert "Authentication required" in response.json()["detail"]
async def test_arbitration_vote_zero_address_rejected(self, dev_client):
"""Test that zero address is rejected in arbitration vote submission"""
os.environ["TRUST_X_WALLET_ADDRESS"] = "true"
response = await dev_client.post(
"/rpc/disputes/vote",
json={
"dispute_id": 1,
"vote_in_favor_of_initiator": True,
"confidence": 90,
"reasoning": "Test reasoning"
},
headers={"X-Wallet-Address": "0x0000000000000000000000000000000000000000"}
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
assert "Zero address is not allowed" in response.json()["detail"]

View File

@@ -0,0 +1,135 @@
"""
CORS configuration security tests.
Validates that wildcard CORS is not used with allow_credentials=True.
"""
import pytest
import os
import sys
from pathlib import Path
def test_agent_coordinator_cors_rejects_wildcard():
"""Test that agent-coordinator config rejects wildcard origins"""
repo_root = Path(__file__).resolve().parents[2]
agent_coordinator_src = repo_root / "apps" / "agent-coordinator" / "src"
if str(agent_coordinator_src) not in sys.path:
sys.path.insert(0, str(agent_coordinator_src))
# Set required secret_key to avoid validation error
os.environ["SECRET_KEY"] = "test_secret_key_for_testing"
from app.config import validated_cors_origins
with pytest.raises(ValueError, match="Wildcard CORS origins are not allowed"):
validated_cors_origins(["*"])
# Clean up
os.environ.pop("SECRET_KEY", None)
def test_agent_coordinator_cors_accepts_localhost():
"""Test that agent-coordinator config accepts localhost origins"""
repo_root = Path(__file__).resolve().parents[2]
agent_coordinator_src = repo_root / "apps" / "agent-coordinator" / "src"
if str(agent_coordinator_src) not in sys.path:
sys.path.insert(0, str(agent_coordinator_src))
# Set required secret_key to avoid validation error
os.environ["SECRET_KEY"] = "test_secret_key_for_testing"
from app.config import validated_cors_origins
origins = [
"http://localhost:8001",
"http://localhost:9001",
"http://127.0.0.1:8001",
]
result = validated_cors_origins(origins)
assert result == origins
# Clean up
os.environ.pop("SECRET_KEY", None)
def test_marketplace_cors_rejects_wildcard():
"""Test that marketplace rejects wildcard origins via environment variable"""
repo_root = Path(__file__).resolve().parents[2]
marketplace_src = repo_root / "apps" / "marketplace"
if str(marketplace_src) not in sys.path:
sys.path.insert(0, str(marketplace_src))
# Set environment variable with wildcard
os.environ["AITBC_MARKETPLACE_CORS_ORIGINS"] = "*"
# The marketplace module raises ValueError on import when wildcard is set
# This is the expected behavior
with pytest.raises(ValueError, match="Wildcard CORS origins are not allowed"):
import importlib.util
spec = importlib.util.spec_from_file_location("agent_marketplace", marketplace_src / "agent_marketplace.py")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
# Clean up
os.environ.pop("AITBC_MARKETPLACE_CORS_ORIGINS", None)
def test_marketplace_cors_accepts_localhost():
"""Test that marketplace accepts localhost origins via environment variable"""
repo_root = Path(__file__).resolve().parents[2]
marketplace_src = repo_root / "apps" / "marketplace"
if str(marketplace_src) not in sys.path:
sys.path.insert(0, str(marketplace_src))
os.environ["AITBC_MARKETPLACE_CORS_ORIGINS"] = "http://localhost:8001,http://localhost:9001"
# Import the function directly from the file
import importlib.util
spec = importlib.util.spec_from_file_location("agent_marketplace", marketplace_src / "agent_marketplace.py")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
result = module.get_cors_origins()
assert "http://localhost:8001" in result
assert "http://localhost:9001" in result
# Clean up
os.environ.pop("AITBC_MARKETPLACE_CORS_ORIGINS", None)
def test_no_wildcard_cors_in_coordinator_api_apps():
"""Scan coordinator-api apps for wildcard CORS with credentials"""
import re
repo_root = Path(__file__).resolve().parents[2]
coordinator_src = repo_root / "apps" / "coordinator-api" / "src"
files_to_check = [
coordinator_src / "app" / "contexts" / "hermes" / "routers" / "hermes_enhanced_app.py",
coordinator_src / "app" / "services" / "enterprise_integration" / "api_gateway.py",
coordinator_src / "app" / "services" / "modality_optimization_app.py",
coordinator_src / "app" / "services" / "multimodal_app.py",
coordinator_src / "app" / "services" / "gpu_multimodal_app.py",
coordinator_src / "app" / "routers" / "marketplace_enhanced_app.py",
coordinator_src / "app" / "services" / "advanced_ai_service.py",
coordinator_src / "app" / "services" / "adaptive_learning_app.py",
]
wildcard_pattern = re.compile(r'allow_origins\s*=\s*\["\*"\]')
credentials_pattern = re.compile(r'allow_credentials\s*=\s*True')
for file_path in files_to_check:
if not file_path.exists():
continue
content = file_path.read_text()
has_wildcard = wildcard_pattern.search(content) is not None
has_credentials = credentials_pattern.search(content) is not None
# If both wildcard and credentials are present, fail the test
if has_wildcard and has_credentials:
pytest.fail(f"File {file_path} contains wildcard CORS with credentials enabled")