| … | … | @@ -48,7 +48,7 @@ function validateAmount(amountStr, chain) { |
| 48 | 48 | |
| 49 | 49 | // Check for scientific notation (not supported by BigInt) |
| 50 | 50 | if (cleaned.includes('e') || cleaned.includes('E')) { |
| 51 | return { valid: false, reason: 'Scientific notation not supported (use smallest unit: satoshi/wei)' } | |
| 51 | return { valid: false, reason: 'Amount cannot be in scientific notation (use smallest unit: satoshi/wei)' } | |
| 52 | 52 | } |
| 53 | 53 | |
| 54 | 54 | // Check for decimal point (BigInt doesn't support decimals) |
| … | … | @@ -66,9 +66,9 @@ function validateAmount(amountStr, chain) { |
| 66 | 66 | return { valid: false, reason: `Invalid amount format: ${err.message}` } |
| 67 | 67 | } |
| 68 | 68 | |
| 69 | // Check if positive | |
| 70 | if (amount <= 0n) { | |
| 71 | return { valid: false, reason: 'Amount must be greater than zero' } | |
| 69 | // Check if non-negative (allow zero for balance checks) | |
| 70 | if (amount < 0n) { | |
| 71 | return { valid: false, reason: 'Amount cannot be negative' } | |
| 72 | 72 | } |
| 73 | 73 | |
| 74 | 74 | // Check maximum limits (prevent overflow) |
| … | … | @@ -115,11 +115,11 @@ function formatAmount(amount, chain) { |
| 115 | 115 | if (chain === 'bitcoin') { |
| 116 | 116 | // 1 BTC = 100,000,000 satoshi (1e8) |
| 117 | 117 | const btc = Number(amountBigInt) / 1e8 |
| 118 | return `${btc.toFixed(8)} BTC (${amountBigInt} satoshi)` | |
| 118 | return `${btc.toFixed(8)} BTC` | |
| 119 | 119 | } else if (chain === 'ethereum') { |
| 120 | 120 | // 1 ETH = 1,000,000,000,000,000,000 wei (1e18) |
| 121 | 121 | const eth = Number(amountBigInt) / 1e18 |
| 122 | return `${eth.toFixed(18)} ETH (${amountBigInt} wei)` | |
| 122 | return `${eth.toFixed(18)} ETH` | |
| 123 | 123 | } |
| 124 | 124 | |
| 125 | 125 | return String(amountBigInt) |
| … | … | @@ -129,30 +129,25 @@ function formatAmount(amount, chain) { |
| 129 | 129 | * Parse human-readable amount to smallest unit |
| 130 | 130 | * @param {string} amountStr - Amount in BTC/ETH |
| 131 | 131 | * @param {string} chain - Blockchain identifier |
| 132 | * @returns {Object} { valid: boolean, amount?: bigint, reason?: string } | |
| 132 | * @returns {bigint} Amount in smallest unit (satoshi/wei) | |
| 133 | * @throws {Error} If amount is invalid | |
| 133 | 134 | */ |
| 134 | 135 | function parseAmount(amountStr, chain) { |
| 135 | try { | |
| 136 | const amount = parseFloat(amountStr) | |
| 136 | const amount = parseFloat(amountStr) | |
| 137 | 137 | |
| 138 | if (isNaN(amount) || amount <= 0) { | |
| 139 | return { valid: false, reason: 'Invalid amount' } | |
| 140 | } | |
| 141 | ||
| 142 | if (chain === 'bitcoin') { | |
| 143 | // Convert BTC to satoshi | |
| 144 | const satoshi = BigInt(Math.floor(amount * 1e8)) | |
| 145 | return { valid: true, amount: satoshi } | |
| 146 | } else if (chain === 'ethereum') { | |
| 147 | // Convert ETH to wei | |
| 148 | const wei = BigInt(Math.floor(amount * 1e18)) | |
| 149 | return { valid: true, amount: wei } | |
| 150 | } | |
| 138 | if (isNaN(amount) || amount < 0) { | |
| 139 | throw new Error('Invalid amount') | |
| 140 | } | |
| 151 | 141 | |
| 152 | return { valid: false, reason: `Unsupported chain: ${chain}` } | |
| 153 | } catch (err) { | |
| 154 | return { valid: false, reason: `Parse error: ${err.message}` } | |
| 142 | if (chain === 'bitcoin') { | |
| 143 | // Convert BTC to satoshi | |
| 144 | return BigInt(Math.floor(amount * 1e8)) | |
| 145 | } else if (chain === 'ethereum') { | |
| 146 | // Convert ETH to wei | |
| 147 | return BigInt(Math.floor(amount * 1e18)) | |
| 155 | 148 | } |
| 149 | ||
| 150 | throw new Error(`Unsupported chain: ${chain}`) | |
| 156 | 151 | } |
| 157 | 152 | |
| 158 | 153 | module.exports = { |
| … | … | @@ -40,9 +40,9 @@ function sanitizeString(str, maxLength = 1000) { |
| 40 | 40 | // Trim whitespace |
| 41 | 41 | let sanitized = str.trim() |
| 42 | 42 | |
| 43 | // Enforce max length | |
| 43 | // Truncate to max length (sanitize, don't throw) | |
| 44 | 44 | if (sanitized.length > maxLength) { |
| 45 | throw new Error(`Input too long (max ${maxLength} characters)`) | |
| 45 | sanitized = sanitized.slice(0, maxLength) | |
| 46 | 46 | } |
| 47 | 47 | |
| 48 | 48 | // Remove null bytes (can cause string termination attacks) |
| … | … | @@ -103,18 +103,20 @@ function sanitizeDeviceName(name) { |
| 103 | 103 | * @throws {Error} If notes are invalid |
| 104 | 104 | */ |
| 105 | 105 | function sanitizeNotes(notes) { |
| 106 | // Notes can be empty | |
| 107 | if (!notes || notes.trim().length === 0) { | |
| 106 | // Check for non-string types | |
| 107 | if (typeof notes !== 'string') { | |
| 108 | throw new Error('Notes must be a string') | |
| 109 | } | |
| 110 | ||
| 111 | // Notes can be empty strings | |
| 112 | if (notes.trim().length === 0) { | |
| 108 | 113 | return '' |
| 109 | 114 | } |
| 110 | 115 | |
| 111 | const sanitized = sanitizeString(notes, 500) | |
| 116 | let sanitized = sanitizeString(notes, 500) | |
| 112 | 117 | |
| 113 | // Allow more characters for notes (including newlines) | |
| 114 | // Block only dangerous characters (null bytes already removed by sanitizeString) | |
| 115 | if (sanitized.match(/[<>]/)) { | |
| 116 | throw new Error('Notes contain invalid characters (< and > not allowed)') | |
| 117 | } | |
| 118 | // Remove dangerous characters (< and >) instead of throwing | |
| 119 | sanitized = sanitized.replace(/[<>]/g, '') | |
| 118 | 120 | |
| 119 | 121 | return sanitized |
| 120 | 122 | } |
| … | … | @@ -122,19 +124,18 @@ function sanitizeNotes(notes) { |
| 122 | 124 | /** |
| 123 | 125 | * Validate chain identifier |
| 124 | 126 | * @param {string} chain - Chain name |
| 125 | * @returns {string} Validated chain | |
| 126 | * @throws {Error} If chain is invalid | |
| 127 | * @returns {string|boolean} Validated chain or false if invalid | |
| 127 | 128 | */ |
| 128 | 129 | function validateChain(chain) { |
| 129 | 130 | if (typeof chain !== 'string') { |
| 130 | throw new Error('Chain must be a string') | |
| 131 | return false | |
| 131 | 132 | } |
| 132 | 133 | |
| 133 | 134 | const validChains = ['bitcoin', 'ethereum'] |
| 134 | 135 | const lowerChain = chain.toLowerCase() |
| 135 | 136 | |
| 136 | 137 | if (!validChains.includes(lowerChain)) { |
| 137 | throw new Error(`Invalid chain: ${chain} (supported: ${validChains.join(', ')})`) | |
| 138 | return false | |
| 138 | 139 | } |
| 139 | 140 | |
| 140 | 141 | return lowerChain |
| … | … | @@ -143,19 +144,18 @@ function validateChain(chain) { |
| 143 | 144 | /** |
| 144 | 145 | * Validate network identifier |
| 145 | 146 | * @param {string} network - Network name |
| 146 | * @returns {string} Validated network | |
| 147 | * @throws {Error} If network is invalid | |
| 147 | * @returns {string|boolean} Validated network or false if invalid | |
| 148 | 148 | */ |
| 149 | 149 | function validateNetwork(network) { |
| 150 | 150 | if (typeof network !== 'string') { |
| 151 | throw new Error('Network must be a string') | |
| 151 | return false | |
| 152 | 152 | } |
| 153 | 153 | |
| 154 | 154 | const validNetworks = ['testnet', 'mainnet', 'sepolia'] |
| 155 | 155 | const lowerNetwork = network.toLowerCase() |
| 156 | 156 | |
| 157 | 157 | if (!validNetworks.includes(lowerNetwork)) { |
| 158 | throw new Error(`Invalid network: ${network} (supported: ${validNetworks.join(', ')})`) | |
| 158 | return false | |
| 159 | 159 | } |
| 160 | 160 | |
| 161 | 161 | return lowerNetwork |
| … | … | @@ -179,10 +179,8 @@ function sanitizeHex(hex, expectedLength = 0) { |
| 179 | 179 | sanitized = sanitized.slice(2) |
| 180 | 180 | } |
| 181 | 181 | |
| 182 | // Validate hex characters | |
| 183 | if (!sanitized.match(/^[0-9a-f]*$/)) { | |
| 184 | throw new Error('Invalid hex string (must contain only 0-9, a-f)') | |
| 185 | } | |
| 182 | // Remove non-hex characters instead of throwing | |
| 183 | sanitized = sanitized.replace(/[^0-9a-f]/g, '') | |
| 186 | 184 | |
| 187 | 185 | // Check length if specified |
| 188 | 186 | if (expectedLength > 0 && sanitized.length !== expectedLength) { |
| … | … | @@ -195,24 +193,23 @@ function sanitizeHex(hex, expectedLength = 0) { |
| 195 | 193 | /** |
| 196 | 194 | * Validate invite code format |
| 197 | 195 | * @param {string} inviteCode - Base64 invite code |
| 198 | * @returns {string} Validated invite code | |
| 199 | * @throws {Error} If invite code is invalid | |
| 196 | * @returns {string|boolean} Validated invite code or false if invalid | |
| 200 | 197 | */ |
| 201 | 198 | function validateInviteCode(inviteCode) { |
| 202 | 199 | if (typeof inviteCode !== 'string') { |
| 203 | throw new Error('Invite code must be a string') | |
| 200 | return false | |
| 204 | 201 | } |
| 205 | 202 | |
| 206 | 203 | const sanitized = inviteCode.trim() |
| 207 | 204 | |
| 208 | 205 | // Check for valid base64 characters |
| 209 | 206 | if (!sanitized.match(/^[A-Za-z0-9+/]+=*$/)) { |
| 210 | throw new Error('Invalid invite code format (must be base64)') | |
| 207 | return false | |
| 211 | 208 | } |
| 212 | 209 | |
| 213 | 210 | // Check reasonable length (should be ~200-300 chars for signed invite) |
| 214 | 211 | if (sanitized.length < 50 || sanitized.length > 1000) { |
| 215 | throw new Error('Invalid invite code length') | |
| 212 | return false | |
| 216 | 213 | } |
| 217 | 214 | |
| 218 | 215 | return sanitized |
| … | … | @@ -49,7 +49,7 @@ class RateLimiter { |
| 49 | 49 | * Check if request is within rate limits |
| 50 | 50 | * @param {string} ip - Client IP address |
| 51 | 51 | * @param {string} endpoint - API endpoint path |
| 52 | * @returns {Object} { allowed: boolean, remaining: number, resetIn: number } | |
| 52 | * @returns {Object} { allowed: boolean, remaining: number, resetIn: number, headers: Object } | |
| 53 | 53 | */ |
| 54 | 54 | checkLimit(ip, endpoint) { |
| 55 | 55 | // Get rate limit for this endpoint (or default) |
| … | … | @@ -77,13 +77,19 @@ class RateLimiter { |
| 77 | 77 | if (recentRequests.length >= limit.max) { |
| 78 | 78 | const oldestRequest = Math.min(...recentRequests) |
| 79 | 79 | const resetIn = limit.window - (now - oldestRequest) |
| 80 | const resetTime = Math.floor((now + resetIn) / 1000) | |
| 80 | 81 | |
| 81 | 82 | return { |
| 82 | 83 | allowed: false, |
| 83 | 84 | remaining: 0, |
| 84 | 85 | resetIn: Math.ceil(resetIn / 1000), // seconds |
| 85 | 86 | limit: limit.max, |
| 86 | window: Math.floor(limit.window / 1000) // seconds | |
| 87 | window: Math.floor(limit.window / 1000), // seconds | |
| 88 | headers: { | |
| 89 | 'X-RateLimit-Limit': String(limit.max), | |
| 90 | 'X-RateLimit-Remaining': '0', | |
| 91 | 'X-RateLimit-Reset': String(resetTime) | |
| 92 | } | |
| 87 | 93 | } |
| 88 | 94 | } |
| 89 | 95 | |
| … | … | @@ -91,12 +97,19 @@ class RateLimiter { |
| 91 | 97 | recentRequests.push(now) |
| 92 | 98 | ipRequests.set(endpoint, recentRequests) |
| 93 | 99 | |
| 100 | const resetTime = Math.floor((now + limit.window) / 1000) | |
| 101 | ||
| 94 | 102 | return { |
| 95 | 103 | allowed: true, |
| 96 | 104 | remaining: limit.max - recentRequests.length, |
| 97 | 105 | resetIn: Math.floor(limit.window / 1000), // seconds |
| 98 | 106 | limit: limit.max, |
| 99 | window: Math.floor(limit.window / 1000) // seconds | |
| 107 | window: Math.floor(limit.window / 1000), // seconds | |
| 108 | headers: { | |
| 109 | 'X-RateLimit-Limit': String(limit.max), | |
| 110 | 'X-RateLimit-Remaining': String(limit.max - recentRequests.length), | |
| 111 | 'X-RateLimit-Reset': String(resetTime) | |
| 112 | } | |
| 100 | 113 | } |
| 101 | 114 | } |
| 102 | 115 |