|
|
|
|
|
|
|
|
|
|
|
export class InputSanitizer { |
|
|
|
static sanitizeHTML(input: string): string { |
|
const element = document.createElement('div'); |
|
element.textContent = input; |
|
return element.innerHTML; |
|
} |
|
|
|
|
|
static sanitizeURL(url: string): string | null { |
|
try { |
|
const parsedUrl = new URL(url); |
|
|
|
|
|
const allowedProtocols = ['http:', 'https:', 'mailto:', 'tel:']; |
|
|
|
if (!allowedProtocols.includes(parsedUrl.protocol)) { |
|
console.warn('Protocolo não permitido:', parsedUrl.protocol); |
|
return null; |
|
} |
|
|
|
|
|
|
|
const suspiciousSchemes = [ |
|
'javascript:', |
|
'data:', |
|
'vbscript:', |
|
'file:', |
|
'ftp:' |
|
]; |
|
|
|
if (suspiciousSchemes.some(scheme => url.toLowerCase().includes(scheme))) { |
|
console.warn('URL suspeita detectada:', url); |
|
return null; |
|
} |
|
|
|
return parsedUrl.toString(); |
|
} catch (error) { |
|
console.warn('URL inválida:', url, error); |
|
return null; |
|
} |
|
} |
|
|
|
|
|
static validateNumber(input: any, min?: number, max?: number): number | null { |
|
const num = Number(input); |
|
|
|
if (isNaN(num) || !isFinite(num)) { |
|
return null; |
|
} |
|
|
|
if (min !== undefined && num < min) { |
|
return null; |
|
} |
|
|
|
if (max !== undefined && num > max) { |
|
return null; |
|
} |
|
|
|
return num; |
|
} |
|
|
|
|
|
static validateString(input: any, maxLength: number = 1000): string | null { |
|
if (typeof input !== 'string') { |
|
return null; |
|
} |
|
|
|
if (input.length > maxLength) { |
|
console.warn('String muito longa:', input.length, 'max:', maxLength); |
|
return null; |
|
} |
|
|
|
|
|
|
|
const sanitized = input.replace(/[\x00-\x1F\x7F]/g, ''); |
|
|
|
return sanitized; |
|
} |
|
} |
|
|
|
|
|
export class CSPHelper { |
|
private static policies: Record<string, string[]> = { |
|
'default-src': ["'self'"], |
|
'script-src': ["'self'", "'unsafe-inline'", "'unsafe-eval'"], |
|
'style-src': ["'self'", "'unsafe-inline'", 'https://fonts.googleapis.com'], |
|
'font-src': ["'self'", 'https://fonts.gstatic.com'], |
|
'img-src': ["'self'", 'data:', 'https:'], |
|
'connect-src': ["'self'", 'https://servicebus2.caixa.gov.br'], |
|
'frame-src': ["'none'"], |
|
'object-src': ["'none'"], |
|
'base-uri': ["'self'"], |
|
'form-action': ["'self'"] |
|
}; |
|
|
|
static generateCSP(): string { |
|
return Object.entries(this.policies) |
|
.map(([directive, sources]) => `${directive} ${sources.join(' ')}`) |
|
.join('; '); |
|
} |
|
|
|
static addAllowedSource(directive: string, source: string): void { |
|
if (this.policies[directive]) { |
|
if (!this.policies[directive].includes(source)) { |
|
this.policies[directive].push(source); |
|
} |
|
} |
|
} |
|
|
|
static setCSPHeader(): void { |
|
const csp = this.generateCSP(); |
|
const meta = document.createElement('meta'); |
|
meta.httpEquiv = 'Content-Security-Policy'; |
|
meta.content = csp; |
|
document.head.appendChild(meta); |
|
} |
|
} |
|
|
|
|
|
export class RateLimiter { |
|
private calls: Map<string, number[]> = new Map(); |
|
private limits: Map<string, { maxCalls: number; windowMs: number }> = new Map(); |
|
|
|
setLimit(key: string, maxCalls: number, windowMs: number): void { |
|
this.limits.set(key, { maxCalls, windowMs }); |
|
} |
|
|
|
isAllowed(key: string): boolean { |
|
const limit = this.limits.get(key); |
|
if (!limit) return true; |
|
|
|
const now = Date.now(); |
|
const calls = this.calls.get(key) || []; |
|
|
|
|
|
const validCalls = calls.filter(time => now - time < limit.windowMs); |
|
|
|
if (validCalls.length >= limit.maxCalls) { |
|
console.warn(`Rate limit excedido para ${key}`); |
|
return false; |
|
} |
|
|
|
validCalls.push(now); |
|
this.calls.set(key, validCalls); |
|
return true; |
|
} |
|
|
|
getRemainingCalls(key: string): number { |
|
const limit = this.limits.get(key); |
|
if (!limit) return Infinity; |
|
|
|
const now = Date.now(); |
|
const calls = this.calls.get(key) || []; |
|
const validCalls = calls.filter(time => now - time < limit.windowMs); |
|
|
|
return Math.max(0, limit.maxCalls - validCalls.length); |
|
} |
|
|
|
|
|
reset(key?: string): void { |
|
if (key) { |
|
this.calls.delete(key); |
|
} else { |
|
this.calls.clear(); |
|
} |
|
console.log(`🔄 Rate limiter resetado${key ? ` para ${key}` : ''}`); |
|
} |
|
} |
|
|
|
|
|
export class APIDataValidator { |
|
static validateLotomaniaResult(data: any): { |
|
isValid: boolean; |
|
sanitized: any; |
|
errors: string[] |
|
} { |
|
const errors: string[] = []; |
|
const sanitized: any = {}; |
|
|
|
try { |
|
if (!data || typeof data !== 'object') { |
|
errors.push('Dados da API não são um objeto válido'); |
|
return { isValid: false, sanitized: {}, errors }; |
|
} |
|
|
|
|
|
const numeroRaw = data.numero || data.concurso; |
|
if (!numeroRaw || typeof numeroRaw !== 'number' || numeroRaw < 1 || numeroRaw > 9999) { |
|
errors.push(`Número do concurso inválido: ${numeroRaw}`); |
|
sanitized.numero = Math.max(1, parseInt(numeroRaw?.toString()) || 1); |
|
} else { |
|
sanitized.numero = numeroRaw; |
|
} |
|
|
|
|
|
const dezenasRaw = data.listaDezenas || data.dezenas || data.numeros; |
|
if (!Array.isArray(dezenasRaw)) { |
|
errors.push('Lista de dezenas não é um array'); |
|
sanitized.listaDezenas = this.generateDefaultNumbers(); |
|
} else { |
|
const validNumbers = dezenasRaw |
|
.map((dezena: any) => { |
|
const num = parseInt(dezena?.toString(), 10); |
|
return !isNaN(num) && num >= 0 && num <= 99 ? num : null; |
|
}) |
|
.filter((num: number | null) => num !== null) as number[]; |
|
|
|
if (validNumbers.length !== 20) { |
|
errors.push(`Esperadas 20 dezenas, encontradas ${validNumbers.length} válidas`); |
|
|
|
|
|
while (validNumbers.length < 20) { |
|
const randomNum = Math.floor(Math.random() * 100); |
|
if (!validNumbers.includes(randomNum)) { |
|
validNumbers.push(randomNum); |
|
} |
|
} |
|
|
|
if (validNumbers.length > 20) { |
|
validNumbers.splice(20); |
|
} |
|
} |
|
|
|
sanitized.listaDezenas = validNumbers.sort((a, b) => a - b); |
|
} |
|
|
|
|
|
const dataRaw = data.dataApuracao || data.data; |
|
if (!dataRaw || typeof dataRaw !== 'string' || dataRaw.length < 8) { |
|
errors.push('Data de apuração inválida'); |
|
sanitized.dataApuracao = new Date().toLocaleDateString('pt-BR'); |
|
} else { |
|
|
|
const dateRegex = /^(\d{2})\/(\d{2})\/(\d{4})$/; |
|
if (!dateRegex.test(dataRaw)) { |
|
errors.push('Formato de data inválido'); |
|
sanitized.dataApuracao = new Date().toLocaleDateString('pt-BR'); |
|
} else { |
|
sanitized.dataApuracao = dataRaw; |
|
} |
|
} |
|
|
|
|
|
const campos = ['valorArrecadado', 'valorAcumuladoProximoConcurso', 'valorEstimadoProximoConcurso']; |
|
campos.forEach(campo => { |
|
const valor = data[campo]; |
|
if (valor !== undefined && valor !== null) { |
|
const valorNum = parseFloat(valor.toString()); |
|
if (isNaN(valorNum) || valorNum < 0) { |
|
errors.push(`${campo} inválido: ${valor}`); |
|
sanitized[campo] = 0; |
|
} else { |
|
sanitized[campo] = valorNum; |
|
} |
|
} else { |
|
sanitized[campo] = 0; |
|
} |
|
}); |
|
|
|
|
|
sanitized.acumulado = Boolean(data.acumulado); |
|
sanitized.localSorteio = this.sanitizeString(data.localSorteio) || 'ESPAÇO DA SORTE'; |
|
|
|
const isValid = errors.length === 0; |
|
if (isValid) { |
|
console.log('✅ Dados da API validados com sucesso'); |
|
} else { |
|
console.warn('⚠️ Dados da API sanitizados:', errors); |
|
} |
|
|
|
return { isValid, sanitized, errors }; |
|
|
|
} catch (error) { |
|
errors.push(`Erro durante validação: ${error}`); |
|
return { |
|
isValid: false, |
|
sanitized: this.getDefaultResult(), |
|
errors |
|
}; |
|
} |
|
} |
|
|
|
private static generateDefaultNumbers(): number[] { |
|
const numbers = new Set<number>(); |
|
while (numbers.size < 20) { |
|
numbers.add(Math.floor(Math.random() * 100)); |
|
} |
|
return Array.from(numbers).sort((a, b) => a - b); |
|
} |
|
|
|
private static sanitizeString(input: any): string { |
|
if (typeof input !== 'string') return ''; |
|
return input.replace(/[<>\"'&]/g, '').trim().substring(0, 100); |
|
} |
|
|
|
private static getDefaultResult(): any { |
|
return { |
|
numero: 1, |
|
listaDezenas: this.generateDefaultNumbers(), |
|
dataApuracao: new Date().toLocaleDateString('pt-BR'), |
|
valorArrecadado: 0, |
|
valorAcumuladoProximoConcurso: 0, |
|
valorEstimadoProximoConcurso: 0, |
|
acumulado: false, |
|
localSorteio: 'ESPAÇO DA SORTE' |
|
}; |
|
} |
|
|
|
private static isValidDate(dateString: string): boolean { |
|
try { |
|
const date = new Date(dateString); |
|
return date instanceof Date && !isNaN(date.getTime()); |
|
} catch { |
|
return false; |
|
} |
|
} |
|
} |
|
|
|
|
|
export class TimingProtection { |
|
private static readonly SAFE_DELAY = 100; |
|
|
|
|
|
static async safeDelay(): Promise<void> { |
|
const delay = Math.random() * this.SAFE_DELAY; |
|
return new Promise(resolve => setTimeout(resolve, delay)); |
|
} |
|
|
|
|
|
static async withConstantTiming<T>( |
|
fn: () => Promise<T>, |
|
targetTime: number = 1000 |
|
): Promise<T> { |
|
const start = Date.now(); |
|
|
|
try { |
|
const result = await fn(); |
|
const elapsed = Date.now() - start; |
|
|
|
if (elapsed < targetTime) { |
|
await new Promise(resolve => setTimeout(resolve, targetTime - elapsed)); |
|
} |
|
|
|
return result; |
|
} catch (error) { |
|
const elapsed = Date.now() - start; |
|
|
|
if (elapsed < targetTime) { |
|
await new Promise(resolve => setTimeout(resolve, targetTime - elapsed)); |
|
} |
|
|
|
throw error; |
|
} |
|
} |
|
} |
|
|
|
|
|
export class IntegrityMonitor { |
|
private checksums: Map<string, string> = new Map(); |
|
private violations: string[] = []; |
|
|
|
|
|
private calculateChecksum(data: string): string { |
|
let hash = 0; |
|
for (let i = 0; i < data.length; i++) { |
|
const char = data.charCodeAt(i); |
|
hash = ((hash << 5) - hash) + char; |
|
hash = hash & hash; |
|
} |
|
return hash.toString(16); |
|
} |
|
|
|
|
|
registerChecksum(key: string, data: string): void { |
|
const checksum = this.calculateChecksum(data); |
|
this.checksums.set(key, checksum); |
|
} |
|
|
|
|
|
verifyIntegrity(key: string, data: string): boolean { |
|
const expectedChecksum = this.checksums.get(key); |
|
if (!expectedChecksum) { |
|
console.warn('Checksum não encontrado para:', key); |
|
return false; |
|
} |
|
|
|
const actualChecksum = this.calculateChecksum(data); |
|
if (actualChecksum !== expectedChecksum) { |
|
const violation = `Violação de integridade detectada para ${key}`; |
|
this.violations.push(violation); |
|
console.error(violation); |
|
return false; |
|
} |
|
|
|
return true; |
|
} |
|
|
|
getViolations(): string[] { |
|
return [...this.violations]; |
|
} |
|
|
|
clearViolations(): void { |
|
this.violations = []; |
|
} |
|
} |
|
|
|
|
|
export const inputSanitizer = new InputSanitizer(); |
|
export const rateLimiter = new RateLimiter(); |
|
export const integrityMonitor = new IntegrityMonitor(); |
|
|
|
|
|
export function setupSecurityDefaults(): void { |
|
|
|
rateLimiter.setLimit('api-calls', 10, 60000); |
|
rateLimiter.setLimit('user-input', 50, 60000); |
|
|
|
|
|
if (process.env.NODE_ENV === 'production') { |
|
CSPHelper.setCSPHeader(); |
|
} |
|
|
|
|
|
integrityMonitor.registerChecksum('app-version', process.env.REACT_APP_VERSION || '1.0.0'); |
|
} |
|
|
|
|
|
export function useSecurityUtils() { |
|
return { |
|
sanitizeHTML: InputSanitizer.sanitizeHTML, |
|
sanitizeURL: InputSanitizer.sanitizeURL, |
|
validateNumber: InputSanitizer.validateNumber, |
|
validateString: InputSanitizer.validateString, |
|
isRateLimited: (key: string) => !rateLimiter.isAllowed(key), |
|
getRemainingCalls: (key: string) => rateLimiter.getRemainingCalls(key), |
|
validateAPIData: APIDataValidator.validateLotomaniaResult |
|
}; |
|
} |
|
|