diff --git a/src/components/SiweValidator/__tests__/validationEngine.test.ts b/src/components/SiweValidator/__tests__/validationEngine.test.ts index 7b4a698..abccde9 100644 --- a/src/components/SiweValidator/__tests__/validationEngine.test.ts +++ b/src/components/SiweValidator/__tests__/validationEngine.test.ts @@ -23,6 +23,40 @@ Version: 2 Chain ID: 0 Nonce: test`; + const messageWithScheme = `https://app.example.com wants you to sign in with your Ethereum account: +0x742d35Cc6C4C1Ca5d428d9eE0e9B1E1234567890 + +Sign in to our Web3 application. + +URI: https://app.example.com/auth +Version: 1 +Chain ID: 137 +Nonce: tw9ZbAehXkE3uuDQ +Issued At: 2025-08-31T19:06:58.219Z +Expiration Time: 2025-08-31T21:16:58.219Z`; + + const messageWithPort = `example.com:3000 wants you to sign in with your Ethereum account: +0x742d35Cc6C4C1Ca5d428d9eE0e9B1E1234567890 + +Sign in to our Web3 application. + +URI: https://example.com:3000/auth +Version: 1 +Chain ID: 1 +Nonce: a1B2c3D4e5F6g7H8 +Issued At: 2023-10-31T16:25:24Z`; + + const messageWithSchemeAndPort = `https://localhost:8080 wants you to sign in with your Ethereum account: +0x742d35Cc6C4C1Ca5d428d9eE0e9B1E1234567890 + +Testing local development. + +URI: https://localhost:8080/api/auth +Version: 1 +Chain ID: 31337 +Nonce: randomNonce123 +Issued At: 2023-10-31T16:25:24Z`; + describe('ValidationEngine.validate', () => { test('validates a correct SIWE message', () => { const result = ValidationEngine.validate(validMessage); @@ -59,15 +93,57 @@ Nonce: test`; const strictResult = ValidationEngine.validate(invalidMessage, { profile: ValidationEngine.PROFILES.strict }); - + const basicResult = ValidationEngine.validate(invalidMessage, { profile: ValidationEngine.PROFILES.basic }); - + // Strict mode should find more issues expect(strictResult.errors.length + strictResult.warnings.length) .toBeGreaterThanOrEqual(basicResult.errors.length + basicResult.warnings.length); }); + + test('validates message with scheme prefix', () => { + const result = ValidationEngine.validate(messageWithScheme); + + expect(result.isValid).toBe(true); + expect(result.errors).toHaveLength(0); + + // Parse the message to verify fields + const parsed = SiweMessageParser.parse(messageWithScheme); + expect(parsed.fields.scheme).toBe('https'); + expect(parsed.fields.domain).toBe('app.example.com'); + }); + + test('validates message with port', () => { + const result = ValidationEngine.validate(messageWithPort); + + expect(result.isValid).toBe(true); + expect(result.errors).toHaveLength(0); + + // Parse the message to verify fields + const parsed = SiweMessageParser.parse(messageWithPort); + expect(parsed.fields.domain).toBe('example.com:3000'); + }); + + test('validates message with both scheme and port', () => { + const result = ValidationEngine.validate(messageWithSchemeAndPort); + + expect(result.isValid).toBe(true); + expect(result.errors).toHaveLength(0); + + // Parse the message to verify fields + const parsed = SiweMessageParser.parse(messageWithSchemeAndPort); + expect(parsed.fields.scheme).toBe('https'); + expect(parsed.fields.domain).toBe('localhost:8080'); + }); + + test('regenerates message with scheme correctly', () => { + const parsed = SiweMessageParser.parse(messageWithScheme); + const regenerated = SiweMessageParser.generateMessage(parsed.fields); + + expect(regenerated).toContain('https://app.example.com wants you to sign in'); + }); }); describe('ValidationEngine.quickValidate', () => { diff --git a/src/components/SiweValidator/parser.ts b/src/components/SiweValidator/parser.ts index ff48edb..780a283 100644 --- a/src/components/SiweValidator/parser.ts +++ b/src/components/SiweValidator/parser.ts @@ -6,7 +6,8 @@ export class SiweMessageParser { private static readonly REQUIRED_FIELDS = ['domain', 'address', 'uri', 'version', 'chainId', 'nonce', 'issuedAt']; private static readonly FIELD_PATTERNS = { - domain: /^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)*[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/, + // Domain pattern now allows optional port (e.g., example.com:3000) + domain: /^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)*[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(:[0-9]+)?$/, address: /^0x[a-fA-F0-9]{40}$/, uri: /^[a-zA-Z][a-zA-Z0-9+.-]*:/, version: /^1$/, @@ -22,18 +23,30 @@ export class SiweMessageParser { let lineIndex = 0; try { - // Parse header line: domain + " wants you to sign in with your Ethereum account:" + // Parse header line: [scheme "://"] domain + " wants you to sign in with your Ethereum account:" + // According to EIP-4361, the scheme is optional if (lineIndex < lines.length) { - const headerMatch = lines[lineIndex].match(/^(.+) wants you to sign in with your Ethereum account:$/); + // Match with optional scheme (e.g., "https://example.com" or just "example.com") + const headerMatch = lines[lineIndex].match(/^(?:([a-zA-Z][a-zA-Z0-9+.-]*):(?:\/\/))?(.+) wants you to sign in with your Ethereum account:$/); if (headerMatch) { - fields.domain = headerMatch[1]; + // headerMatch[1] is the optional scheme, headerMatch[2] is the domain (with optional port) + const scheme = headerMatch[1]; + const domainWithPort = headerMatch[2]; + + // Store the domain (including port if present) + fields.domain = domainWithPort; + + // Store the scheme separately if present (for validation or reconstruction) + if (scheme) { + fields.scheme = scheme; + } } else { parseErrors.push(this.createParseError( 'format', 'header', lineIndex + 1, 1, - 'Invalid header format. Expected: "domain wants you to sign in with your Ethereum account:"', + 'Invalid header format. Expected: "[scheme://]domain wants you to sign in with your Ethereum account:"', 'INVALID_HEADER' )); } @@ -173,9 +186,10 @@ export class SiweMessageParser { public static generateMessage(fields: SiweMessageFields): string { const lines: string[] = []; - // Header + // Header (with optional scheme) if (fields.domain) { - lines.push(`${fields.domain} wants you to sign in with your Ethereum account:`); + const prefix = fields.scheme ? `${fields.scheme}://` : ''; + lines.push(`${prefix}${fields.domain} wants you to sign in with your Ethereum account:`); } // Address diff --git a/src/components/SiweValidator/securityValidators.ts b/src/components/SiweValidator/securityValidators.ts index 7dbf384..3d5baf1 100644 --- a/src/components/SiweValidator/securityValidators.ts +++ b/src/components/SiweValidator/securityValidators.ts @@ -129,9 +129,27 @@ export class SecurityValidators { if (uri) { try { const url = new URL(uri); - const uriDomain = url.hostname; - - if (domain !== uriDomain && !uriDomain.endsWith(`.${domain}`)) { + const uriHostname = url.hostname; + // Only use url.port if it's explicitly specified in the URI + const uriPort = url.port || null; + + // Extract hostname and port from message domain (e.g., "example.com:3000" -> "example.com", "3000") + let messageHostname = domain; + let messagePort: string | null = null; + + const portMatch = domain.match(/^(.+):(\d+)$/); + if (portMatch) { + messageHostname = portMatch[1]; + messagePort = portMatch[2]; + } + + // Check hostname mismatch + const hostnameMismatch = messageHostname !== uriHostname && !uriHostname.endsWith(`.${messageHostname}`); + + // Only check port mismatch if BOTH have explicit ports specified + const portMismatch = messagePort && uriPort && messagePort !== uriPort; + + if (hostnameMismatch) { errors.push({ type: 'security', field: 'uri', @@ -143,6 +161,18 @@ export class SecurityValidators { suggestion: 'Ensure URI domain matches or is subdomain of message domain', code: 'SECURITY_DOMAIN_MISMATCH' }); + } else if (portMismatch) { + errors.push({ + type: 'security', + field: 'uri', + line: SiweMessageParser.getFieldLine(message.rawMessage, 'uri'), + column: 1, + message: `URI port (${uriPort}) does not match message domain port (${messagePort})`, + severity: 'warning', + fixable: false, + suggestion: 'Ensure URI port matches the port specified in the domain', + code: 'SECURITY_PORT_MISMATCH' + }); } } catch { // URI validation will catch invalid URIs @@ -511,6 +541,13 @@ export class SecurityValidators { } private static isSuspiciousDomain(domain: string): boolean { + // Extract hostname from domain (remove port if present) + let hostname = domain; + const portMatch = domain.match(/^(.+):(\d+)$/); + if (portMatch) { + hostname = portMatch[1]; + } + const suspiciousPatterns = [ /metamask.*\.(?!io$)/i, // Fake MetaMask domains /wallet.*connect/i, // Fake WalletConnect @@ -522,10 +559,17 @@ export class SecurityValidators { /[0-9]{8,}\./, // Domains with long numbers ]; - return suspiciousPatterns.some(pattern => pattern.test(domain)); + return suspiciousPatterns.some(pattern => pattern.test(hostname)); } private static isDevelopmentDomain(domain: string): boolean { + // Extract hostname from domain (remove port if present) + let hostname = domain; + const portMatch = domain.match(/^(.+):(\d+)$/); + if (portMatch) { + hostname = portMatch[1]; + } + const devPatterns = [ 'localhost', '127.0.0.1', @@ -539,6 +583,6 @@ export class SecurityValidators { 'test.' ]; - return devPatterns.some(pattern => domain.includes(pattern)); + return devPatterns.some(pattern => hostname.includes(pattern)); } } \ No newline at end of file diff --git a/src/components/SiweValidator/types.ts b/src/components/SiweValidator/types.ts index 7e37ee3..1c5023c 100644 --- a/src/components/SiweValidator/types.ts +++ b/src/components/SiweValidator/types.ts @@ -30,7 +30,8 @@ export interface ValidationSuggestion extends ValidationError { } export interface SiweMessageFields { - domain?: string; + scheme?: string; // Optional scheme from the header (e.g., "https") + domain?: string; // Domain with optional port (e.g., "example.com:3000") address?: string; statement?: string; uri?: string; diff --git a/src/components/SiweValidator/validators.ts b/src/components/SiweValidator/validators.ts index 69d879a..2d745a8 100644 --- a/src/components/SiweValidator/validators.ts +++ b/src/components/SiweValidator/validators.ts @@ -528,24 +528,53 @@ export class FieldValidators { // Helper methods for specific validations private static validateDomainFormat(domain: string): DomainValidation { - // Basic domain format validation + // Extract domain and port if present (e.g., "example.com:3000") + let domainPart = domain; + let port: string | null = null; + + const portMatch = domain.match(/^(.+):(\d+)$/); + if (portMatch) { + domainPart = portMatch[1]; + port = portMatch[2]; + + // Validate port range (1-65535) + const portNum = parseInt(port, 10); + if (portNum < 1 || portNum > 65535) { + return { + isValid: false, + isSubdomain: false, + tld: null, + securityRisk: 'none' + }; + } + } + + // Basic domain format validation (now without port) const domainRegex = /^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)*[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/; - const isValid = domainRegex.test(domain) && domain.length <= 253; - - const parts = domain.split('.'); - const tld = parts[parts.length - 1]; - const isSubdomain = parts.length > 2; - + + // Also allow IP addresses (IPv4 for now) + const ipv4Regex = /^(\d{1,3}\.){3}\d{1,3}$/; + const isIPv4 = ipv4Regex.test(domainPart); + + // Check if it's localhost + const isLocalhost = domainPart === 'localhost'; + + const isValid = (domainRegex.test(domainPart) || isIPv4 || isLocalhost) && domainPart.length <= 253; + + const parts = domainPart.split('.'); + const tld = !isIPv4 && !isLocalhost && parts.length > 1 ? parts[parts.length - 1] : null; + const isSubdomain = !isIPv4 && !isLocalhost && parts.length > 2; + // Basic security risk assessment let securityRisk: 'none' | 'low' | 'medium' | 'high' = 'none'; - if (domain.includes('localhost') || domain.includes('127.0.0.1')) { + if (isLocalhost || domainPart.includes('127.0.0.1') || domainPart === '0.0.0.0') { securityRisk = 'low'; } - + return { isValid, isSubdomain, - tld: isValid ? tld : null, + tld, securityRisk }; }