|
|
|
|
@@ -207,6 +207,37 @@ def _openai_error(message: str, err_type: str = "invalid_request_error", param:
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# SECURITY FIX (V-013): Safe error handling to prevent info disclosure
|
|
|
|
|
def _handle_error_securely(exception: Exception, context: str = "") -> Dict[str, Any]:
|
|
|
|
|
"""Handle errors securely - log full details, return generic message.
|
|
|
|
|
|
|
|
|
|
Prevents information disclosure by not exposing internal error details
|
|
|
|
|
to API clients. Logs full stack trace internally for debugging.
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
exception: The caught exception
|
|
|
|
|
context: Additional context about where the error occurred
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
OpenAI-style error response with generic message
|
|
|
|
|
"""
|
|
|
|
|
import traceback
|
|
|
|
|
|
|
|
|
|
# Log full error details internally
|
|
|
|
|
error_id = str(uuid.uuid4())[:8]
|
|
|
|
|
logger.error(
|
|
|
|
|
f"Internal error [{error_id}] in {context}: {exception}\n"
|
|
|
|
|
f"{traceback.format_exc()}"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Return generic error to client - no internal details
|
|
|
|
|
return _openai_error(
|
|
|
|
|
message=f"An internal error occurred. Reference: {error_id}",
|
|
|
|
|
err_type="internal_error",
|
|
|
|
|
code="internal_error"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if AIOHTTP_AVAILABLE:
|
|
|
|
|
@web.middleware
|
|
|
|
|
async def body_limit_middleware(request, handler):
|
|
|
|
|
@@ -241,6 +272,43 @@ else:
|
|
|
|
|
security_headers_middleware = None # type: ignore[assignment]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# SECURITY FIX (V-016): Rate limiting middleware
|
|
|
|
|
if AIOHTTP_AVAILABLE:
|
|
|
|
|
@web.middleware
|
|
|
|
|
async def rate_limit_middleware(request, handler):
|
|
|
|
|
"""Apply rate limiting per client IP.
|
|
|
|
|
|
|
|
|
|
Returns 429 Too Many Requests if rate limit exceeded.
|
|
|
|
|
Configurable via API_SERVER_RATE_LIMIT env var (requests per minute).
|
|
|
|
|
"""
|
|
|
|
|
# Skip rate limiting for health checks
|
|
|
|
|
if request.path == "/health":
|
|
|
|
|
return await handler(request)
|
|
|
|
|
|
|
|
|
|
# Get client IP (respecting X-Forwarded-For if behind proxy)
|
|
|
|
|
client_ip = request.headers.get("X-Forwarded-For", request.remote)
|
|
|
|
|
if client_ip and "," in client_ip:
|
|
|
|
|
client_ip = client_ip.split(",")[0].strip()
|
|
|
|
|
|
|
|
|
|
limiter = _get_rate_limiter()
|
|
|
|
|
if not limiter.acquire(client_ip):
|
|
|
|
|
retry_after = limiter.get_retry_after(client_ip)
|
|
|
|
|
logger.warning(f"Rate limit exceeded for {client_ip}")
|
|
|
|
|
return web.json_response(
|
|
|
|
|
_openai_error(
|
|
|
|
|
f"Rate limit exceeded. Try again in {retry_after} seconds.",
|
|
|
|
|
err_type="rate_limit_error",
|
|
|
|
|
code="rate_limit_exceeded"
|
|
|
|
|
),
|
|
|
|
|
status=429,
|
|
|
|
|
headers={"Retry-After": str(retry_after)}
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
return await handler(request)
|
|
|
|
|
else:
|
|
|
|
|
rate_limit_middleware = None # type: ignore[assignment]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class _IdempotencyCache:
|
|
|
|
|
"""In-memory idempotency cache with TTL and basic LRU semantics."""
|
|
|
|
|
def __init__(self, max_items: int = 1000, ttl_seconds: int = 300):
|
|
|
|
|
@@ -273,6 +341,59 @@ class _IdempotencyCache:
|
|
|
|
|
_idem_cache = _IdempotencyCache()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# SECURITY FIX (V-016): Rate limiting
|
|
|
|
|
class _RateLimiter:
|
|
|
|
|
"""Token bucket rate limiter per client IP.
|
|
|
|
|
|
|
|
|
|
Default: 100 requests per minute per IP.
|
|
|
|
|
Configurable via API_SERVER_RATE_LIMIT env var (requests per minute).
|
|
|
|
|
"""
|
|
|
|
|
def __init__(self, requests_per_minute: int = 100):
|
|
|
|
|
from collections import defaultdict
|
|
|
|
|
self._buckets = defaultdict(lambda: {"tokens": requests_per_minute, "last": 0})
|
|
|
|
|
self._rate = requests_per_minute / 60.0 # tokens per second
|
|
|
|
|
self._max_tokens = requests_per_minute
|
|
|
|
|
self._lock = threading.Lock()
|
|
|
|
|
|
|
|
|
|
def _get_bucket(self, key: str) -> dict:
|
|
|
|
|
import time
|
|
|
|
|
with self._lock:
|
|
|
|
|
bucket = self._buckets[key]
|
|
|
|
|
now = time.time()
|
|
|
|
|
elapsed = now - bucket["last"]
|
|
|
|
|
bucket["last"] = now
|
|
|
|
|
# Add tokens based on elapsed time
|
|
|
|
|
bucket["tokens"] = min(
|
|
|
|
|
self._max_tokens,
|
|
|
|
|
bucket["tokens"] + elapsed * self._rate
|
|
|
|
|
)
|
|
|
|
|
return bucket
|
|
|
|
|
|
|
|
|
|
def acquire(self, key: str) -> bool:
|
|
|
|
|
"""Try to acquire a token. Returns True if allowed, False if rate limited."""
|
|
|
|
|
bucket = self._get_bucket(key)
|
|
|
|
|
with self._lock:
|
|
|
|
|
if bucket["tokens"] >= 1:
|
|
|
|
|
bucket["tokens"] -= 1
|
|
|
|
|
return True
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
def get_retry_after(self, key: str) -> int:
|
|
|
|
|
"""Get seconds until next token is available."""
|
|
|
|
|
return 1 # Simplified - return 1 second
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
_rate_limiter = None
|
|
|
|
|
|
|
|
|
|
def _get_rate_limiter() -> _RateLimiter:
|
|
|
|
|
global _rate_limiter
|
|
|
|
|
if _rate_limiter is None:
|
|
|
|
|
# Parse rate limit from env (default 100 req/min)
|
|
|
|
|
rate_limit = int(os.getenv("API_SERVER_RATE_LIMIT", "100"))
|
|
|
|
|
_rate_limiter = _RateLimiter(rate_limit)
|
|
|
|
|
return _rate_limiter
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _make_request_fingerprint(body: Dict[str, Any], keys: List[str]) -> str:
|
|
|
|
|
from hashlib import sha256
|
|
|
|
|
subset = {k: body.get(k) for k in keys}
|
|
|
|
|
@@ -292,7 +413,29 @@ class APIServerAdapter(BasePlatformAdapter):
|
|
|
|
|
extra = config.extra or {}
|
|
|
|
|
self._host: str = extra.get("host", os.getenv("API_SERVER_HOST", DEFAULT_HOST))
|
|
|
|
|
self._port: int = int(extra.get("port", os.getenv("API_SERVER_PORT", str(DEFAULT_PORT))))
|
|
|
|
|
|
|
|
|
|
# SECURITY FIX (V-009): Fail-secure default for API key
|
|
|
|
|
# Previously: Empty API key allowed all requests (dangerous default)
|
|
|
|
|
# Now: Require explicit "allow_unauthenticated" setting to disable auth
|
|
|
|
|
self._api_key: str = extra.get("key", os.getenv("API_SERVER_KEY", ""))
|
|
|
|
|
self._allow_unauthenticated: bool = extra.get(
|
|
|
|
|
"allow_unauthenticated",
|
|
|
|
|
os.getenv("API_SERVER_ALLOW_UNAUTHENTICATED", "").lower() in ("true", "1", "yes")
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# SECURITY: Log warning if no API key configured
|
|
|
|
|
if not self._api_key and not self._allow_unauthenticated:
|
|
|
|
|
logger.warning(
|
|
|
|
|
"API_SERVER_KEY not configured. All requests will be rejected. "
|
|
|
|
|
"Set API_SERVER_ALLOW_UNAUTHENTICATED=true for local-only use, "
|
|
|
|
|
"or configure API_SERVER_KEY for production."
|
|
|
|
|
)
|
|
|
|
|
elif not self._api_key and self._allow_unauthenticated:
|
|
|
|
|
logger.warning(
|
|
|
|
|
"API_SERVER running without authentication. "
|
|
|
|
|
"This is only safe for local-only deployments."
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
self._cors_origins: tuple[str, ...] = self._parse_cors_origins(
|
|
|
|
|
extra.get("cors_origins", os.getenv("API_SERVER_CORS_ORIGINS", "")),
|
|
|
|
|
)
|
|
|
|
|
@@ -317,15 +460,22 @@ class APIServerAdapter(BasePlatformAdapter):
|
|
|
|
|
return tuple(str(item).strip() for item in items if str(item).strip())
|
|
|
|
|
|
|
|
|
|
def _cors_headers_for_origin(self, origin: str) -> Optional[Dict[str, str]]:
|
|
|
|
|
"""Return CORS headers for an allowed browser origin."""
|
|
|
|
|
"""Return CORS headers for an allowed browser origin.
|
|
|
|
|
|
|
|
|
|
SECURITY FIX (V-008): Never allow wildcard "*" with credentials.
|
|
|
|
|
If "*" is configured, we reject the request to prevent security issues.
|
|
|
|
|
"""
|
|
|
|
|
if not origin or not self._cors_origins:
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
# SECURITY FIX (V-008): Reject wildcard CORS origins
|
|
|
|
|
# Wildcard with credentials is a security vulnerability
|
|
|
|
|
if "*" in self._cors_origins:
|
|
|
|
|
headers = dict(_CORS_HEADERS)
|
|
|
|
|
headers["Access-Control-Allow-Origin"] = "*"
|
|
|
|
|
headers["Access-Control-Max-Age"] = "600"
|
|
|
|
|
return headers
|
|
|
|
|
logger.warning(
|
|
|
|
|
"CORS wildcard '*' is not allowed for security reasons. "
|
|
|
|
|
"Please configure specific origins in API_SERVER_CORS_ORIGINS."
|
|
|
|
|
)
|
|
|
|
|
return None # Reject wildcard - too dangerous
|
|
|
|
|
|
|
|
|
|
if origin not in self._cors_origins:
|
|
|
|
|
return None
|
|
|
|
|
@@ -355,10 +505,22 @@ class APIServerAdapter(BasePlatformAdapter):
|
|
|
|
|
Validate Bearer token from Authorization header.
|
|
|
|
|
|
|
|
|
|
Returns None if auth is OK, or a 401 web.Response on failure.
|
|
|
|
|
If no API key is configured, all requests are allowed.
|
|
|
|
|
|
|
|
|
|
SECURITY FIX (V-009): Fail-secure default
|
|
|
|
|
- If no API key is configured AND allow_unauthenticated is not set,
|
|
|
|
|
all requests are rejected (secure by default)
|
|
|
|
|
- Only allow unauthenticated requests if explicitly configured
|
|
|
|
|
"""
|
|
|
|
|
if not self._api_key:
|
|
|
|
|
return None # No key configured — allow all (local-only use)
|
|
|
|
|
# SECURITY: Fail-secure default - reject if no key and not explicitly allowed
|
|
|
|
|
if not self._api_key and not self._allow_unauthenticated:
|
|
|
|
|
return web.json_response(
|
|
|
|
|
{"error": {"message": "Authentication required. Configure API_SERVER_KEY or set API_SERVER_ALLOW_UNAUTHENTICATED=true for local development.", "type": "authentication_error", "code": "auth_required"}},
|
|
|
|
|
status=401,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Allow unauthenticated requests only if explicitly configured
|
|
|
|
|
if not self._api_key and self._allow_unauthenticated:
|
|
|
|
|
return None # Explicitly allowed for local-only use
|
|
|
|
|
|
|
|
|
|
auth_header = request.headers.get("Authorization", "")
|
|
|
|
|
if auth_header.startswith("Bearer "):
|
|
|
|
|
@@ -953,7 +1115,8 @@ class APIServerAdapter(BasePlatformAdapter):
|
|
|
|
|
jobs = self._cron_list(include_disabled=include_disabled)
|
|
|
|
|
return web.json_response({"jobs": jobs})
|
|
|
|
|
except Exception as e:
|
|
|
|
|
return web.json_response({"error": str(e)}, status=500)
|
|
|
|
|
# SECURITY FIX (V-013): Use secure error handling
|
|
|
|
|
return web.json_response(_handle_error_securely(e, "list_jobs"), status=500)
|
|
|
|
|
|
|
|
|
|
async def _handle_create_job(self, request: "web.Request") -> "web.Response":
|
|
|
|
|
"""POST /api/jobs — create a new cron job."""
|
|
|
|
|
@@ -1001,7 +1164,8 @@ class APIServerAdapter(BasePlatformAdapter):
|
|
|
|
|
job = self._cron_create(**kwargs)
|
|
|
|
|
return web.json_response({"job": job})
|
|
|
|
|
except Exception as e:
|
|
|
|
|
return web.json_response({"error": str(e)}, status=500)
|
|
|
|
|
# SECURITY FIX (V-013): Use secure error handling
|
|
|
|
|
return web.json_response(_handle_error_securely(e, "list_jobs"), status=500)
|
|
|
|
|
|
|
|
|
|
async def _handle_get_job(self, request: "web.Request") -> "web.Response":
|
|
|
|
|
"""GET /api/jobs/{job_id} — get a single cron job."""
|
|
|
|
|
@@ -1020,7 +1184,8 @@ class APIServerAdapter(BasePlatformAdapter):
|
|
|
|
|
return web.json_response({"error": "Job not found"}, status=404)
|
|
|
|
|
return web.json_response({"job": job})
|
|
|
|
|
except Exception as e:
|
|
|
|
|
return web.json_response({"error": str(e)}, status=500)
|
|
|
|
|
# SECURITY FIX (V-013): Use secure error handling
|
|
|
|
|
return web.json_response(_handle_error_securely(e, "list_jobs"), status=500)
|
|
|
|
|
|
|
|
|
|
async def _handle_update_job(self, request: "web.Request") -> "web.Response":
|
|
|
|
|
"""PATCH /api/jobs/{job_id} — update a cron job."""
|
|
|
|
|
@@ -1053,7 +1218,8 @@ class APIServerAdapter(BasePlatformAdapter):
|
|
|
|
|
return web.json_response({"error": "Job not found"}, status=404)
|
|
|
|
|
return web.json_response({"job": job})
|
|
|
|
|
except Exception as e:
|
|
|
|
|
return web.json_response({"error": str(e)}, status=500)
|
|
|
|
|
# SECURITY FIX (V-013): Use secure error handling
|
|
|
|
|
return web.json_response(_handle_error_securely(e, "list_jobs"), status=500)
|
|
|
|
|
|
|
|
|
|
async def _handle_delete_job(self, request: "web.Request") -> "web.Response":
|
|
|
|
|
"""DELETE /api/jobs/{job_id} — delete a cron job."""
|
|
|
|
|
@@ -1072,7 +1238,8 @@ class APIServerAdapter(BasePlatformAdapter):
|
|
|
|
|
return web.json_response({"error": "Job not found"}, status=404)
|
|
|
|
|
return web.json_response({"ok": True})
|
|
|
|
|
except Exception as e:
|
|
|
|
|
return web.json_response({"error": str(e)}, status=500)
|
|
|
|
|
# SECURITY FIX (V-013): Use secure error handling
|
|
|
|
|
return web.json_response(_handle_error_securely(e, "list_jobs"), status=500)
|
|
|
|
|
|
|
|
|
|
async def _handle_pause_job(self, request: "web.Request") -> "web.Response":
|
|
|
|
|
"""POST /api/jobs/{job_id}/pause — pause a cron job."""
|
|
|
|
|
@@ -1091,7 +1258,8 @@ class APIServerAdapter(BasePlatformAdapter):
|
|
|
|
|
return web.json_response({"error": "Job not found"}, status=404)
|
|
|
|
|
return web.json_response({"job": job})
|
|
|
|
|
except Exception as e:
|
|
|
|
|
return web.json_response({"error": str(e)}, status=500)
|
|
|
|
|
# SECURITY FIX (V-013): Use secure error handling
|
|
|
|
|
return web.json_response(_handle_error_securely(e, "list_jobs"), status=500)
|
|
|
|
|
|
|
|
|
|
async def _handle_resume_job(self, request: "web.Request") -> "web.Response":
|
|
|
|
|
"""POST /api/jobs/{job_id}/resume — resume a paused cron job."""
|
|
|
|
|
@@ -1110,7 +1278,8 @@ class APIServerAdapter(BasePlatformAdapter):
|
|
|
|
|
return web.json_response({"error": "Job not found"}, status=404)
|
|
|
|
|
return web.json_response({"job": job})
|
|
|
|
|
except Exception as e:
|
|
|
|
|
return web.json_response({"error": str(e)}, status=500)
|
|
|
|
|
# SECURITY FIX (V-013): Use secure error handling
|
|
|
|
|
return web.json_response(_handle_error_securely(e, "list_jobs"), status=500)
|
|
|
|
|
|
|
|
|
|
async def _handle_run_job(self, request: "web.Request") -> "web.Response":
|
|
|
|
|
"""POST /api/jobs/{job_id}/run — trigger immediate execution."""
|
|
|
|
|
@@ -1129,7 +1298,8 @@ class APIServerAdapter(BasePlatformAdapter):
|
|
|
|
|
return web.json_response({"error": "Job not found"}, status=404)
|
|
|
|
|
return web.json_response({"job": job})
|
|
|
|
|
except Exception as e:
|
|
|
|
|
return web.json_response({"error": str(e)}, status=500)
|
|
|
|
|
# SECURITY FIX (V-013): Use secure error handling
|
|
|
|
|
return web.json_response(_handle_error_securely(e, "list_jobs"), status=500)
|
|
|
|
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
# Output extraction helper
|
|
|
|
|
@@ -1241,7 +1411,8 @@ class APIServerAdapter(BasePlatformAdapter):
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
mws = [mw for mw in (cors_middleware, body_limit_middleware, security_headers_middleware) if mw is not None]
|
|
|
|
|
# SECURITY FIX (V-016): Add rate limiting middleware
|
|
|
|
|
mws = [mw for mw in (cors_middleware, body_limit_middleware, security_headers_middleware, rate_limit_middleware) if mw is not None]
|
|
|
|
|
self._app = web.Application(middlewares=mws)
|
|
|
|
|
self._app["api_server_adapter"] = self
|
|
|
|
|
self._app.router.add_get("/health", self._handle_health)
|
|
|
|
|
|