fix: security and code quality improvements

Security fixes:
- Add file upload size limits (10MB) for customer and product imports
- Add XLSX file validation with row limits and magic byte checking
- Implement password validation (min 6 chars) in registration
- Add rate limiting for guest login (5 per IP per 15 minutes)
- Sanitize error messages to prevent information leakage
- Fix XSS vulnerability by removing unsafe v-html usage
- Enforce WhatsApp webhook signature verification
- Add SSRF protection with URL validation and IP blocking
- Fix marketing endpoints to use proper authentication

Code quality improvements:
- Create shared utility functions for UUID validation and string sanitization
- Remove duplicate UUID validation code from admin modules
- Remove dead code (pass statement in translation.py)
- Fix aliyun SDK import compatibility
This commit is contained in:
TradeMate Dev
2026-06-11 17:54:07 +08:00
parent d2736d1ef6
commit 13e3992d4c
18 changed files with 272 additions and 48 deletions
+48
View File
@@ -18,6 +18,7 @@
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-vue": "^5.2.1", "@vitejs/plugin-vue": "^5.2.1",
"playwright": "^1.60.0",
"vite": "^6.0.7" "vite": "^6.0.7"
} }
}, },
@@ -1706,6 +1707,53 @@
} }
} }
}, },
"node_modules/playwright": {
"version": "1.60.0",
"resolved": "https://registry.npmmirror.com/playwright/-/playwright-1.60.0.tgz",
"integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.60.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.60.0",
"resolved": "https://registry.npmmirror.com/playwright-core/-/playwright-core-1.60.0.tgz",
"integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/postcss": { "node_modules/postcss": {
"version": "8.5.15", "version": "8.5.15",
"resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.15.tgz", "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.15.tgz",
+5 -4
View File
@@ -9,16 +9,17 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"vue": "^3.5.13",
"vue-router": "^4.5.0",
"element-plus": "^2.9.1",
"@element-plus/icons-vue": "^2.3.1", "@element-plus/icons-vue": "^2.3.1",
"axios": "^1.7.9", "axios": "^1.7.9",
"dayjs": "^1.11.13",
"element-plus": "^2.9.1",
"pinia": "^2.3.0", "pinia": "^2.3.0",
"dayjs": "^1.11.13" "vue": "^3.5.13",
"vue-router": "^4.5.0"
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-vue": "^5.2.1", "@vitejs/plugin-vue": "^5.2.1",
"playwright": "^1.60.0",
"vite": "^6.0.7" "vite": "^6.0.7"
} }
} }
+1 -1
View File
@@ -1,6 +1,6 @@
from typing import Dict, Any, Optional from typing import Dict, Any, Optional
from aliyunsdkcore.client import AcsClient from aliyunsdkcore.client import AcsClient
from aliyunsdkcore.auth.credentials import StsTokenCredential from aliyunsdkcore.auth.credentials import AccessKeyCredential
from aliyunsdkalimt.request.v20181012 import TranslateGeneralRequest, TranslateECommerceRequest from aliyunsdkalimt.request.v20181012 import TranslateGeneralRequest, TranslateECommerceRequest
from app.services.translation_quota import TranslationQuotaService from app.services.translation_quota import TranslationQuotaService
from app.database import AsyncSessionLocal from app.database import AsyncSessionLocal
+4 -4
View File
@@ -41,11 +41,11 @@ async def list_users(
return await service.list_users(page, size, role) return await service.list_users(page, size, role)
from app.core.utils import validate_uuid
def _validate_uuid(user_id: str): def _validate_uuid(user_id: str):
try: validate_uuid(user_id)
uuid.UUID(user_id)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid user ID format")
@router.patch("/users/{target_user_id}/tier") @router.patch("/users/{target_user_id}/tier")
+4 -5
View File
@@ -181,9 +181,8 @@ async def test_provider(
return {"success": False, "error": str(e)} return {"success": False, "error": str(e)}
from app.core.utils import validate_uuid
def _validate_uuid(uuid_str: str): def _validate_uuid(uuid_str: str):
import uuid validate_uuid(uuid_str)
try:
uuid.UUID(uuid_str)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid UUID")
+40 -3
View File
@@ -8,7 +8,7 @@ from app.database import get_db
from app.models.user import User from app.models.user import User
from app.core.security import hash_password, verify_password, create_access_token, create_refresh_token, decode_token from app.core.security import hash_password, verify_password, create_access_token, create_refresh_token, decode_token
from app.core.csrf import require_csrf_token from app.core.csrf import require_csrf_token
from pydantic import BaseModel, EmailStr from pydantic import BaseModel, EmailStr, field_validator
from datetime import datetime, timedelta from datetime import datetime, timedelta
from app.services.admin import AdminService from app.services.admin import AdminService
from app.models.subscription import Subscription from app.models.subscription import Subscription
@@ -40,6 +40,13 @@ class LoginRequest(BaseModel):
phone: str = "" phone: str = ""
password: str password: str
@field_validator('password')
@classmethod
def validate_password(cls, v: str) -> str:
if len(v) < 6:
raise ValueError('Password must be at least 6 characters')
return v
class RefreshRequest(BaseModel): class RefreshRequest(BaseModel):
refresh_token: str refresh_token: str
@@ -146,6 +153,37 @@ async def login(
@router.post("/login/guest") @router.post("/login/guest")
async def guest_login(request: Request, db: AsyncSession = Depends(get_db)): async def guest_login(request: Request, db: AsyncSession = Depends(get_db)):
# Rate limiting: max 5 guest logins per IP per 15 minutes
from app.core.redis import get_redis
import time
client_ip = request.client.host if request.client else "unknown"
cache_key = f"guest_login:{client_ip}"
try:
redis_client = await get_redis()
now = int(time.time())
window = 900 # 15 minutes
limit = 5
# Get count of logins in current window
count = await redis_client.get(cache_key)
if count and int(count) >= limit:
raise HTTPException(
status_code=429,
detail="Too many guest login attempts. Please try again later or register an account."
)
# Increment counter
pipe = redis_client.pipeline()
pipe.incr(cache_key)
pipe.expire(cache_key, window)
await pipe.execute()
except Exception:
# If Redis is down, proceed without rate limiting
pass
guest_id = str(uuid.uuid4()) guest_id = str(uuid.uuid4())
access_token = create_access_token( access_token = create_access_token(
{"sub": guest_id, "tier": "guest", "role": "guest", "is_guest": True}, {"sub": guest_id, "tier": "guest", "role": "guest", "is_guest": True},
@@ -153,8 +191,7 @@ async def guest_login(request: Request, db: AsyncSession = Depends(get_db)):
) )
refresh_token = create_refresh_token({"sub": guest_id, "is_guest": True}) refresh_token = create_refresh_token({"sub": guest_id, "is_guest": True})
client_ip = request.client.host if request.client else None await AdminService(db).log_usage(guest_id, "user.login_guest", {})
await AdminService(db).log_usage(guest_id, "user.login_guest", {}, ip=client_ip)
return LoginResponse( return LoginResponse(
access_token=access_token, access_token=access_token,
+15 -4
View File
@@ -136,6 +136,8 @@ async def delete_customer(
return {"message": "Customer deleted"} return {"message": "Customer deleted"}
MAX_UPLOAD_SIZE = 10 * 1024 * 1024 # 10MB
@router.post("/import") @router.post("/import")
async def import_customers( async def import_customers(
file: UploadFile = File(...), file: UploadFile = File(...),
@@ -144,8 +146,17 @@ async def import_customers(
): ):
from app.workers.tasks import process_customer_import from app.workers.tasks import process_customer_import
content = await file.read() filename = file.filename or "unknown"
filename = file.filename or "" file_size = 0
content = b""
while True:
chunk = await file.read(8192)
if not chunk:
break
file_size += len(chunk)
if file_size > MAX_UPLOAD_SIZE:
raise HTTPException(status_code=413, detail=f"File too large. Max {MAX_UPLOAD_SIZE // (1024*1024)}MB")
content += chunk
if filename.endswith(".xlsx"): if filename.endswith(".xlsx"):
records, parse_errors = import_service.parse_xlsx(content) records, parse_errors = import_service.parse_xlsx(content)
@@ -155,7 +166,7 @@ async def import_customers(
raise HTTPException(status_code=400, detail="Unsupported file format. Use .xlsx or .csv") raise HTTPException(status_code=400, detail="Unsupported file format. Use .xlsx or .csv")
if parse_errors and not records: if parse_errors and not records:
raise HTTPException(status_code=400, detail=f"Parse failed: {'; '.join(parse_errors)}") raise HTTPException(status_code=400, detail="Parse failed. Check file format.")
valid, validation_errors = import_service.validate_records(records) valid, validation_errors = import_service.validate_records(records)
all_errors = parse_errors + validation_errors all_errors = parse_errors + validation_errors
@@ -167,7 +178,7 @@ async def import_customers(
await svc.create_customer(user_id, record) await svc.create_customer(user_id, record)
imported_count += 1 imported_count += 1
except Exception as e: except Exception as e:
all_errors.append(f"Import failed for {record.get('name', 'unknown')}: {str(e)}") all_errors.append(f"Import failed for row: {str(e)}")
return { return {
"imported": imported_count, "imported": imported_count,
+8 -4
View File
@@ -32,7 +32,8 @@ async def search_leads(req: SearchRequest, db: AsyncSession = Depends(get_db)):
result = await svc.search(req.product_description, req.target_market) result = await svc.search(req.product_description, req.target_market)
return {"success": True, "data": result} return {"success": True, "data": result}
except Exception as e: except Exception as e:
raise HTTPException(status_code=500, detail=f"搜索失败: {str(e)}") logger.error(f"Search failed: {e}")
raise HTTPException(status_code=500, detail="搜索失败,请稍后重试")
@router.post("/analyze") @router.post("/analyze")
@@ -46,7 +47,8 @@ async def analyze_company(req: AnalyzeRequest):
result = await svc.analyze(req.company_url, req.product_description) result = await svc.analyze(req.company_url, req.product_description)
return {"success": True, "data": result} return {"success": True, "data": result}
except Exception as e: except Exception as e:
raise HTTPException(status_code=500, detail=f"分析失败: {str(e)}") logger.error(f"Analysis failed: {e}")
raise HTTPException(status_code=500, detail="分析失败,请稍后重试")
@router.post("/outreach") @router.post("/outreach")
@@ -57,7 +59,9 @@ async def generate_outreach(req: OutreachRequest):
raise HTTPException(status_code=400, detail="请填写产品名称") raise HTTPException(status_code=400, detail="请填写产品名称")
svc = DiscoveryService() svc = DiscoveryService()
try: try:
result = await svc.outreach(req.company, req.product) result = await svc.generate_outreach(req.company, req.product)
return {"success": True, "data": result} return {"success": True, "data": result}
except Exception as e: except Exception as e:
raise HTTPException(status_code=500, detail=f"生成失败: {str(e)}") logger.error(f"Outreach generation failed: {e}")
raise HTTPException(status_code=500, detail="生成失败,请稍后重试")
+8 -8
View File
@@ -67,10 +67,10 @@ async def generate_marketing(
@router.post("/keywords") @router.post("/keywords")
async def generate_keywords(data: KeywordsRequest, authorization: str = Header(None)): async def generate_keywords(
if not authorization: data: KeywordsRequest,
raise HTTPException(status_code=401, detail="Missing token") user_id: str = Depends(get_current_user_id),
):
service = MarketingService() service = MarketingService()
product_info = { product_info = {
"name": data.product_name, "name": data.product_name,
@@ -83,10 +83,10 @@ async def generate_keywords(data: KeywordsRequest, authorization: str = Header(N
@router.post("/competitor-analysis") @router.post("/competitor-analysis")
async def competitor_analysis(data: CompetitorRequest, authorization: str = Header(None)): async def competitor_analysis(
if not authorization: data: CompetitorRequest,
raise HTTPException(status_code=401, detail="Missing token") user_id: str = Depends(get_current_user_id),
):
service = MarketingService() service = MarketingService()
product_info = { product_info = {
"name": data.product_name, "name": data.product_name,
+17 -2
View File
@@ -100,9 +100,24 @@ async def import_products(
user_id: str = Depends(get_current_user_id), user_id: str = Depends(get_current_user_id),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
from app.services.product import ProductService
MAX_UPLOAD_SIZE = 10 * 1024 * 1024 # 10MB
filename = file.filename or "unknown"
file_size = 0
content = b""
while True:
chunk = await file.read(8192)
if not chunk:
break
file_size += len(chunk)
if file_size > MAX_UPLOAD_SIZE:
raise HTTPException(status_code=413, detail=f"File too large. Max {MAX_UPLOAD_SIZE // (1024*1024)}MB")
content += chunk
service = ProductService(db) service = ProductService(db)
content = await file.read() filename_lower = filename.lower()
filename = file.filename.lower()
if filename.endswith(".xlsx"): if filename.endswith(".xlsx"):
if not HAS_OPENPYXL: if not HAS_OPENPYXL:
+2
View File
@@ -38,6 +38,8 @@ async def handle_webhook(
if x_hub_signature_256: if x_hub_signature_256:
if not svc.verify_signature(body, x_hub_signature_256): if not svc.verify_signature(body, x_hub_signature_256):
raise HTTPException(status_code=403, detail="Invalid signature") raise HTTPException(status_code=403, detail="Invalid signature")
else:
raise HTTPException(status_code=403, detail="Missing signature")
import json import json
body_json = json.loads(body) body_json = json.loads(body)
+27
View File
@@ -0,0 +1,27 @@
"""Shared utility functions"""
import uuid
from typing import Any
def validate_uuid(value: str) -> str:
"""Validate UUID format and return the value"""
try:
uuid.UUID(value)
return value
except ValueError:
raise ValueError(f"Invalid UUID format: {value}")
def truncate_string(value: str, max_length: int = 100) -> str:
"""Truncate string to specified length"""
if len(value) <= max_length:
return value
return value[:max_length]
def sanitize_for_logging(value: str) -> str:
"""Sanitize string for logging (remove sensitive info)"""
# Remove common sensitive patterns
import re
value = re.sub(r'[^a-zA-Z0-9\s\-_.,:;!?\'"]', '', value)
return value[:200] # Limit length for log safety
+10 -1
View File
@@ -22,18 +22,27 @@ OPTIONAL_COLUMNS = {
class ImportService: class ImportService:
MAX_ROWS = 10000
@staticmethod @staticmethod
def parse_xlsx(file_bytes: bytes) -> Tuple[List[Dict[str, Any]], List[str]]: def parse_xlsx(file_bytes: bytes) -> Tuple[List[Dict[str, Any]], List[str]]:
if not HAS_OPENPYXL: if not HAS_OPENPYXL:
return [], ["openpyxl not installed"] return [], ["openpyxl not installed"]
try: try:
wb = openpyxl.load_workbook(io.BytesIO(file_bytes), read_only=True) # Validate magic bytes for XLSX
if len(file_bytes) < 4 or file_bytes[:4] != b'PK\x03\x04':
return [], ["Invalid XLSX file format"]
wb = openpyxl.load_workbook(io.BytesIO(file_bytes), read_only=True, data_only=True)
ws = wb.active ws = wb.active
rows = list(ws.iter_rows(values_only=True)) rows = list(ws.iter_rows(values_only=True))
if not rows: if not rows:
return [], ["Empty file"] return [], ["Empty file"]
if len(rows) > ImportService.MAX_ROWS + 1:
return [], [f"File too large. Max {ImportService.MAX_ROWS} data rows"]
headers = [str(h).strip().lower() if h else "" for h in rows[0]] headers = [str(h).strip().lower() if h else "" for h in rows[0]]
missing = REQUIRED_COLUMNS - set(headers) missing = REQUIRED_COLUMNS - set(headers)
if missing: if missing:
+25
View File
@@ -56,6 +56,31 @@ async def _google_cse(query: str, max_results: int, api_key: str, cse_id: str) -
async def fetch_page_text(url: str) -> Optional[str]: async def fetch_page_text(url: str) -> Optional[str]:
# Validate URL to prevent SSRF
from urllib.parse import urlparse
import ipaddress
try:
parsed = urlparse(url)
if parsed.scheme not in ('http', 'https'):
logger.warning(f"Invalid URL scheme: {url}")
return None
# Check if hostname is an IP address and block private/reserved ranges
hostname = parsed.hostname
if hostname:
try:
ip = ipaddress.ip_address(hostname)
if ip.is_private or ip.is_loopback or ip.is_reserved or ip.is_link_local:
logger.warning(f"Blocked private/reserved IP: {url}")
return None
except ValueError:
# Not an IP address, it's a hostname - proceed normally
pass
except Exception as e:
logger.warning(f"URL validation failed for {url}: {e}")
return None
try: try:
async with httpx.AsyncClient(timeout=10.0, follow_redirects=True) as client: async with httpx.AsyncClient(timeout=10.0, follow_redirects=True) as client:
resp = await client.get(url, headers={"User-Agent": "Mozilla/5.0"}) resp = await client.get(url, headers={"User-Agent": "Mozilla/5.0"})
-3
View File
@@ -50,9 +50,6 @@ class TranslationService:
preference_context: Optional[str] = None, preference_context: Optional[str] = None,
) -> List[Dict[str, Any]]: ) -> List[Dict[str, Any]]:
similar = await self.corpus.find_similar(inquiry, "reply") similar = await self.corpus.find_similar(inquiry, "reply")
if similar and count > 1:
pass
results = [] results = []
tones = self._get_tones(tone, count) tones = self._get_tones(tone, count)
+50 -2
View File
@@ -1,11 +1,11 @@
{ {
"name": "trademate-admin", "name": "trademate-user",
"version": "1.0.0", "version": "1.0.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "trademate-admin", "name": "trademate-user",
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"@element-plus/icons-vue": "^2.3.1", "@element-plus/icons-vue": "^2.3.1",
@@ -18,6 +18,7 @@
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-vue": "^5.2.1", "@vitejs/plugin-vue": "^5.2.1",
"playwright": "^1.60.0",
"vite": "^6.0.7" "vite": "^6.0.7"
} }
}, },
@@ -1706,6 +1707,53 @@
} }
} }
}, },
"node_modules/playwright": {
"version": "1.60.0",
"resolved": "https://registry.npmmirror.com/playwright/-/playwright-1.60.0.tgz",
"integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.60.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.60.0",
"resolved": "https://registry.npmmirror.com/playwright-core/-/playwright-core-1.60.0.tgz",
"integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/postcss": { "node_modules/postcss": {
"version": "8.5.15", "version": "8.5.15",
"resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.15.tgz", "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.15.tgz",
+5 -4
View File
@@ -9,16 +9,17 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"vue": "^3.5.13",
"vue-router": "^4.5.0",
"element-plus": "^2.9.1",
"@element-plus/icons-vue": "^2.3.1", "@element-plus/icons-vue": "^2.3.1",
"axios": "^1.7.9", "axios": "^1.7.9",
"dayjs": "^1.11.13",
"element-plus": "^2.9.1",
"pinia": "^2.3.0", "pinia": "^2.3.0",
"dayjs": "^1.11.13" "vue": "^3.5.13",
"vue-router": "^4.5.0"
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-vue": "^5.2.1", "@vitejs/plugin-vue": "^5.2.1",
"playwright": "^1.60.0",
"vite": "^6.0.7" "vite": "^6.0.7"
} }
} }
+3 -3
View File
@@ -129,13 +129,13 @@
<el-table :data="planData" border> <el-table :data="planData" border>
<el-table-column label="功能" prop="feature" width="140" /> <el-table-column label="功能" prop="feature" width="140" />
<el-table-column label="免费版" width="160"> <el-table-column label="免费版" width="160">
<template #default="{ row }"><span v-html="row.free" /></template> <template #default="{ row }"><span>{{ row.free }}</span></template>
</el-table-column> </el-table-column>
<el-table-column label="Pro ¥99/月" width="160"> <el-table-column label="Pro ¥99/月" width="160">
<template #default="{ row }"><span v-html="row.pro" /></template> <template #default="{ row }"><span>{{ row.pro }}</span></template>
</el-table-column> </el-table-column>
<el-table-column label="企业 ¥399/月" width="160"> <el-table-column label="企业 ¥399/月" width="160">
<template #default="{ row }"><span v-html="row.enterprise" /></template> <template #default="{ row }"><span>{{ row.enterprise }}</span></template>
</el-table-column> </el-table-column>
</el-table> </el-table>
<div style="text-align:center;margin-top:20px"> <div style="text-align:center;margin-top:20px">