diff --git a/functions/send-email-link/src/index.ts b/functions/send-email-link/src/index.ts index 77ecd33f9..ba73777a7 100644 --- a/functions/send-email-link/src/index.ts +++ b/functions/send-email-link/src/index.ts @@ -1,11 +1,11 @@ import { createJobApp } from '@constructive-io/knative-job-fn'; -import { GraphQLClient } from 'graphql-request'; -import gql from 'graphql-tag'; import { generate } from '@launchql/mjml'; import { send as sendPostmaster } from '@launchql/postmaster'; -import { send as sendSmtp } from 'simple-smtp-server'; import { parseEnvBoolean } from '@pgpmjs/env'; import { createLogger } from '@pgpmjs/logger'; +import { GraphQLClient } from 'graphql-request'; +import gql from 'graphql-tag'; +import { send as sendSmtp } from 'simple-smtp-server'; const isDryRun = parseEnvBoolean(process.env.SEND_EMAIL_LINK_DRY_RUN) ?? false; const useSmtp = parseEnvBoolean(process.env.EMAIL_SEND_USE_SMTP) ?? false; @@ -77,6 +77,111 @@ const getRequiredEnv = (name: string): string => { return value; }; +// Maximum logo size to embed as base64 (50 KB) +const MAX_LOGO_SIZE_BYTES = 50 * 1024; + +/** + * Validates that a URL is safe to fetch (prevents SSRF attacks). + * @param urlString - The URL to validate + * @returns true if the URL is safe to fetch, false otherwise + */ +const isUrlSafeToFetch = (urlString: string): boolean => { + try { + const url = new URL(urlString); + + // Only allow HTTP and HTTPS protocols + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + logger.warn('Blocked URL with disallowed protocol', { url: urlString, protocol: url.protocol }); + return false; + } + + // Get hostname for IP validation + const hostname = url.hostname.toLowerCase(); + + // Block localhost and local hostnames + const localHostnames = ['localhost', '0.0.0.0', '127.0.0.1']; + if (localHostnames.includes(hostname) || hostname.endsWith('.localhost')) { + logger.warn('Blocked URL pointing to localhost', { url: urlString, hostname }); + return false; + } + + // Block private IP ranges using regex patterns + // IPv4 private ranges: 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 169.254.0.0/16 (link-local) + const ipv4PrivateRanges = [ + /^10\./, // 10.0.0.0/8 + /^172\.(1[6-9]|2\d|3[01])\./, // 172.16.0.0/12 (matches 172.16-31.x.x) + /^192\.168\./, // 192.168.0.0/16 + /^169\.254\./, // 169.254.0.0/16 (link-local/cloud metadata) + /^127\./ // 127.0.0.0/8 (loopback) + ]; + + for (const pattern of ipv4PrivateRanges) { + if (pattern.test(hostname)) { + logger.warn('Blocked URL pointing to private IP range', { url: urlString, hostname }); + return false; + } + } + + // Block IPv6 localhost and link-local addresses + if (hostname === '::1' || hostname.startsWith('fe80:') || hostname.startsWith('fc00:') || hostname.startsWith('fd00:')) { + logger.warn('Blocked URL pointing to private IPv6 address', { url: urlString, hostname }); + return false; + } + + return true; + } catch { + // Invalid URL + logger.warn('Invalid URL format', { url: urlString }); + return false; + } +}; + +/** + * Fetches an image and returns it as a base64 data URI. + * This bypasses email client image proxies that may not handle SVG. + * Falls back to original URL if image is too large or fetch fails. + * Validates URLs to prevent SSRF attacks. + */ +const fetchLogoAsBase64 = async (url: string | undefined): Promise => { + if (!url) return undefined; + + // Validate URL to prevent SSRF attacks + if (!isUrlSafeToFetch(url)) { + logger.warn('Blocked unsafe URL for logo fetch', { url }); + return undefined; + } + // Add timeout to prevent hanging on unresponsive servers + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 10000); // 10 second timeout + + try { + const response = await fetch(url, { signal: controller.signal }); + + if (!response.ok) return url; + + const buffer = await response.arrayBuffer(); + + // Skip base64 for large images to avoid bloating emails + if (buffer.byteLength > MAX_LOGO_SIZE_BYTES) { + logger.debug('Logo too large for base64 embedding, using original URL', { + url, + size: buffer.byteLength, + maxSize: MAX_LOGO_SIZE_BYTES + }); + return url; + } + + const base64 = Buffer.from(buffer).toString('base64'); + const contentType = response.headers.get('content-type') || 'image/png'; + return `data:${contentType};base64,${base64}`; + } catch { + logger.warn('Failed to fetch logo for base64 encoding, using original URL', { url }); + return url; + } finally { + clearTimeout(timeoutId); + } +}; + type GraphQLClientOptions = { hostHeaderEnvVar?: string; databaseId?: string; @@ -129,23 +234,23 @@ export const sendEmailLink = async ( const validateForType = (): { missing?: string } | null => { switch (params.email_type) { - case 'invite_email': - if (!params.invite_token || !params.sender_id) { - return { missing: 'invite_token_or_sender_id' }; - } - return null; - case 'forgot_password': - if (!params.user_id || !params.reset_token) { - return { missing: 'user_id_or_reset_token' }; - } - return null; - case 'email_verification': - if (!params.email_id || !params.verification_token) { - return { missing: 'email_id_or_verification_token' }; - } - return null; - default: - return { missing: 'email_type' }; + case 'invite_email': + if (!params.invite_token || !params.sender_id) { + return { missing: 'invite_token_or_sender_id' }; + } + return null; + case 'forgot_password': + if (!params.user_id || !params.reset_token) { + return { missing: 'user_id_or_reset_token' }; + } + return null; + case 'email_verification': + if (!params.email_id || !params.verification_token) { + return { missing: 'email_id_or_verification_token' }; + } + return null; + default: + return { missing: 'email_type' }; } }; @@ -181,7 +286,7 @@ export const sendEmailLink = async ( const subdomain = domainNode.subdomain; const domain = domainNode.domain; const supportEmail = legalTermsModule.data.emails.support; - const logo = site.logo?.url; + const logo = await fetchLogoAsBase64(site.logo?.url); const company = legalTermsModule.data.company; const website = company.website; const nick = company.nick; @@ -230,58 +335,58 @@ export const sendEmailLink = async ( let inviterName: string | undefined; switch (params.email_type) { - case 'invite_email': { - if (!params.invite_token || !params.sender_id) { - return { missing: 'invite_token_or_sender_id' }; - } - url.pathname = 'register'; - url.searchParams.append('invite_token', params.invite_token); - url.searchParams.append('email', params.email); + case 'invite_email': { + if (!params.invite_token || !params.sender_id) { + return { missing: 'invite_token_or_sender_id' }; + } + url.pathname = 'register'; + url.searchParams.append('invite_token', params.invite_token); + url.searchParams.append('email', params.email); - const scope = Number(params.invite_type) === 2 ? 'org' : 'app'; - url.searchParams.append('type', scope); + const scope = Number(params.invite_type) === 2 ? 'org' : 'app'; + url.searchParams.append('type', scope); - const inviter = await client.request(GetUser, { - userId: params.sender_id - }); - inviterName = inviter?.user?.displayName; - - if (inviterName) { - subject = `${inviterName} invited you to ${nick}!`; - subMessage = `You've been invited to ${nick}`; - } else { - subject = `Welcome to ${nick}!`; - subMessage = `You've been invited to ${nick}`; - } - linkText = 'Join Us'; - break; + const inviter = await client.request(GetUser, { + userId: params.sender_id + }); + inviterName = inviter?.user?.displayName; + + if (inviterName) { + subject = `${inviterName} invited you to ${nick}!`; + subMessage = `You've been invited to ${nick}`; + } else { + subject = `Welcome to ${nick}!`; + subMessage = `You've been invited to ${nick}`; } - case 'forgot_password': { - if (!params.user_id || !params.reset_token) { - return { missing: 'user_id_or_reset_token' }; - } - url.pathname = 'reset-password'; - url.searchParams.append('role_id', params.user_id); - url.searchParams.append('reset_token', params.reset_token); - subject = `${nick} Password Reset Request`; - subMessage = 'Click below to reset your password'; - linkText = 'Reset Password'; - break; + linkText = 'Join Us'; + break; + } + case 'forgot_password': { + if (!params.user_id || !params.reset_token) { + return { missing: 'user_id_or_reset_token' }; } - case 'email_verification': { - if (!params.email_id || !params.verification_token) { - return { missing: 'email_id_or_verification_token' }; - } - url.pathname = 'verify-email'; - url.searchParams.append('email_id', params.email_id); - url.searchParams.append('verification_token', params.verification_token); - subject = `${nick} Email Verification`; - subMessage = 'Please confirm your email address'; - linkText = 'Confirm Email'; - break; + url.pathname = 'reset-password'; + url.searchParams.append('role_id', params.user_id); + url.searchParams.append('reset_token', params.reset_token); + subject = `${nick} Password Reset Request`; + subMessage = 'Click below to reset your password'; + linkText = 'Reset Password'; + break; + } + case 'email_verification': { + if (!params.email_id || !params.verification_token) { + return { missing: 'email_id_or_verification_token' }; } - default: - return false; + url.pathname = 'verify-email'; + url.searchParams.append('email_id', params.email_id); + url.searchParams.append('verification_token', params.verification_token); + subject = `${nick} Email Verification`; + subMessage = 'Please confirm your email address'; + linkText = 'Confirm Email'; + break; + } + default: + return false; } const link = url.href; @@ -359,7 +464,7 @@ app.post('/', async (req: any, res: any, next: any) => { hostHeaderEnvVar: 'GRAPHQL_HOST_HEADER', databaseId, ...(apiName && { apiName }), - ...(schemata && { schemata }), + ...(schemata && { schemata }) }); // For GetDatabaseInfo query - uses same API routing as client @@ -368,7 +473,7 @@ app.post('/', async (req: any, res: any, next: any) => { hostHeaderEnvVar: 'META_GRAPHQL_HOST_HEADER', databaseId, ...(apiName && { apiName }), - ...(schemata && { schemata }), + ...(schemata && { schemata }) }); const result = await sendEmailLink(params, {