This commit is contained in:
MarkLo 2025-12-13 01:54:47 +08:00
parent 5e48737ab8
commit 8204aff28b
5 changed files with 548 additions and 27 deletions

View File

@ -1,11 +1,15 @@
"""
FastAPI application entry point for TradingAgentsX Backend
"""
from fastapi import FastAPI
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from starlette.middleware.base import BaseHTTPMiddleware
import logging
import sys
import time
from pathlib import Path
from collections import defaultdict
from datetime import datetime, timedelta
from backend.app.core.config import settings
from backend.app.core.cors import setup_cors
@ -18,6 +22,105 @@ logging.basicConfig(
)
logger = logging.getLogger(__name__)
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
"""Add security headers to all responses"""
async def dispatch(self, request: Request, call_next):
response = await call_next(request)
# Security headers
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["X-Frame-Options"] = "DENY"
response.headers["X-XSS-Protection"] = "1; mode=block"
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
response.headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=()"
return response
class RateLimitMiddleware(BaseHTTPMiddleware):
"""Simple in-memory rate limiting middleware"""
def __init__(self, app, max_requests: int = 30, window_seconds: int = 60):
super().__init__(app)
self.max_requests = max_requests
self.window_seconds = window_seconds
self.requests: dict[str, list[float]] = defaultdict(list)
async def dispatch(self, request: Request, call_next):
# Skip rate limiting for health checks
if request.url.path == "/api/health":
return await call_next(request)
# Get client IP
client_ip = request.client.host if request.client else "unknown"
forwarded_for = request.headers.get("X-Forwarded-For")
if forwarded_for:
client_ip = forwarded_for.split(",")[0].strip()
# Clean old requests
now = time.time()
cutoff = now - self.window_seconds
self.requests[client_ip] = [
t for t in self.requests[client_ip] if t > cutoff
]
# Check rate limit
if len(self.requests[client_ip]) >= self.max_requests:
retry_after = int(self.window_seconds - (now - self.requests[client_ip][0]))
return JSONResponse(
status_code=429,
content={
"error": "Too many requests",
"message": f"Rate limit exceeded. Please wait {retry_after} seconds.",
"retry_after": retry_after,
},
headers={"Retry-After": str(retry_after)},
)
# Record this request
self.requests[client_ip].append(now)
# Process request
response = await call_next(request)
# Add rate limit headers
remaining = self.max_requests - len(self.requests[client_ip])
response.headers["X-RateLimit-Limit"] = str(self.max_requests)
response.headers["X-RateLimit-Remaining"] = str(max(0, remaining))
response.headers["X-RateLimit-Reset"] = str(int(now + self.window_seconds))
return response
class SensitiveDataFilter(logging.Filter):
"""Filter to mask API keys in log messages"""
SENSITIVE_PATTERNS = ["api_key", "apikey", "api-key", "token", "secret", "password"]
def filter(self, record):
if hasattr(record, 'msg') and isinstance(record.msg, str):
msg = record.msg
for pattern in self.SENSITIVE_PATTERNS:
if pattern.lower() in msg.lower():
# Mask the value after the pattern
import re
msg = re.sub(
rf'({pattern}["\']?\s*[=:]\s*["\']?)([^"\'\s,}}]+)',
r'\1***MASKED***',
msg,
flags=re.IGNORECASE
)
record.msg = msg
return True
# Add sensitive data filter to all loggers
for handler in logging.root.handlers:
handler.addFilter(SensitiveDataFilter())
# Create FastAPI application
app = FastAPI(
title=settings.app_name,
@ -27,6 +130,10 @@ app = FastAPI(
redoc_url="/redoc",
)
# Add security middleware (order matters - added first, executed last)
app.add_middleware(SecurityHeadersMiddleware)
app.add_middleware(RateLimitMiddleware, max_requests=30, window_seconds=60)
# Setup CORS
setup_cors(app)
@ -47,13 +154,25 @@ async def root():
@app.exception_handler(Exception)
async def global_exception_handler(request, exc):
"""Global exception handler"""
logger.error(f"Unhandled exception: {str(exc)}", exc_info=True)
"""Global exception handler - masks sensitive data in errors"""
error_msg = str(exc)
# Mask any API keys that might be in the error message
import re
patterns = ["sk-", "sk-ant-", "xai-", "AIza"]
for pattern in patterns:
error_msg = re.sub(
rf'{pattern}[a-zA-Z0-9_-]+',
f'{pattern}***MASKED***',
error_msg
)
logger.error(f"Unhandled exception: {error_msg}", exc_info=True)
return JSONResponse(
status_code=500,
content={
"error": "Internal server error",
"detail": str(exc), # Always return detailed error for user debugging
"detail": error_msg,
"type": type(exc).__name__,
},
)

View File

@ -29,9 +29,10 @@ import {
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import {
getApiSettings,
saveApiSettings,
getApiSettingsAsync,
saveApiSettingsAsync,
clearApiSettings,
migrateToEncrypted,
type ApiSettings,
DEFAULT_API_SETTINGS,
} from "@/lib/storage";
@ -59,25 +60,38 @@ type FormValues = z.infer<typeof formSchema>;
export function ApiSettingsDialog() {
const [open, setOpen] = useState(false);
const [saveSuccess, setSaveSuccess] = useState(false);
const [loading, setLoading] = useState(false);
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: getApiSettings(),
defaultValues: DEFAULT_API_SETTINGS,
});
// Load settings when dialog opens
// Load and decrypt settings when dialog opens
useEffect(() => {
if (open) {
const settings = getApiSettings();
form.reset(settings);
setLoading(true);
setSaveSuccess(false);
// First try to migrate legacy settings
migrateToEncrypted().then(() => {
// Then load decrypted settings
return getApiSettingsAsync();
}).then((settings) => {
form.reset(settings);
}).catch((error) => {
console.error("Failed to load settings:", error);
}).finally(() => {
setLoading(false);
});
}
}, [open, form]);
const onSubmit = (values: FormValues) => {
const onSubmit = async (values: FormValues) => {
setLoading(true);
try {
// Type assertion since our form values match ApiSettings structure
saveApiSettings(values as ApiSettings);
// Encrypt and save settings
await saveApiSettingsAsync(values as ApiSettings);
setSaveSuccess(true);
setTimeout(() => {
setSaveSuccess(false);
@ -85,6 +99,8 @@ export function ApiSettingsDialog() {
}, 1500);
} catch (error) {
console.error("Failed to save settings:", error);
} finally {
setLoading(false);
}
};
@ -110,7 +126,10 @@ export function ApiSettingsDialog() {
<DialogHeader>
<DialogTitle>API </DialogTitle>
<DialogDescription>
API
API
<span className="block mt-1 text-xs text-green-600 dark:text-green-400">
🔒 AES-256-GCM
</span>
</DialogDescription>
</DialogHeader>
@ -335,8 +354,8 @@ export function ApiSettingsDialog() {
)}
<div className="flex gap-2 pt-4">
<Button type="submit" className="flex-1">
<Button type="submit" className="flex-1" disabled={loading}>
{loading ? "處理中..." : "儲存設定"}
</Button>
<Button
type="button"

217
frontend/lib/crypto.ts Normal file
View File

@ -0,0 +1,217 @@
/**
* Crypto utilities for secure API key storage
* Uses AES-256-GCM encryption with Web Crypto API
*/
// Storage keys
const SALT_KEY = "tradingagents_crypto_salt";
const IV_PREFIX = "tradingagents_iv_";
/**
* Generate a random salt for key derivation
*/
function generateSalt(): Uint8Array {
return crypto.getRandomValues(new Uint8Array(16));
}
/**
* Get or create the salt stored in localStorage
*/
function getOrCreateSalt(): Uint8Array {
if (typeof window === "undefined") {
return new Uint8Array(16);
}
const storedSalt = localStorage.getItem(SALT_KEY);
if (storedSalt) {
return Uint8Array.from(atob(storedSalt), (c) => c.charCodeAt(0));
}
const newSalt = generateSalt();
localStorage.setItem(SALT_KEY, btoa(String.fromCharCode(...newSalt)));
return newSalt;
}
/**
* Generate a browser fingerprint for key derivation
* This creates a semi-unique identifier based on browser characteristics
*/
function getBrowserFingerprint(): string {
if (typeof window === "undefined") {
return "server-side";
}
const components = [
navigator.userAgent,
navigator.language,
screen.width.toString(),
screen.height.toString(),
screen.colorDepth.toString(),
new Date().getTimezoneOffset().toString(),
navigator.hardwareConcurrency?.toString() || "unknown",
];
return components.join("|");
}
/**
* Derive an encryption key from the browser fingerprint using PBKDF2
*/
async function deriveKey(salt: Uint8Array): Promise<CryptoKey> {
const fingerprint = getBrowserFingerprint();
const encoder = new TextEncoder();
const keyMaterial = await crypto.subtle.importKey(
"raw",
encoder.encode(fingerprint),
"PBKDF2",
false,
["deriveBits", "deriveKey"]
);
return crypto.subtle.deriveKey(
{
name: "PBKDF2",
salt: salt.buffer as ArrayBuffer, // Cast to ArrayBuffer to fix TypeScript error
iterations: 100000,
hash: "SHA-256",
},
keyMaterial,
{ name: "AES-GCM", length: 256 },
false,
["encrypt", "decrypt"]
);
}
/**
* Encrypt a string using AES-256-GCM
*/
export async function encrypt(plaintext: string): Promise<string> {
if (typeof window === "undefined" || !plaintext) {
return plaintext;
}
try {
const salt = getOrCreateSalt();
const key = await deriveKey(salt);
const iv = crypto.getRandomValues(new Uint8Array(12));
const encoder = new TextEncoder();
const encrypted = await crypto.subtle.encrypt(
{ name: "AES-GCM", iv },
key,
encoder.encode(plaintext)
);
// Combine IV + encrypted data and encode as base64
const combined = new Uint8Array(iv.length + encrypted.byteLength);
combined.set(iv);
combined.set(new Uint8Array(encrypted), iv.length);
return btoa(String.fromCharCode(...combined));
} catch (error) {
console.error("Encryption failed:", error);
throw new Error("Failed to encrypt data");
}
}
/**
* Decrypt a string using AES-256-GCM
*/
export async function decrypt(ciphertext: string): Promise<string> {
if (typeof window === "undefined" || !ciphertext) {
return ciphertext;
}
try {
const salt = getOrCreateSalt();
const key = await deriveKey(salt);
// Decode from base64
const combined = Uint8Array.from(atob(ciphertext), (c) => c.charCodeAt(0));
// Extract IV (first 12 bytes) and encrypted data
const iv = combined.slice(0, 12);
const encrypted = combined.slice(12);
const decrypted = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv },
key,
encrypted
);
const decoder = new TextDecoder();
return decoder.decode(decrypted);
} catch (error) {
console.error("Decryption failed:", error);
throw new Error("Failed to decrypt data");
}
}
/**
* Check if a string appears to be encrypted (base64 with proper length)
*/
export function isEncrypted(value: string): boolean {
if (!value || value.length < 20) return false;
try {
// Try to decode as base64
const decoded = atob(value);
// Encrypted data should be at least 12 (IV) + 16 (min ciphertext) bytes
return decoded.length >= 28;
} catch {
return false;
}
}
/**
* Encrypt an object (for API settings)
*/
export async function encryptObject(obj: Record<string, string>): Promise<Record<string, string>> {
const encrypted: Record<string, string> = {};
for (const [key, value] of Object.entries(obj)) {
if (value && typeof value === "string" && value.trim() !== "") {
// Only encrypt non-empty values that look like API keys
if (key.includes("api_key") || key.includes("api_secret")) {
encrypted[key] = await encrypt(value);
} else {
encrypted[key] = value;
}
} else {
encrypted[key] = value;
}
}
return encrypted;
}
/**
* Decrypt an object (for API settings)
*/
export async function decryptObject(obj: Record<string, string>): Promise<Record<string, string>> {
const decrypted: Record<string, string> = {};
for (const [key, value] of Object.entries(obj)) {
if (value && typeof value === "string" && isEncrypted(value)) {
try {
decrypted[key] = await decrypt(value);
} catch {
// If decryption fails, the data might be corrupted or from a different device
decrypted[key] = "";
}
} else {
decrypted[key] = value;
}
}
return decrypted;
}
/**
* Clear all crypto-related data (useful for logout/reset)
*/
export function clearCryptoData(): void {
if (typeof window === "undefined") return;
localStorage.removeItem(SALT_KEY);
}

View File

@ -1,7 +1,10 @@
/**
* localStorage utility for API settings
* localStorage utility for API settings with encryption
* API keys are encrypted using AES-256-GCM before storage
*/
import { encryptObject, decryptObject, isEncrypted, clearCryptoData } from "./crypto";
export interface ApiSettings {
// Required providers
openai_api_key: string;
@ -21,6 +24,7 @@ export interface ApiSettings {
}
const STORAGE_KEY = "tradingagents_api_settings";
const ENCRYPTED_FLAG_KEY = "tradingagents_encrypted";
export const DEFAULT_API_SETTINGS: ApiSettings = {
openai_api_key: "",
@ -30,13 +34,77 @@ export const DEFAULT_API_SETTINGS: ApiSettings = {
grok_api_key: "",
deepseek_api_key: "",
qwen_api_key: "",
finmind_api_key: "", // 台灣股市資料 API
finmind_api_key: "",
custom_base_url: "",
custom_api_key: "",
};
/**
* Get API settings from localStorage
* Check if stored data is using legacy (unencrypted) format
*/
function isLegacyFormat(): boolean {
if (typeof window === "undefined") return false;
const encryptedFlag = localStorage.getItem(ENCRYPTED_FLAG_KEY);
if (encryptedFlag === "true") return false;
const stored = localStorage.getItem(STORAGE_KEY);
if (!stored) return false;
try {
const parsed = JSON.parse(stored);
// Check if any API key looks like plaintext (starts with known prefixes)
const apiKeyFields = Object.keys(parsed).filter(k => k.includes("api_key"));
for (const field of apiKeyFields) {
const value = parsed[field];
if (value && typeof value === "string") {
// OpenAI keys start with sk-, Anthropic with sk-ant-, etc.
if (value.startsWith("sk-") || value.startsWith("AIza") || value.length < 50) {
return true;
}
}
}
} catch {
return false;
}
return false;
}
/**
* Get API settings from localStorage (async due to decryption)
*/
export async function getApiSettingsAsync(): Promise<ApiSettings> {
if (typeof window === "undefined") {
return DEFAULT_API_SETTINGS;
}
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (!stored) {
return DEFAULT_API_SETTINGS;
}
const parsed = JSON.parse(stored);
// If legacy format detected, return as-is (will be encrypted on next save)
if (isLegacyFormat()) {
console.warn("Legacy unencrypted settings detected. Will encrypt on next save.");
return { ...DEFAULT_API_SETTINGS, ...parsed };
}
// Decrypt the settings
const decrypted = await decryptObject(parsed);
return { ...DEFAULT_API_SETTINGS, ...decrypted };
} catch (error) {
console.error("Error reading API settings:", error);
return DEFAULT_API_SETTINGS;
}
}
/**
* Get API settings synchronously (for backward compatibility)
* WARNING: This returns encrypted values - use getApiSettingsAsync for actual values
*/
export function getApiSettings(): ApiSettings {
if (typeof window === "undefined") {
@ -47,7 +115,6 @@ export function getApiSettings(): ApiSettings {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
const parsed = JSON.parse(stored);
// Merge with defaults to handle any missing fields
return { ...DEFAULT_API_SETTINGS, ...parsed };
}
} catch (error) {
@ -58,19 +125,36 @@ export function getApiSettings(): ApiSettings {
}
/**
* Save API settings to localStorage
* Save API settings to localStorage with encryption
*/
export async function saveApiSettingsAsync(settings: ApiSettings): Promise<void> {
if (typeof window === "undefined") {
return;
}
try {
// Encrypt sensitive fields
const encrypted = await encryptObject(settings as unknown as Record<string, string>);
localStorage.setItem(STORAGE_KEY, JSON.stringify(encrypted));
localStorage.setItem(ENCRYPTED_FLAG_KEY, "true");
} catch (error) {
console.error("Error saving API settings:", error);
throw error;
}
}
/**
* Save API settings synchronously (legacy - not recommended)
* WARNING: This saves unencrypted data - use saveApiSettingsAsync instead
*/
export function saveApiSettings(settings: ApiSettings): void {
if (typeof window === "undefined") {
return;
}
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
} catch (error) {
console.error("Error saving API settings to localStorage:", error);
throw error;
}
// Call async version and ignore the promise (for backward compatibility)
saveApiSettingsAsync(settings).catch(console.error);
}
/**
@ -83,7 +167,37 @@ export function clearApiSettings(): void {
try {
localStorage.removeItem(STORAGE_KEY);
localStorage.removeItem(ENCRYPTED_FLAG_KEY);
clearCryptoData();
} catch (error) {
console.error("Error clearing API settings from localStorage:", error);
}
}
/**
* Migrate legacy unencrypted settings to encrypted format
*/
export async function migrateToEncrypted(): Promise<boolean> {
if (typeof window === "undefined") return false;
if (!isLegacyFormat()) {
return false; // Already encrypted or no data
}
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (!stored) return false;
const parsed = JSON.parse(stored);
const merged = { ...DEFAULT_API_SETTINGS, ...parsed };
// Save with encryption
await saveApiSettingsAsync(merged);
console.log("Successfully migrated settings to encrypted format");
return true;
} catch (error) {
console.error("Failed to migrate settings:", error);
return false;
}
}

View File

@ -3,6 +3,58 @@ import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: 'standalone',
reactCompiler: true,
// Security headers
async headers() {
return [
{
// Apply to all routes
source: '/:path*',
headers: [
{
key: 'X-DNS-Prefetch-Control',
value: 'on'
},
{
key: 'X-XSS-Protection',
value: '1; mode=block'
},
{
key: 'X-Frame-Options',
value: 'DENY'
},
{
key: 'X-Content-Type-Options',
value: 'nosniff'
},
{
key: 'Referrer-Policy',
value: 'strict-origin-when-cross-origin'
},
{
key: 'Permissions-Policy',
value: 'camera=(), microphone=(), geolocation=()'
},
{
// Content Security Policy
key: 'Content-Security-Policy',
value: [
"default-src 'self'",
"script-src 'self' 'unsafe-eval' 'unsafe-inline'", // Required for Next.js
"style-src 'self' 'unsafe-inline'", // Required for Tailwind
"img-src 'self' data: blob: https:",
"font-src 'self' data:",
"connect-src 'self' https://api.openai.com https://api.anthropic.com https://api.x.ai https://api.deepseek.com https://dashscope-intl.aliyuncs.com https://generativelanguage.googleapis.com https://*.alphavantage.co https://api.finmindtrade.com",
"frame-ancestors 'none'",
"base-uri 'self'",
"form-action 'self'",
].join('; ')
},
],
},
];
},
async rewrites() {
// In development: use localhost backend
// In production (Railway): use BACKEND_URL env var or fallback to Railway URL