mirror of
https://github.com/MervinPraison/PraisonAIUI.git
synced 2026-11-07 05:56:17 -10:00
fix(security): add input validation and XSS protection to i18n
- Add regex escaping in LocaleProvider to prevent regex injection - Add locale format validation (e.g., 'en', 'en-US') - Add key format validation for translation keys - Pre-compile regex patterns for better performance - Add AbortController to handle race conditions - Add loading state to context for better UX Co-authored-by: Mervin Praison <[email protected]>
This commit is contained in:
1 parent
d9aaec3c1b
commit
e1bb77ff1f
2 files changed
+56
-10
No files matched your search
@@ -9,6 +9,7 @@ interface LocaleContextValue {
|
||||
t: (key: string, variables?: Record<string, string>) => string
|
||||
isRTL: boolean
|
||||
strings: Record<string, string>
|
||||
loading: boolean
|
||||
}
|
||||
|
||||
const LocaleContext = createContext<LocaleContextValue | null>(null)
|
||||
@@ -29,7 +30,7 @@ interface LocaleProviderProps {
|
||||
export function LocaleProvider({ config, children }: LocaleProviderProps) {
|
||||
const [locale, setLocaleState] = useState(config?.defaultLocale || 'en')
|
||||
const [strings, setStrings] = useState<Record<string, string>>({})
|
||||
const [, setLoading] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const locales = config?.locales || ['en']
|
||||
const rtlLocales = config?.rtlLocales || []
|
||||
@@ -46,17 +47,23 @@ export function LocaleProvider({ config, children }: LocaleProviderProps) {
|
||||
|
||||
// Load strings from API when locale changes
|
||||
useEffect(() => {
|
||||
const abortController = new AbortController()
|
||||
|
||||
async function loadStrings() {
|
||||
if (!config || locales.length <= 1) return
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
const response = await fetch(`/api/i18n/strings/${locale}`)
|
||||
const response = await fetch(`/api/i18n/strings/${locale}`, {
|
||||
signal: abortController.signal
|
||||
})
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setStrings(data.strings || {})
|
||||
}
|
||||
} catch (error) {
|
||||
// Ignore abort errors
|
||||
if (error instanceof Error && error.name === 'AbortError') return
|
||||
console.error('Failed to load translations:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
@@ -64,9 +71,18 @@ export function LocaleProvider({ config, children }: LocaleProviderProps) {
|
||||
}
|
||||
|
||||
loadStrings()
|
||||
|
||||
// Cleanup: abort the request if component unmounts or locale changes
|
||||
return () => abortController.abort()
|
||||
}, [locale, config, locales.length])
|
||||
|
||||
const setLocale = useCallback(async (newLocale: string) => {
|
||||
// Validate locale format (e.g., 'en', 'en-US', 'zh-CN')
|
||||
if (!/^[a-z]{2}(-[A-Z]{2})?$/.test(newLocale)) {
|
||||
console.error('Invalid locale format:', newLocale)
|
||||
return
|
||||
}
|
||||
|
||||
if (!locales.includes(newLocale)) return
|
||||
|
||||
setLocaleState(newLocale)
|
||||
@@ -86,10 +102,12 @@ export function LocaleProvider({ config, children }: LocaleProviderProps) {
|
||||
const t = useCallback((key: string, variables?: Record<string, string>) => {
|
||||
let text = strings[key] || key
|
||||
|
||||
// Apply variable substitution
|
||||
// Apply variable substitution with proper escaping
|
||||
if (variables) {
|
||||
Object.entries(variables).forEach(([k, v]) => {
|
||||
text = text.replace(new RegExp(`{${k}}`, 'g'), v)
|
||||
// Escape special regex characters in the key to prevent injection
|
||||
const escapedKey = k.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
text = text.replace(new RegExp(`{${escapedKey}}`, 'g'), v)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -103,7 +121,8 @@ export function LocaleProvider({ config, children }: LocaleProviderProps) {
|
||||
setLocale,
|
||||
t,
|
||||
isRTL,
|
||||
strings
|
||||
strings,
|
||||
loading
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -8,6 +8,7 @@ Architecture:
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
@@ -19,6 +20,10 @@ from ._base import BaseFeatureProtocol
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Compile regex patterns once for performance
|
||||
LOCALE_PATTERN = re.compile(r'^[a-z]{2}(-[A-Z]{2})?$')
|
||||
KEY_PATTERN = re.compile(r'^[a-zA-Z0-9._-]+$')
|
||||
|
||||
|
||||
# ── I18n Protocol ────────────────────────────────────────────────────
|
||||
|
||||
@@ -200,6 +205,11 @@ class I18nFeature(BaseFeatureProtocol):
|
||||
async def _strings(self, request: Request) -> JSONResponse:
|
||||
mgr = get_i18n_manager()
|
||||
locale = request.path_params["locale"]
|
||||
|
||||
# Validate locale format to prevent injection
|
||||
if not LOCALE_PATTERN.match(locale):
|
||||
return JSONResponse({"error": f"Invalid locale format: '{locale}'"}, status_code=400)
|
||||
|
||||
strings = mgr.get_strings(locale)
|
||||
if not strings:
|
||||
return JSONResponse({"error": f"Locale '{locale}' not found"}, status_code=404)
|
||||
@@ -208,10 +218,21 @@ class I18nFeature(BaseFeatureProtocol):
|
||||
async def _translate(self, request: Request) -> JSONResponse:
|
||||
mgr = get_i18n_manager()
|
||||
body = await request.json()
|
||||
text = mgr.t(
|
||||
body.get("key", ""), locale=body.get("locale", ""), **body.get("variables", {})
|
||||
)
|
||||
return JSONResponse({"key": body.get("key", ""), "text": text})
|
||||
|
||||
# Validate locale if provided
|
||||
locale = body.get("locale", "")
|
||||
if locale:
|
||||
if not LOCALE_PATTERN.match(locale):
|
||||
return JSONResponse({"error": f"Invalid locale format: '{locale}'"}, status_code=400)
|
||||
|
||||
# Validate key format (alphanumeric with dots)
|
||||
key = body.get("key", "")
|
||||
if key:
|
||||
if not KEY_PATTERN.match(key):
|
||||
return JSONResponse({"error": f"Invalid key format: '{key}'"}, status_code=400)
|
||||
|
||||
text = mgr.t(key, locale=locale, **body.get("variables", {}))
|
||||
return JSONResponse({"key": key, "text": text})
|
||||
|
||||
async def _get_locale(self, request: Request) -> JSONResponse:
|
||||
mgr = get_i18n_manager()
|
||||
@@ -220,7 +241,13 @@ class I18nFeature(BaseFeatureProtocol):
|
||||
async def _set_locale(self, request: Request) -> JSONResponse:
|
||||
mgr = get_i18n_manager()
|
||||
body = await request.json()
|
||||
mgr.set_locale(body.get("locale", "en"))
|
||||
locale = body.get("locale", "en")
|
||||
|
||||
# Validate locale format to prevent injection
|
||||
if not LOCALE_PATTERN.match(locale):
|
||||
return JSONResponse({"error": f"Invalid locale format: '{locale}'"}, status_code=400)
|
||||
|
||||
mgr.set_locale(locale)
|
||||
return JSONResponse({"locale": mgr.get_locale()})
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user