Source identity
| Field | Recorded value |
|---|---|
| Source type | GitHub source |
| Original record | Janady13/llms-txt-hub / apps/web/lib/security-utils.ts |
| Date | Not specified in source |
| Repository | Janady13/llms-txt-hub |
| Revision | e0f4bf978d4df168369c7601d9c5f5ea89e84377 |
| Branch | main |
| Path or archive member | apps/web/lib/security-utils.ts |
| Original SHA-256 | 1eb2da0f249da710e61cb9aaf452b461a95a54f9c693e238956f819137414409 |
| Git blob SHA | cf10c0b3d6d29ad652c39420b49e05b5967f965d |
| Reading-copy SHA-256 | 1eb2da0f249da710e61cb9aaf452b461a95a54f9c693e238956f819137414409 |
Recovered source transcript
Download complete source text ↓205 lines · 5.5 KB
/**
* Security utilities for input validation and sanitization
*/
import validator from 'validator'
import { sanitizeErrorMessage, stripHtml } from './security-utils-helpers'
// Re-export helpers for backward compatibility
export {
checkRateLimit,
clearRateLimiting,
escapeHtml,
getRateLimitKey,
stripHtml
} from './security-utils-helpers'
/**
* Sanitize text input to prevent XSS attacks
*/
export function sanitizeText(input: string | null | undefined): string | null {
if (input === null || input === undefined) return null
if (input === '') return ''
const cleaned = stripHtml(input)
// Additional sanitization: remove zero-width characters but preserve line breaks
return cleaned
.replace(/\u200B|\u200C|\u200D|\uFEFF/g, '') // Remove zero-width characters
.trim()
}
/**
* Validate and sanitize URL
*/
export function sanitizeUrl(url: string | null | undefined): string | null {
if (!url) return null
const trimmed = url.trim()
// Validate URL format
if (
!validator.isURL(trimmed, {
protocols: ['http', 'https'],
require_protocol: true,
require_valid_protocol: true,
allow_query_components: true,
allow_fragments: true,
allow_underscores: true,
disallow_auth: false,
require_tld: false // Allow localhost and IP addresses
})
) {
throw new Error('Invalid URL format')
}
// Additional check for malicious URLs
const lowerUrl = trimmed.toLowerCase()
if (
lowerUrl.includes('javascript:') ||
lowerUrl.includes('data:') ||
lowerUrl.includes('vbscript:') ||
lowerUrl.includes('file:')
) {
throw new Error('Invalid URL protocol')
}
return trimmed
}
/**
* Validate username format
*/
export function validateUsername(username: string): { valid: boolean; error?: string } {
if (!username) {
return { valid: false, error: 'Username is required' }
}
const sanitized = sanitizeText(username)
if (!sanitized) {
return { valid: false, error: 'Username cannot be empty' }
}
if (sanitized.length < 3) {
return { valid: false, error: 'Username must be at least 3 characters' }
}
if (sanitized.length > 30) {
return { valid: false, error: 'Username must be 30 characters or less' }
}
if (!/^[a-zA-Z0-9_-]+$/.test(sanitized)) {
return {
valid: false,
error: 'Username can only contain letters, numbers, underscores, and hyphens'
}
}
// Check for reserved usernames
const reservedUsernames = [
'admin',
'api',
'root',
'system',
'support',
'help',
'about',
'login',
'signup',
'www',
'mail',
'ftp'
]
if (reservedUsernames.includes(sanitized.toLowerCase())) {
return { valid: false, error: 'Username is reserved' }
}
return { valid: true }
}
/**
* Create a safe error message that doesn't leak sensitive information
*/
export function createSafeErrorMessage(
error: unknown,
options?: { includeStack?: boolean } | string
): string {
// Handle old API where second param was fallbackMessage
const fallbackMessage = typeof options === 'string' ? options : 'An error occurred'
if (error === null || error === undefined) {
return fallbackMessage
}
if (typeof error === 'string') {
// Simple string error - just return it if safe
return sanitizeErrorMessage(error) || fallbackMessage
}
if (error instanceof Error) {
// Map common error patterns to user-friendly messages
const errorMappings: Array<{ pattern: RegExp; message: string }> = [
{ pattern: /database.*connection.*failed/i, message: 'Database error occurred' },
{ pattern: /user not found/i, message: 'User not found' },
{ pattern: /invalid token/i, message: 'Authentication error' },
{ pattern: /rate limit exceeded/i, message: 'Too many requests' },
{ pattern: /ENOENT.*no such file/i, message: 'File not found' },
{ pattern: /ECONNREFUSED/i, message: 'Connection refused' },
{ pattern: /Timeout.*exceeded/i, message: 'Request timeout' },
{ pattern: /Invalid input/i, message: 'Invalid input' },
{ pattern: /Validation failed/i, message: 'Validation failed' },
{ pattern: /Resource not found/i, message: 'Resource not found' },
{ pattern: /Access denied/i, message: 'Access denied' },
{ pattern: /Too many requests/i, message: 'Too many requests' }
]
for (const { pattern, message } of errorMappings) {
if (pattern.test(error.message)) {
return message
}
}
// Sanitize the error message to remove sensitive info
return sanitizeErrorMessage(error.message) || fallbackMessage
}
return fallbackMessage
}
/**
* Validate request origin for CSRF protection
*/
export function validateOrigin(
request: Request,
allowedOrigins: string[] = []
): { valid: boolean; error?: string } {
const origin = request.headers.get('origin')
if (!origin) {
return { valid: false, error: 'Origin header required' }
}
// If allowed origins are provided, check against them
if (allowedOrigins.length > 0) {
if (allowedOrigins.includes(origin)) {
return { valid: true }
}
return { valid: false, error: 'Origin not allowed' }
}
// Default check against host
const host = request.headers.get('host')
if (!host) {
return { valid: false, error: 'Host header missing' }
}
try {
const originUrl = new URL(origin)
if (originUrl.host === host) {
return { valid: true }
}
return { valid: false, error: 'Origin does not match host' }
} catch {
return { valid: false, error: 'Invalid origin URL' }
}
}