/**
* FILE: lib/input-sanitizer.cjs
* STATUS: ACTIVE
* RUNTIME: Bare
* MODULE: CommonJS
*
* PURPOSE:
* Input sanitization to prevent injection attacks and validate user inputs
*
* USED BY:
* - wallet-server.cjs (all user input validation)
*
* KEY EXPORTS:
* - sanitizeString(str, maxLength) - Generic string sanitization
* - sanitizeContactName(name) - Contact name validation
* - sanitizeDeviceName(name) - Device name validation
* - sanitizeNotes(notes) - Notes/description validation
* - validateChain(chain) - Chain identifier validation
* - validateNetwork(network) - Network identifier validation
*
* PROTECTION:
* - Removes null bytes (string termination attacks)
* - Enforces length limits (DoS prevention)
* - Validates character sets (injection prevention)
* - Trims whitespace (normalization)
*/
/**
* Generic string sanitization
* @param {string} str - Input string
* @param {number} maxLength - Maximum allowed length
* @returns {string} Sanitized string
* @throws {Error} If input is invalid
*/
function sanitizeString(str, maxLength = 1000) {
if (typeof str !== 'string') {
throw new Error('Input must be a string')
}
// Trim whitespace
let sanitized = str.trim()
// Truncate to max length (sanitize, don't throw)
if (sanitized.length > maxLength) {
sanitized = sanitized.slice(0, maxLength)
}
// Remove null bytes (can cause string termination attacks)
sanitized = sanitized.replace(/\0/g, '')
return sanitized
}
/**
* Sanitize contact name
* Allows: alphanumeric, spaces, and common punctuation
* @param {string} name - Contact name
* @returns {string} Sanitized name
* @throws {Error} If name is invalid
*/
function sanitizeContactName(name) {
const sanitized = sanitizeString(name, 100)
if (sanitized.length === 0) {
throw new Error('Name cannot be empty')
}
// Only allow safe characters (alphanumeric, spaces, common punctuation)
if (!sanitized.match(/^[a-zA-Z0-9\s\-_.,'()]+$/)) {
throw new Error('Name contains invalid characters (allowed: letters, numbers, spaces, -_.\'(),)')
}
return sanitized
}
/**
* Sanitize device name
* Allows: alphanumeric, spaces, hyphens, underscores
* @param {string} name - Device name
* @returns {string} Sanitized name
* @throws {Error} If name is invalid
*/
function sanitizeDeviceName(name) {
const sanitized = sanitizeString(name, 50)
if (sanitized.length === 0) {
throw new Error('Device name cannot be empty')
}
// Only allow alphanumeric, spaces, hyphens, underscores
if (!sanitized.match(/^[a-zA-Z0-9\s\-_]+$/)) {
throw new Error('Device name contains invalid characters (allowed: letters, numbers, spaces, -_)')
}
return sanitized
}
/**
* Sanitize notes/description fields
* Allows: alphanumeric, spaces, and extended punctuation
* @param {string} notes - Notes text
* @returns {string} Sanitized notes
* @throws {Error} If notes are invalid
*/
function sanitizeNotes(notes) {
// Check for non-string types
if (typeof notes !== 'string') {
throw new Error('Notes must be a string')
}
// Notes can be empty strings
if (notes.trim().length === 0) {
return ''
}
let sanitized = sanitizeString(notes, 500)
// Remove dangerous characters (< and >) instead of throwing
sanitized = sanitized.replace(/[<>]/g, '')
return sanitized
}
/**
* Validate chain identifier
* @param {string} chain - Chain name
* @returns {string|boolean} Validated chain or false if invalid
*/
function validateChain(chain) {
if (typeof chain !== 'string') {
return false
}
const validChains = ['bitcoin', 'ethereum']
const lowerChain = chain.toLowerCase()
if (!validChains.includes(lowerChain)) {
return false
}
return lowerChain
}
/**
* Validate network identifier
* @param {string} network - Network name
* @returns {string|boolean} Validated network or false if invalid
*/
function validateNetwork(network) {
if (typeof network !== 'string') {
return false
}
const validNetworks = ['testnet', 'mainnet', 'sepolia']
const lowerNetwork = network.toLowerCase()
if (!validNetworks.includes(lowerNetwork)) {
return false
}
return lowerNetwork
}
/**
* Sanitize hex string (for keys, hashes, etc.)
* @param {string} hex - Hex string
* @param {number} expectedLength - Expected length (0 = any length)
* @returns {string} Sanitized hex string (lowercase)
* @throws {Error} If hex is invalid
*/
function sanitizeHex(hex, expectedLength = 0) {
if (typeof hex !== 'string') {
throw new Error('Hex string must be a string')
}
// Remove 0x prefix if present
let sanitized = hex.toLowerCase()
if (sanitized.startsWith('0x')) {
sanitized = sanitized.slice(2)
}
// Remove non-hex characters instead of throwing
sanitized = sanitized.replace(/[^0-9a-f]/g, '')
// Check length if specified
if (expectedLength > 0 && sanitized.length !== expectedLength) {
throw new Error(`Invalid hex length (expected ${expectedLength}, got ${sanitized.length})`)
}
return sanitized
}
/**
* Validate invite code format
* @param {string} inviteCode - Base64 invite code
* @returns {string|boolean} Validated invite code or false if invalid
*/
function validateInviteCode(inviteCode) {
if (typeof inviteCode !== 'string') {
return false
}
const sanitized = inviteCode.trim()
// Check for valid base64 characters
if (!sanitized.match(/^[A-Za-z0-9+/]+=*$/)) {
return false
}
// Check reasonable length (should be ~200-300 chars for signed invite)
if (sanitized.length < 50 || sanitized.length > 1000) {
return false
}
return sanitized
}
module.exports = {
sanitizeString,
sanitizeContactName,
sanitizeDeviceName,
sanitizeNotes,
validateChain,
validateNetwork,
sanitizeHex,
validateInviteCode
}