OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-wallet-lib / input-sanitizer.cjs

228 lines · javascript ref:
/**
 * 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
}