@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
JavaScript
"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