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:
praisonai-triage-agent[bot]andMervin Praison committed 2026-06-17 10:29:41 +00:00
1 parent d9aaec3c1b
commit e1bb77ff1f
2 files changed
+56 -10

No files matched your search

+24 -5
View File
@@ -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 (
+32 -5
View File
@@ -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()})