From 7ee87d3e5dca5dc15e281e2d29cdc2e2da889885 Mon Sep 17 00:00:00 2001 From: Anmol1696 Date: Thu, 22 Jan 2026 16:46:43 +0400 Subject: [PATCH 1/8] add base64 logo --- functions/send-email-link/src/index.ts | 38 +++++++++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/functions/send-email-link/src/index.ts b/functions/send-email-link/src/index.ts index 77ecd33f9..c63d21e46 100644 --- a/functions/send-email-link/src/index.ts +++ b/functions/send-email-link/src/index.ts @@ -77,6 +77,42 @@ const getRequiredEnv = (name: string): string => { return value; }; +// Maximum logo size to embed as base64 (50 KB) +const MAX_LOGO_SIZE_BYTES = 50 * 1024; + +/** + * 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. + */ +const fetchLogoAsBase64 = async (url: string | undefined): Promise => { + if (!url) return undefined; + + try { + const response = await fetch(url); + 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 (error) { + logger.warn('Failed to fetch logo for base64 encoding, using original URL', { url }); + return url; + } +}; + type GraphQLClientOptions = { hostHeaderEnvVar?: string; databaseId?: string; @@ -181,7 +217,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; From 36850ff926ee2fb474b6f09df835ac610c345955 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 22 Jan 2026 13:08:15 +0000 Subject: [PATCH 2/8] Initial plan From 5d8ed78ccdbb65a354a9b66b7aa917b2dda853a7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 22 Jan 2026 13:08:38 +0000 Subject: [PATCH 3/8] Initial plan From d4319d56244ea1e31c27ae95bc115d96da9246af Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 22 Jan 2026 13:16:46 +0000 Subject: [PATCH 4/8] Add SSRF protection to logo fetching function - Add isUrlSafeToFetch validation function that checks: - Only allows HTTP/HTTPS protocols - Blocks localhost and local hostnames - Blocks private IPv4 ranges (10.x, 172.16-31.x, 192.168.x, 169.254.x, 127.x) - Blocks private IPv6 addresses (::1, fe80:, fc00:, fd00:) - Integrate URL validation into fetchLogoAsBase64 before fetching - Return undefined for unsafe URLs instead of attempting fetch - Add warning logs for blocked URLs - Fix unused error variables in catch blocks Co-authored-by: Anmol1696 <10805402+Anmol1696@users.noreply.github.com> --- functions/send-email-link/src/index.ts | 203 ++++++++++++++++--------- 1 file changed, 133 insertions(+), 70 deletions(-) diff --git a/functions/send-email-link/src/index.ts b/functions/send-email-link/src/index.ts index c63d21e46..a0f65bf8a 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; @@ -80,14 +80,77 @@ const getRequiredEnv = (name: string): string => { // 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 + /^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; + } + try { const response = await fetch(url); if (!response.ok) return url; @@ -107,7 +170,7 @@ const fetchLogoAsBase64 = async (url: string | undefined): Promise { 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' }; } }; @@ -266,58 +329,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; @@ -395,7 +458,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 @@ -404,7 +467,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, { From a36456d5c7e54d3bdf4061c07e80c45f9b1f554f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 22 Jan 2026 13:17:53 +0000 Subject: [PATCH 5/8] Add 10-second timeout to fetch operation in fetchLogoAsBase64 Co-authored-by: Anmol1696 <10805402+Anmol1696@users.noreply.github.com> --- functions/send-email-link/src/index.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/functions/send-email-link/src/index.ts b/functions/send-email-link/src/index.ts index c63d21e46..13c02ea6b 100644 --- a/functions/send-email-link/src/index.ts +++ b/functions/send-email-link/src/index.ts @@ -89,7 +89,13 @@ const fetchLogoAsBase64 = async (url: string | undefined): Promise controller.abort(), 10000); // 10 second timeout + + const response = await fetch(url, { signal: controller.signal }); + clearTimeout(timeoutId); + if (!response.ok) return url; const buffer = await response.arrayBuffer(); From 09253716d72f63cbb0bfbfe2491c3205840fd4ce Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 22 Jan 2026 13:18:27 +0000 Subject: [PATCH 6/8] Clarify regex comment for 172.16.0.0/12 range Co-authored-by: Anmol1696 <10805402+Anmol1696@users.noreply.github.com> --- functions/send-email-link/src/index.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/functions/send-email-link/src/index.ts b/functions/send-email-link/src/index.ts index a0f65bf8a..306aa73d1 100644 --- a/functions/send-email-link/src/index.ts +++ b/functions/send-email-link/src/index.ts @@ -108,11 +108,11 @@ const isUrlSafeToFetch = (urlString: string): boolean => { // 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 - /^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) + /^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) { From 305bb04f30173e960f64e3242eb9ff894d9f6247 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 22 Jan 2026 13:18:54 +0000 Subject: [PATCH 7/8] Move clearTimeout to finally block to prevent memory leaks Co-authored-by: Anmol1696 <10805402+Anmol1696@users.noreply.github.com> --- functions/send-email-link/src/index.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/functions/send-email-link/src/index.ts b/functions/send-email-link/src/index.ts index 13c02ea6b..82c61276a 100644 --- a/functions/send-email-link/src/index.ts +++ b/functions/send-email-link/src/index.ts @@ -88,13 +88,12 @@ const MAX_LOGO_SIZE_BYTES = 50 * 1024; const fetchLogoAsBase64 = async (url: string | undefined): Promise => { if (!url) return undefined; - try { - // Add timeout to prevent hanging on unresponsive servers - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 10000); // 10 second timeout + // 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 }); - clearTimeout(timeoutId); if (!response.ok) return url; @@ -116,6 +115,8 @@ const fetchLogoAsBase64 = async (url: string | undefined): Promise Date: Thu, 22 Jan 2026 13:19:39 +0000 Subject: [PATCH 8/8] Remove trailing whitespace Co-authored-by: Anmol1696 <10805402+Anmol1696@users.noreply.github.com> --- functions/send-email-link/src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/functions/send-email-link/src/index.ts b/functions/send-email-link/src/index.ts index 82c61276a..ad3e363ef 100644 --- a/functions/send-email-link/src/index.ts +++ b/functions/send-email-link/src/index.ts @@ -94,7 +94,7 @@ const fetchLogoAsBase64 = async (url: string | undefined): Promise