diff --git a/backend/app/routes/admin.py b/backend/app/routes/admin.py index 4a843e9b..0b053692 100644 --- a/backend/app/routes/admin.py +++ b/backend/app/routes/admin.py @@ -12,9 +12,12 @@ Pricing: GET /api/admin/pricing """ import logging +import os +import time from datetime import datetime, timezone, timedelta from quart import Blueprint, jsonify, request from bson import ObjectId +from openai import AsyncOpenAI from app.auth.quart_jwt import jwt_required, get_jwt_identity from app.utils import admin_required, make_serializable @@ -678,12 +681,18 @@ async def get_ai_config(): }), 200 +_PROVIDER_ALLOWED_FIELDS = {'id', 'name', 'enabled', 'endpoint', 'api_key', 'models'} +_MODEL_ALLOWED_FIELDS = {'id', 'display_name', 'role', 'enabled'} + + @admin_bp.route('/ai-config', methods=['PUT']) @jwt_required() @admin_required async def update_ai_config(): """PUT /api/admin/ai-config — update AI provider config. - API key is only updated when passed non-empty. + API key is only updated when passed as a non-empty, non-masked value. + Provider objects are merged against a whitelist of allowed fields. + active_provider/model values are validated to be non-empty strings. """ body = await request.get_json() or {} settings = await get_settings() @@ -691,25 +700,38 @@ async def update_ai_config(): update: dict = {} - if 'active_provider' in body: - update['active_provider'] = body['active_provider'] - if 'active_main_model' in body: - update['active_main_model'] = body['active_main_model'] - if 'active_mini_model' in body: - update['active_mini_model'] = body['active_mini_model'] + for field in ('active_provider', 'active_main_model', 'active_mini_model'): + if field in body: + val = body[field] + if not isinstance(val, str) or not val.strip(): + return jsonify({'message': f'{field} must be a non-empty string'}), 400 + update[field] = val.strip() if 'ai_providers' in body: + if not isinstance(body['ai_providers'], list): + return jsonify({'message': 'ai_providers must be a list'}), 400 merged = [] for p in body['ai_providers']: pid = p.get('id') - existing = existing_providers.get(pid, {}) - mp = {**existing, **p} + if not pid or pid not in existing_providers: + continue # silently skip unknown providers — never create new ones from client + existing = existing_providers[pid] + # Whitelist: only allow known fields from client + safe_update = {k: v for k, v in p.items() if k in _PROVIDER_ALLOWED_FIELDS} + mp = {**existing, **safe_update} + # Preserve models whitelist too + if 'models' in safe_update and isinstance(safe_update['models'], list): + mp['models'] = [ + {k: v for k, v in m.items() if k in _MODEL_ALLOWED_FIELDS} + for m in safe_update['models'] + ] # Only replace key if a non-empty, non-masked value is supplied - new_key = p.get('api_key', '') + new_key = safe_update.get('api_key', '') if not new_key or '***' in new_key: mp['api_key'] = existing.get('api_key', '') merged.append(mp) - update['ai_providers'] = merged + if merged: + update['ai_providers'] = merged await update_settings(update) return jsonify({'ok': True}), 200 @@ -719,28 +741,28 @@ async def update_ai_config(): @jwt_required() @admin_required async def test_ai_config(): - """POST /api/admin/ai-config/test — send a minimal request to the configured endpoint.""" - import time - from openai import AsyncOpenAI + """POST /api/admin/ai-config/test — test connection using DB-stored provider config. + Accepts optional provider_id to select which provider to test. + Never reads endpoint/api_key from the request body to prevent SSRF. + """ body = await request.get_json() or {} settings = await get_settings() providers = {p['id']: p for p in settings.get('ai_providers', [])} provider_id = body.get('provider_id') or settings.get('active_provider', 'azure_openai') - provider = providers.get(provider_id, {}) - endpoint = body.get('endpoint') or provider.get('endpoint', '') - api_key = body.get('api_key', '') - # If masked or empty, fall back to stored key - if not api_key or '***' in api_key: - api_key = provider.get('api_key', '') - # Final fallback: environment variable - if not api_key: - import os - api_key = os.environ.get('AZURE_AI_API_KEY', '') - if not endpoint: - import os - endpoint = os.environ.get('AZURE_AI_ENDPOINT', '') + # Validate provider_id is one we actually know about — prevents probing arbitrary IDs + if provider_id not in providers: + return jsonify({'ok': False, 'error': 'Unknown provider'}), 400 + + provider = providers[provider_id] + + # Endpoint and API key come exclusively from DB or env — never from the request body + endpoint = provider.get('endpoint', '') or os.environ.get('AZURE_AI_ENDPOINT', '') + api_key = provider.get('api_key', '') or os.environ.get('AZURE_AI_API_KEY', '') + + if not endpoint or not api_key: + return jsonify({'ok': False, 'error': 'Endpoint or API key not configured'}), 200 model = settings.get('active_main_model', 'gpt-5.4') @@ -756,4 +778,6 @@ async def test_ai_config(): reply = resp.choices[0].message.content.strip() if resp.choices else '' return jsonify({'ok': True, 'latency_ms': latency_ms, 'reply': reply}), 200 except Exception as exc: - return jsonify({'ok': False, 'error': str(exc)}), 200 + # Log full exception (may contain key fragments) only server-side + logger.warning('AI config test failed for provider %s: %s', provider_id, exc) + return jsonify({'ok': False, 'error': 'Connection failed — check endpoint and API key'}), 200 diff --git a/backend/app/routes/billing.py b/backend/app/routes/billing.py index dd31f81a..ec5b6b90 100644 --- a/backend/app/routes/billing.py +++ b/backend/app/routes/billing.py @@ -59,7 +59,7 @@ async def get_transactions(): @jwt_required() async def create_checkout(): user_id = get_jwt_identity() - data = await request.get_json() + data = await request.get_json() or {} pack_id = data.get("pack_id") if not pack_id: diff --git a/src/components/focus-group-session/SetupTab.tsx b/src/components/focus-group-session/SetupTab.tsx index 56cfa89e..ecb1340a 100755 --- a/src/components/focus-group-session/SetupTab.tsx +++ b/src/components/focus-group-session/SetupTab.tsx @@ -13,6 +13,7 @@ import { FormLabel, FormMessage, } from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; import { Select, @@ -274,7 +275,7 @@ export function SetupTab({