UNPKG

@mikkelscheike/email-provider-links

Version:

TypeScript library for email provider detection with 140 providers (259 domains), concurrent DNS resolution, alias normalization, and HTTPS login URL validation for login and password reset flows

262 lines 9.53 kB
"use strict"; /** * URL Security Validation Module * * Provides validation and allowlisting for email provider URLs to prevent * malicious redirects and supply chain attacks. */ Object.defineProperty(exports, "__esModule", { value: true }); exports.getAllowedDomains = getAllowedDomains; exports.validateEmailProviderUrl = validateEmailProviderUrl; exports.validateAllProviderUrls = validateAllProviderUrls; exports.validateAllProviderUrlsWithAllowlist = validateAllProviderUrlsWithAllowlist; exports.auditProviderSecurity = auditProviderSecurity; exports.auditProviderSecurityWithAllowlist = auditProviderSecurityWithAllowlist; const fs_1 = require("fs"); const hash_verifier_1 = require("./hash-verifier"); const idn_1 = require("./idn"); const provider_store_1 = require("./provider-store"); /** * Get allowlisted domains from provider data * Only URLs from these domains will be considered safe. */ function getAllowedDomains() { const filePath = (0, provider_store_1.resolveDefaultProvidersPath)(); const shouldVerifyHash = process.env.EMAIL_PROVIDER_LINKS_VERIFY_HASH === '1'; const integrity = shouldVerifyHash ? (0, hash_verifier_1.verifyProvidersIntegrity)(filePath) : { isValid: true, actualHash: 'runtime-skip', file: filePath }; // Fail closed only when hash verification is explicitly enabled if (!integrity.isValid && process.env.NODE_ENV === 'production' && !process.env.JEST_WORKER_ID) { return new Set(); } if (cachedAllowedDomains && cachedAllowlistHash === integrity.actualHash) { return cachedAllowedDomains; } const fileContent = (0, fs_1.readFileSync)(filePath, 'utf8'); const data = JSON.parse(fileContent); const providers = data.providers; const allowedDomains = new Set(); for (const provider of providers) { if (provider.loginUrl) { try { const url = new URL(provider.loginUrl); allowedDomains.add((0, idn_1.domainToPunycode)(url.hostname.toLowerCase())); } catch { continue; } } } cachedAllowedDomains = allowedDomains; cachedAllowlistHash = integrity.actualHash; return allowedDomains; } let cachedAllowedDomains = null; let cachedAllowlistHash = null; /** * Suspicious URL patterns that should always be rejected */ const SUSPICIOUS_PATTERNS = [ /[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}/, // IP addresses /localhost/i, /127\.0\.0\.1/, /192\.168\./, /10\./, /172\./, /\.tk$|\.ml$|\.ga$|\.cf$/i, // Suspicious TLDs /[a-z0-9]+-[a-z0-9]+-[a-z0-9]+\./i, // Random subdomain patterns ]; /** * URL shortener domains (should be rejected for security) */ const URL_SHORTENERS = [ 'bit.ly', 'tinyurl.com', 't.co', 'short.link', 'ow.ly', 'is.gd', 'buff.ly' ]; /** * Validates if a URL is safe for email provider redirects * * @param url - The URL to validate * @param allowedDomainsOverride - Optional allowlist to avoid recomputing from disk * @returns Validation result with details */ function validateEmailProviderUrl(url, allowedDomainsOverride) { try { // Check for malicious patterns in raw URL before parsing const rawUrl = url.toLowerCase(); // Decode URL to catch encoded malicious patterns let decodedUrl = ''; try { decodedUrl = decodeURIComponent(rawUrl); } catch { // If URL can't be decoded, treat as suspicious return { isValid: false, reason: 'URL contains potentially malicious content', domain: 'unknown' }; } // Check both raw and decoded URLs for malicious patterns const urlsToCheck = [rawUrl, decodedUrl]; for (const urlToCheck of urlsToCheck) { if (urlToCheck.includes('..') || urlToCheck.includes('%2e%2e') || urlToCheck.includes('javascript:') || urlToCheck.includes('data:') || urlToCheck.includes('vbscript:') || urlToCheck.includes('file:') || urlToCheck.includes('about:') || urlToCheck.includes('<script') || urlToCheck.includes('onload=') || urlToCheck.includes('onerror=')) { return { isValid: false, reason: 'URL contains potentially malicious content', domain: 'unknown' }; } } // Parse and normalize the URL const urlObj = new URL(url); const domain = (0, idn_1.domainToPunycode)(urlObj.hostname.toLowerCase()); const normalizedUrl = urlObj.toString(); // Must use HTTPS if (urlObj.protocol !== 'https:') { return { isValid: false, reason: 'URL must use HTTPS protocol', domain }; } // Check for suspicious patterns for (const pattern of SUSPICIOUS_PATTERNS) { if (pattern.test(domain)) { return { isValid: false, reason: 'URL contains suspicious patterns', domain }; } } // Check for URL shorteners if (URL_SHORTENERS.includes(domain)) { return { isValid: false, reason: 'URL shorteners are not allowed', domain }; } // Check if the domain is allowed const allowedDomains = allowedDomainsOverride ?? getAllowedDomains(); if (!allowedDomains.has(domain)) { return { isValid: false, reason: `Domain '${domain}' is not in the allowlist`, domain }; } // Additional security checks for malicious content const fullUrl = urlObj.toString().toLowerCase(); const pathname = urlObj.pathname.toLowerCase(); const search = urlObj.search.toLowerCase(); // Check for path traversal if (pathname.includes('..') || pathname.includes('%2e%2e')) { return { isValid: false, reason: 'URL contains potentially malicious content', domain }; } // Check for JavaScript injection if (fullUrl.includes('javascript:') || search.includes('javascript') || fullUrl.includes('data:')) { return { isValid: false, reason: 'URL contains potentially malicious content', domain }; } return { isValid: true, domain, normalizedUrl }; } catch (error) { return { isValid: false, reason: `Invalid URL format: ${error instanceof Error ? error.message : 'Unknown error'}` }; } } /** * Validates all URLs in an email providers array * * @param providers - Array of email providers to validate * @returns Array of validation results */ function validateAllProviderUrls(providers) { return validateAllProviderUrlsWithAllowlist(providers); } /** * Validates all URLs in an email providers array, with optional precomputed allowlist. * This avoids recomputing the allowlist from disk for performance-sensitive codepaths. */ function validateAllProviderUrlsWithAllowlist(providers, allowedDomainsOverride) { const results = []; for (const provider of providers) { if (provider.loginUrl) { results.push({ provider: provider.companyProvider || 'Unknown', url: provider.loginUrl, validation: validateEmailProviderUrl(provider.loginUrl, allowedDomainsOverride) }); } else { // Providers without URLs are counted but marked as invalid for audit purposes // (they don't affect security level, but are tracked for completeness) results.push({ provider: provider.companyProvider || 'Unknown', url: provider.loginUrl || '', validation: { isValid: false, reason: provider.loginUrl === '' ? 'Empty URL provided' : 'No URL provided' } }); } } return results; } /** * Security audit function to check all provider URLs * * @param providers - Array of email providers to audit * @returns Security audit report */ function auditProviderSecurity(providers) { return auditProviderSecurityWithAllowlist(providers); } /** * Security audit function to check all provider URLs, with optional precomputed allowlist. */ function auditProviderSecurityWithAllowlist(providers, allowedDomainsOverride) { const validations = validateAllProviderUrlsWithAllowlist(providers, allowedDomainsOverride); const invalid = validations.filter(v => !v.validation.isValid); const valid = validations.filter(v => v.validation.isValid); return { total: validations.length, valid: valid.length, invalid: invalid.length, invalidProviders: invalid, report: invalid.length === 0 ? 'All provider URLs passed security validation' : `${invalid.length} provider(s) failed security validation` }; } //# sourceMappingURL=url-validator.js.map