Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 78 additions & 2 deletions src/components/SiweValidator/__tests__/validationEngine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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', () => {
Expand Down
28 changes: 21 additions & 7 deletions src/components/SiweValidator/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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$/,
Expand All @@ -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'
));
}
Expand Down Expand Up @@ -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
Expand Down
54 changes: 49 additions & 5 deletions src/components/SiweValidator/securityValidators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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',
Expand All @@ -539,6 +583,6 @@ export class SecurityValidators {
'test.'
];

return devPatterns.some(pattern => domain.includes(pattern));
return devPatterns.some(pattern => hostname.includes(pattern));
}
}
3 changes: 2 additions & 1 deletion src/components/SiweValidator/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
49 changes: 39 additions & 10 deletions src/components/SiweValidator/validators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
};
}
Expand Down