feat: production readiness improvements

## Error Handling
- Add GlobalErrorBoundary with error classification and recovery
- Add custom error types (SecurityError, ConnectionError, TimeoutError)
- Fix ErrorAlert component syntax errors

## Offline Mode
- Add offlineStore for offline state management
- Implement message queue with localStorage persistence
- Add exponential backoff reconnection (1s→60s)
- Add OfflineIndicator component with status display
- Queue messages when offline, auto-retry on reconnect

## Security Hardening
- Add AES-256-GCM encryption for chat history storage
- Add secure API key storage with OS keychain integration
- Add security audit logging system
- Add XSS prevention and input validation utilities
- Add rate limiting and token generation helpers

## CI/CD (Gitea Actions)
- Add .gitea/workflows/ci.yml for continuous integration
- Add .gitea/workflows/release.yml for release automation
- Support Windows Tauri build and release

## UI Components
- Add LoadingSpinner, LoadingOverlay, LoadingDots components
- Add MessageSkeleton, ConversationListSkeleton skeletons
- Add EmptyMessages, EmptyConversations empty states
- Integrate loading states in ChatArea and ConversationList

## E2E Tests
- Fix WebSocket mock for streaming response tests
- Fix approval endpoint route matching
- Add store state exposure for testing
- All 19 core-features tests now passing

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
iven
2026-03-22 00:03:22 +08:00
parent ce562e8bfc
commit 185763868a
27 changed files with 5725 additions and 268 deletions

View File

@@ -0,0 +1,729 @@
/**
* Security Utilities for Input Validation and XSS Prevention
*
* Provides comprehensive input validation, sanitization, and XSS prevention
* for the ZCLAW application.
*
* Security features:
* - HTML sanitization
* - URL validation
* - Path traversal prevention
* - Input validation helpers
* - Content Security Policy helpers
*/
// ============================================================================
// HTML Sanitization
// ============================================================================
/**
* HTML entity encoding map
*/
const HTML_ENTITIES: Record<string, string> = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#x27;',
'/': '&#x2F;',
'`': '&#x60;',
'=': '&#x3D;',
};
/**
* Escape HTML entities in a string
* Prevents XSS attacks by encoding dangerous characters
*
* @param input - The string to escape
* @returns The escaped string
*/
export function escapeHtml(input: string): string {
if (typeof input !== 'string') {
return '';
}
return input.replace(/[&<>"'`=\/]/g, char => HTML_ENTITIES[char] || char);
}
/**
* Unescape HTML entities in a string
*
* @param input - The string to unescape
* @returns The unescaped string
*/
export function unescapeHtml(input: string): string {
if (typeof input !== 'string') {
return '';
}
const textarea = document.createElement('textarea');
textarea.innerHTML = input;
return textarea.value;
}
/**
* Allowed HTML tags for safe rendering
*/
const ALLOWED_TAGS = new Set([
'p', 'br', 'b', 'i', 'u', 'strong', 'em',
'ul', 'ol', 'li', 'blockquote', 'code', 'pre',
'a', 'span', 'div', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
]);
/**
* Allowed HTML attributes
*/
const ALLOWED_ATTRIBUTES = new Set([
'href', 'title', 'class', 'id', 'target', 'rel',
]);
/**
* Sanitize HTML content for safe rendering
* Removes dangerous tags and attributes while preserving safe content
*
* @param html - The HTML string to sanitize
* @param options - Sanitization options
* @returns The sanitized HTML
*/
export function sanitizeHtml(
html: string,
options: {
allowedTags?: string[];
allowedAttributes?: string[];
allowDataAttributes?: boolean;
} = {}
): string {
if (typeof html !== 'string') {
return '';
}
const allowedTags = new Set(options.allowedTags || ALLOWED_TAGS);
const allowedAttributes = new Set(options.allowedAttributes || ALLOWED_ATTRIBUTES);
// Create a temporary container
const container = document.createElement('div');
container.innerHTML = html;
// Recursively clean elements
function cleanElement(element: Element): void {
// Remove script tags entirely
if (element.tagName.toLowerCase() === 'script') {
element.remove();
return;
}
// Remove style tags entirely
if (element.tagName.toLowerCase() === 'style') {
element.remove();
return;
}
// Remove event handlers and dangerous attributes
const attributes = Array.from(element.attributes);
for (const attr of attributes) {
const attrName = attr.name.toLowerCase();
// Remove event handlers (onclick, onload, etc.)
if (attrName.startsWith('on')) {
element.removeAttribute(attr.name);
continue;
}
// Remove javascript: URLs
if (attrName === 'href' || attrName === 'src') {
const value = attr.value.toLowerCase().trim();
if (value.startsWith('javascript:') || value.startsWith('data:text/html')) {
element.removeAttribute(attr.name);
continue;
}
}
// Remove data attributes if not allowed
if (attrName.startsWith('data-') && !options.allowDataAttributes) {
element.removeAttribute(attr.name);
continue;
}
// Remove non-allowed attributes
if (!allowedAttributes.has(attrName)) {
element.removeAttribute(attr.name);
}
}
// Remove non-allowed tags (but keep their content)
if (!allowedTags.has(element.tagName.toLowerCase())) {
const parent = element.parentNode;
while (element.firstChild) {
parent?.insertBefore(element.firstChild, element);
}
parent?.removeChild(element);
return;
}
// Recursively clean child elements
Array.from(element.children).forEach(cleanElement);
}
// Clean all elements
Array.from(container.children).forEach(cleanElement);
return container.innerHTML;
}
// ============================================================================
// URL Validation
// ============================================================================
/**
* Allowed URL schemes
*/
const ALLOWED_SCHEMES = new Set([
'http', 'https', 'mailto', 'tel', 'ftp', 'file',
]);
/**
* Validate and sanitize a URL
*
* @param url - The URL to validate
* @param options - Validation options
* @returns The validated URL or null if invalid
*/
export function validateUrl(
url: string,
options: {
allowedSchemes?: string[];
allowLocalhost?: boolean;
allowPrivateIp?: boolean;
maxLength?: number;
} = {}
): string | null {
if (typeof url !== 'string' || url.length === 0) {
return null;
}
const maxLength = options.maxLength || 2048;
if (url.length > maxLength) {
return null;
}
try {
const parsed = new URL(url);
// Check scheme
const allowedSchemes = new Set(options.allowedSchemes || ALLOWED_SCHEMES);
if (!allowedSchemes.has(parsed.protocol.replace(':', ''))) {
return null;
}
// Check for localhost
if (!options.allowLocalhost) {
if (parsed.hostname === 'localhost' ||
parsed.hostname === '127.0.0.1' ||
parsed.hostname === '[::1]') {
return null;
}
}
// Check for private IP ranges
if (!options.allowPrivateIp) {
const privateIpRegex = /^(10\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.)/;
if (privateIpRegex.test(parsed.hostname)) {
return null;
}
}
return parsed.toString();
} catch {
return null;
}
}
/**
* Check if a URL is safe for redirect
* Prevents open redirect vulnerabilities
*
* @param url - The URL to check
* @returns True if the URL is safe for redirect
*/
export function isSafeRedirectUrl(url: string): boolean {
if (typeof url !== 'string' || url.length === 0) {
return false;
}
// Relative URLs are generally safe
if (url.startsWith('/') && !url.startsWith('//')) {
return true;
}
// Check for javascript: protocol
const lowerUrl = url.toLowerCase().trim();
if (lowerUrl.startsWith('javascript:')) {
return false;
}
// Check for data: protocol
if (lowerUrl.startsWith('data:')) {
return false;
}
// Validate as absolute URL
const validated = validateUrl(url, { allowLocalhost: false });
return validated !== null;
}
// ============================================================================
// Path Validation
// ============================================================================
/**
* Validate a file path to prevent path traversal attacks
*
* @param path - The path to validate
* @param options - Validation options
* @returns The validated path or null if invalid
*/
export function validatePath(
path: string,
options: {
allowAbsolute?: boolean;
allowParentDir?: boolean;
maxLength?: number;
allowedExtensions?: string[];
baseDir?: string;
} = {}
): string | null {
if (typeof path !== 'string' || path.length === 0) {
return null;
}
const maxLength = options.maxLength || 4096;
if (path.length > maxLength) {
return null;
}
// Normalize path separators
let normalized = path.replace(/\\/g, '/');
// Check for null bytes
if (normalized.includes('\0')) {
return null;
}
// Check for path traversal
if (!options.allowParentDir) {
if (normalized.includes('..') || normalized.includes('./')) {
return null;
}
}
// Check for absolute paths
if (!options.allowAbsolute) {
if (normalized.startsWith('/') || /^[a-zA-Z]:/.test(normalized)) {
return null;
}
}
// Check extensions
if (options.allowedExtensions && options.allowedExtensions.length > 0) {
const ext = normalized.split('.').pop()?.toLowerCase();
if (!ext || !options.allowedExtensions.includes(ext)) {
return null;
}
}
// If baseDir is specified, ensure path is within it
if (options.baseDir) {
const baseDir = options.baseDir.replace(/\\/g, '/').replace(/\/$/, '');
if (!normalized.startsWith(baseDir)) {
// Try to resolve relative to baseDir
try {
const resolved = new URL(normalized, `file://${baseDir}/`).pathname;
if (!resolved.startsWith(baseDir)) {
return null;
}
normalized = resolved;
} catch {
return null;
}
}
}
return normalized;
}
// ============================================================================
// Input Validation Helpers
// ============================================================================
/**
* Validate an email address
*
* @param email - The email to validate
* @returns True if valid
*/
export function isValidEmail(email: string): boolean {
if (typeof email !== 'string' || email.length === 0 || email.length > 254) {
return false;
}
// RFC 5322 compliant regex (simplified)
const emailRegex = /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;
return emailRegex.test(email);
}
/**
* Validate a username
*
* @param username - The username to validate
* @param options - Validation options
* @returns True if valid
*/
export function isValidUsername(
username: string,
options: {
minLength?: number;
maxLength?: number;
allowedChars?: RegExp;
} = {}
): boolean {
const minLength = options.minLength || 3;
const maxLength = options.maxLength || 30;
const allowedChars = options.allowedChars || /^[a-zA-Z0-9_-]+$/;
if (typeof username !== 'string') {
return false;
}
if (username.length < minLength || username.length > maxLength) {
return false;
}
return allowedChars.test(username);
}
/**
* Validate a password strength
*
* @param password - The password to validate
* @param options - Validation options
* @returns Validation result with strength score
*/
export function validatePasswordStrength(
password: string,
options: {
minLength?: number;
requireUppercase?: boolean;
requireLowercase?: boolean;
requireNumber?: boolean;
requireSpecial?: boolean;
maxLength?: number;
} = {}
): {
valid: boolean;
score: number;
issues: string[];
} {
const minLength = options.minLength || 8;
const maxLength = options.maxLength || 128;
const issues: string[] = [];
let score = 0;
if (typeof password !== 'string') {
return { valid: false, score: 0, issues: ['Password must be a string'] };
}
if (password.length < minLength) {
issues.push(`Password must be at least ${minLength} characters`);
} else {
score += Math.min(password.length / 8, 3) * 10;
}
if (password.length > maxLength) {
issues.push(`Password must be at most ${maxLength} characters`);
}
if (options.requireUppercase !== false && !/[A-Z]/.test(password)) {
issues.push('Password must contain an uppercase letter');
} else if (/[A-Z]/.test(password)) {
score += 10;
}
if (options.requireLowercase !== false && !/[a-z]/.test(password)) {
issues.push('Password must contain a lowercase letter');
} else if (/[a-z]/.test(password)) {
score += 10;
}
if (options.requireNumber !== false && !/[0-9]/.test(password)) {
issues.push('Password must contain a number');
} else if (/[0-9]/.test(password)) {
score += 10;
}
if (options.requireSpecial !== false && !/[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/.test(password)) {
issues.push('Password must contain a special character');
} else if (/[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/.test(password)) {
score += 15;
}
// Check for common patterns
const commonPatterns = [
/123/,
/abc/i,
/qwe/i,
/password/i,
/admin/i,
/letmein/i,
];
for (const pattern of commonPatterns) {
if (pattern.test(password)) {
issues.push('Password contains a common pattern');
score -= 10;
break;
}
}
return {
valid: issues.length === 0,
score: Math.max(0, Math.min(100, score)),
issues,
};
}
/**
* Sanitize a filename
*
* @param filename - The filename to sanitize
* @returns The sanitized filename
*/
export function sanitizeFilename(filename: string): string {
if (typeof filename !== 'string') {
return '';
}
// Remove path separators
let sanitized = filename.replace(/[\/\\]/g, '_');
// Remove null bytes
sanitized = sanitized.replace(/\0/g, '');
// Remove control characters
sanitized = sanitized.replace(/[\x00-\x1f\x7f]/g, '');
// Remove dangerous characters
sanitized = sanitized.replace(/[<>:"|?*]/g, '_');
// Trim whitespace and dots
sanitized = sanitized.trim().replace(/^\.+|\.+$/g, '');
// Limit length
if (sanitized.length > 255) {
const ext = sanitized.split('.').pop();
const name = sanitized.slice(0, -(ext?.length || 0) - 1);
sanitized = name.slice(0, 250 - (ext?.length || 0)) + (ext ? `.${ext}` : '');
}
return sanitized;
}
/**
* Sanitize JSON input
* Prevents prototype pollution and other JSON-based attacks
*
* @param json - The JSON string to sanitize
* @returns The parsed and sanitized object or null if invalid
*/
export function sanitizeJson<T = unknown>(json: string): T | null {
if (typeof json !== 'string') {
return null;
}
try {
const parsed = JSON.parse(json);
// Check for prototype pollution
if (typeof parsed === 'object' && parsed !== null) {
const dangerousKeys = ['__proto__', 'constructor', 'prototype'];
for (const key of dangerousKeys) {
if (key in parsed) {
delete (parsed as Record<string, unknown>)[key];
}
}
}
return parsed as T;
} catch {
return null;
}
}
// ============================================================================
// Rate Limiting
// ============================================================================
interface RateLimitEntry {
count: number;
resetAt: number;
}
const rateLimitStore = new Map<string, RateLimitEntry>();
/**
* Check if an action is rate limited
*
* @param key - The rate limit key (e.g., 'api:username')
* @param maxAttempts - Maximum attempts allowed
* @param windowMs - Time window in milliseconds
* @returns True if rate limited (should block), false otherwise
*/
export function isRateLimited(
key: string,
maxAttempts: number,
windowMs: number
): boolean {
const now = Date.now();
const entry = rateLimitStore.get(key);
if (!entry || now > entry.resetAt) {
rateLimitStore.set(key, {
count: 1,
resetAt: now + windowMs,
});
return false;
}
if (entry.count >= maxAttempts) {
return true;
}
entry.count++;
return false;
}
/**
* Reset rate limit for a key
*
* @param key - The rate limit key to reset
*/
export function resetRateLimit(key: string): void {
rateLimitStore.delete(key);
}
/**
* Get remaining attempts for a rate-limited action
*
* @param key - The rate limit key
* @param maxAttempts - Maximum attempts allowed
* @returns Number of remaining attempts
*/
export function getRemainingAttempts(key: string, maxAttempts: number): number {
const entry = rateLimitStore.get(key);
if (!entry || Date.now() > entry.resetAt) {
return maxAttempts;
}
return Math.max(0, maxAttempts - entry.count);
}
// ============================================================================
// Content Security Policy Helpers
// ============================================================================
/**
* Generate a nonce for CSP
*
* @returns A base64-encoded nonce
*/
export function generateCspNonce(): string {
const array = crypto.getRandomValues(new Uint8Array(16));
return btoa(String.fromCharCode(...array));
}
/**
* CSP directives for secure applications
*/
export const DEFAULT_CSP_DIRECTIVES = {
'default-src': "'self'",
'script-src': "'self' 'unsafe-inline'", // Note: unsafe-inline should be avoided in production
'style-src': "'self' 'unsafe-inline'",
'img-src': "'self' data: https:",
'font-src': "'self'",
'connect-src': "'self' ws: wss:",
'frame-ancestors': "'none'",
'base-uri': "'self'",
'form-action': "'self'",
};
/**
* Build a Content Security Policy header value
*
* @param directives - CSP directives
* @returns The CSP header value
*/
export function buildCspHeader(
directives: Partial<typeof DEFAULT_CSP_DIRECTIVES> = DEFAULT_CSP_DIRECTIVES
): string {
const merged = { ...DEFAULT_CSP_DIRECTIVES, ...directives };
return Object.entries(merged)
.map(([key, value]) => `${key} ${value}`)
.join('; ');
}
// ============================================================================
// Security Headers Validation
// ============================================================================
/**
* Check if security headers are properly set (for browser environments)
*/
export function checkSecurityHeaders(): {
secure: boolean;
issues: string[];
} {
const issues: string[] = [];
// Check if running over HTTPS
if (typeof window !== 'undefined') {
if (window.location.protocol !== 'https:' && window.location.hostname !== 'localhost') {
issues.push('Application is not running over HTTPS');
}
// Check for mixed content
if (window.location.protocol === 'https:') {
// This would require DOM inspection to detect mixed content
}
}
return {
secure: issues.length === 0,
issues,
};
}
// ============================================================================
// Secure Random Generation
// ============================================================================
/**
* Generate a secure random token
*
* @param length - Token length in bytes
* @returns Hex-encoded random token
*/
export function generateSecureToken(length: number = 32): string {
const array = crypto.getRandomValues(new Uint8Array(length));
return Array.from(array)
.map(b => b.toString(16).padStart(2, '0'))
.join('');
}
/**
* Generate a secure random ID
*
* @param prefix - Optional prefix
* @returns A secure random ID
*/
export function generateSecureId(prefix: string = ''): string {
const timestamp = Date.now().toString(36);
const random = generateSecureToken(8);
return prefix ? `${prefix}_${timestamp}_${random}` : `${timestamp}_${random}`;
}