diff --git a/.github/workflows/i18n.yml b/.github/workflows/i18n.yml index 28152562c77c7f..592cf462d919bd 100644 --- a/.github/workflows/i18n.yml +++ b/.github/workflows/i18n.yml @@ -10,6 +10,7 @@ concurrency: jobs: i18n: name: Run i18n + if: ${{ secrets.CI_LINGO_DOT_DEV_API_KEY != '' }} runs-on: blacksmith-2vcpu-ubuntu-2404 permissions: actions: write diff --git a/packages/app-store/salesforce/lib/CrmService.ts b/packages/app-store/salesforce/lib/CrmService.ts index eb80fdd7f7560b..be4db76c91a42e 100644 --- a/packages/app-store/salesforce/lib/CrmService.ts +++ b/packages/app-store/salesforce/lib/CrmService.ts @@ -133,6 +133,14 @@ class SalesforceCRMService implements CRM { private credentialId: number; private describeCache = new Map>(); + /** + * Escapes a string value for safe interpolation into SOQL queries. + * Prevents SOQL injection by escaping backslashes and single quotes. + */ + private sanitizeSoqlValue(value: string): string { + return value.replace(/\\/g, "\\\\").replace(/'/g, "\\'"); + } + constructor(credential: CredentialPayload, appOptions: z.infer, testMode = false) { this.integrationName = "salesforce_other_calendar"; this.credentialId = credential.id; @@ -289,7 +297,7 @@ class SalesforceCRMService implements CRM { private getSalesforceUserIdFromEmail = async (email: string) => { const conn = await this.conn; const query = await conn.query( - `SELECT Id, Email FROM User WHERE Email = '${email}' AND IsActive = true LIMIT 1` + `SELECT Id, Email FROM User WHERE Email = '${this.sanitizeSoqlValue(email)}' AND IsActive = true LIMIT 1` ); if (query.records.length > 0) { return (query.records[0] as { Email: string; Id: string }).Id; @@ -299,7 +307,7 @@ class SalesforceCRMService implements CRM { private getSalesforceUserFromUserId = async (userId: string) => { const conn = await this.conn; - return await conn.query(`SELECT Id, Email, Name FROM User WHERE Id = '${userId}' AND IsActive = true`); + return await conn.query(`SELECT Id, Email, Name FROM User WHERE Id = '${this.sanitizeSoqlValue(userId)}' AND IsActive = true`); }; private getSalesforceEventBody = (event: CalendarEvent): string => { @@ -661,10 +669,10 @@ class SalesforceCRMService implements CRM { // For an account let's assume that the first email is the one we should be querying against const attendeeEmail = emailArray[0]; log.info("[recordToSearch=ACCOUNT] Searching contact for email", safeStringify({ attendeeEmail })); - soql = `SELECT Id, Email, OwnerId, AccountId, Account.OwnerId, Account.Owner.Email, Account.Website${extraFields} FROM ${SalesforceRecordEnum.CONTACT} WHERE Email = '${attendeeEmail}' AND AccountId != null`; + soql = `SELECT Id, Email, OwnerId, AccountId, Account.OwnerId, Account.Owner.Email, Account.Website${extraFields} FROM ${SalesforceRecordEnum.CONTACT} WHERE Email = '${this.sanitizeSoqlValue(attendeeEmail)}' AND AccountId != null`; } else { // Handle Contact/Lead record types - soql = `SELECT Id, Email, OwnerId, Owner.Email${extraFields} FROM ${recordToSearch} WHERE Email IN ('${emailArray.join( + soql = `SELECT Id, Email, OwnerId, Owner.Email${extraFields} FROM ${recordToSearch} WHERE Email IN ('${emailArray.map((e) => this.sanitizeSoqlValue(e)).join( "','" )}')`; } @@ -898,7 +906,7 @@ class SalesforceCRMService implements CRM { if (!accountId && appOptions.createLeadIfAccountNull && !contactCreated) { // Check to see if the lead exists already const leadQuery = await conn.query( - `SELECT Id, Email FROM Lead WHERE Email = '${attendee.email}' LIMIT 1` + `SELECT Id, Email FROM Lead WHERE Email = '${this.sanitizeSoqlValue(attendee.email)}' LIMIT 1` ); if (leadQuery.records.length > 0) { const contact = leadQuery.records[0] as { Id: string; Email: string }; @@ -973,17 +981,17 @@ class SalesforceCRMService implements CRM { } for (const event of salesforceEvents) { - const salesforceEvent = (await conn.query(`SELECT WhoId FROM Event WHERE Id = '${event.uid}'`)) as { + const salesforceEvent = (await conn.query(`SELECT WhoId FROM Event WHERE Id = '${this.sanitizeSoqlValue(event.uid)}'`)) as { records: { WhoId: string }[]; }; let salesforceAttendeeEmail: string | undefined = undefined; // Figure out if the attendee is a contact or lead const contactQuery = (await conn.query( - `SELECT Email FROM Contact WHERE Id = '${salesforceEvent.records[0].WhoId}'` + `SELECT Email FROM Contact WHERE Id = '${this.sanitizeSoqlValue(salesforceEvent.records[0].WhoId)}'` )) as { records: { Email: string }[] }; const leadQuery = (await conn.query( - `SELECT Email FROM Lead WHERE Id = '${salesforceEvent.records[0].WhoId}'` + `SELECT Email FROM Lead WHERE Id = '${this.sanitizeSoqlValue(salesforceEvent.records[0].WhoId)}'` )) as { records: { Email: string }[] }; // Prioritize contacts over leads @@ -1266,7 +1274,7 @@ class SalesforceCRMService implements CRM { // Get the associated record that the event was created on const recordQuery = (await conn.query( - `SELECT OwnerId, Owner.Name FROM ${recordType} WHERE Id = '${id}'` + `SELECT OwnerId, Owner.Name FROM ${recordType} WHERE Id = '${this.sanitizeSoqlValue(id)}'` )) as { records: { OwnerId: string; Owner: { Name: string } }[] }; if (!recordQuery || !recordQuery.records.length) { @@ -1300,7 +1308,7 @@ class SalesforceCRMService implements CRM { public getAllPossibleAccountWebsiteFromEmailDomain(emailDomain: string) { const websites = getAllPossibleWebsiteValuesFromEmailDomain(emailDomain); // Format for SOQL query - return websites.map((website) => `'${website}'`).join(", "); + return websites.map((website) => `'${this.sanitizeSoqlValue(website)}'`).join(", "); } private async getAccountIdBasedOnEmailDomainOfContacts(email: string) { @@ -1334,7 +1342,7 @@ class SalesforceCRMService implements CRM { // Fallback to querying which account the majority of contacts are under const response = await conn.query( - `SELECT Id, Email, AccountId FROM Contact WHERE Email LIKE '%@${emailDomain}' AND AccountId != null` + `SELECT Id, Email, AccountId FROM Contact WHERE Email LIKE '%@${this.sanitizeSoqlValue(emailDomain)}' AND AccountId != null` ); SalesforceRoutingTraceService.searchingByContactEmailDomain({ @@ -1836,7 +1844,7 @@ class SalesforceCRMService implements CRM { const existingFieldNames = existingFields.map((field) => field.name); const query = await conn.query( - `SELECT Id, ${existingFieldNames.join(", ")} FROM ${personRecordType} WHERE Id = '${contactId}'` + `SELECT Id, ${existingFieldNames.join(", ")} FROM ${personRecordType} WHERE Id = '${this.sanitizeSoqlValue(contactId)}'` ); if (!query.records.length) { @@ -1863,7 +1871,7 @@ class SalesforceCRMService implements CRM { // First see if the contact already exists and connect it to the account const userQuery = await conn.query( - `SELECT Id, Email FROM Contact WHERE Email = '${attendee.email}' LIMIT 1` + `SELECT Id, Email FROM Contact WHERE Email = '${this.sanitizeSoqlValue(attendee.email)}' LIMIT 1` ); if (userQuery.records.length > 0) { const contact = userQuery.records[0] as { Id: string; Email: string }; @@ -1909,7 +1917,7 @@ class SalesforceCRMService implements CRM { if (!accountId) return; const accountQuery = (await conn.query( - `SELECT ${lookupField.name} FROM ${SalesforceRecordEnum.ACCOUNT} WHERE Id = '${accountId}'` + `SELECT ${lookupField.name} FROM ${SalesforceRecordEnum.ACCOUNT} WHERE Id = '${this.sanitizeSoqlValue(accountId)}'` )) as { // We do not know what fields are included in the account since it's unqiue to each instance // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -2043,7 +2051,7 @@ class SalesforceCRMService implements CRM { private async findContactByEmail(email: string) { const conn = await this.conn; const contactsQuery = await conn.query( - `SELECT Id, Email FROM ${SalesforceRecordEnum.CONTACT} WHERE Email = '${email}' LIMIT 1` + `SELECT Id, Email FROM ${SalesforceRecordEnum.CONTACT} WHERE Email = '${this.sanitizeSoqlValue(email)}' LIMIT 1` ); if (contactsQuery.records.length > 0) { @@ -2054,7 +2062,7 @@ class SalesforceCRMService implements CRM { private async findLeadByEmail(email: string) { const conn = await this.conn; const leadsQuery = await conn.query( - `SELECT Id, Email FROM ${SalesforceRecordEnum.LEAD} WHERE Email = '${email}' LIMIT 1` + `SELECT Id, Email FROM ${SalesforceRecordEnum.LEAD} WHERE Email = '${this.sanitizeSoqlValue(email)}' LIMIT 1` ); if (leadsQuery.records.length > 0) { diff --git a/packages/lib/server/service/lingoDotDev.ts b/packages/lib/server/service/lingoDotDev.ts index d64bc5bb89103a..5cac5a798aa433 100644 --- a/packages/lib/server/service/lingoDotDev.ts +++ b/packages/lib/server/service/lingoDotDev.ts @@ -1,13 +1,14 @@ -import type { LocaleCode } from "@lingo.dev/_spec"; -import { LingoDotDevEngine } from "lingo.dev/sdk"; - import { LINGO_DOT_DEV_API_KEY } from "@calcom/lib/constants"; import logger from "@calcom/lib/logger"; +import type { LocaleCode } from "@lingo.dev/_spec"; +import { LingoDotDevEngine } from "lingo.dev/sdk"; export class LingoDotDevService { - private static engine = new LingoDotDevEngine({ - apiKey: LINGO_DOT_DEV_API_KEY, - }); + private static engine = LINGO_DOT_DEV_API_KEY + ? new LingoDotDevEngine({ + apiKey: LINGO_DOT_DEV_API_KEY, + }) + : null; /** * Localizes text from one language to another @@ -25,8 +26,13 @@ export class LingoDotDevService { return null; } + if (!LingoDotDevService.engine) { + logger.warn("LingoDotDevService.localizeText() skipped: LINGO_DOT_DEV_API_KEY is not set"); + return null; + } + try { - const result = await this.engine.localizeText(text, { + const result = await LingoDotDevService.engine.localizeText(text, { sourceLocale, targetLocale, }); @@ -50,8 +56,13 @@ export class LingoDotDevService { sourceLocale: string, targetLocales: string[] ): Promise { + if (!LingoDotDevService.engine) { + logger.warn("LingoDotDevService.batchLocalizeText() skipped: LINGO_DOT_DEV_API_KEY is not set"); + return []; + } + try { - const result = await this.engine.batchLocalizeText(text, { + const result = await LingoDotDevService.engine.batchLocalizeText(text, { // TODO: LocaleCode is hacky, use our locale mapping instead. sourceLocale: sourceLocale as LocaleCode, targetLocales: targetLocales as LocaleCode[], @@ -76,8 +87,13 @@ export class LingoDotDevService { return texts; } + if (!LingoDotDevService.engine) { + logger.warn("LingoDotDevService.localizeTexts() skipped: LINGO_DOT_DEV_API_KEY is not set"); + return texts; + } + try { - const result = await this.engine.localizeChat( + const result = await LingoDotDevService.engine.localizeChat( texts.map((text) => ({ name: "NO_NAME", text: text.trim() })), { sourceLocale, diff --git a/packages/trpc/server/routers/viewer/webhook/create.handler.ts b/packages/trpc/server/routers/viewer/webhook/create.handler.ts index 387e79b607ab59..d5411b7cc0fbf5 100644 --- a/packages/trpc/server/routers/viewer/webhook/create.handler.ts +++ b/packages/trpc/server/routers/viewer/webhook/create.handler.ts @@ -33,9 +33,10 @@ export const createHandler = async ({ ctx, input }: CreateOptions) => { }); } + const { webhookId: _webhookId, ...inputWithoutWebhookId } = input; const webhookData: Prisma.WebhookCreateInput = { id: v4(), - ...input, + ...inputWithoutWebhookId, }; if (input.platform && user.role !== "ADMIN") { throw new TRPCError({ code: "UNAUTHORIZED" }); diff --git a/packages/trpc/server/routers/viewer/webhook/edit.handler.ts b/packages/trpc/server/routers/viewer/webhook/edit.handler.ts index 88925d7c7ff58e..53666e2ca260b5 100644 --- a/packages/trpc/server/routers/viewer/webhook/edit.handler.ts +++ b/packages/trpc/server/routers/viewer/webhook/edit.handler.ts @@ -21,7 +21,7 @@ type EditOptions = { }; export const editHandler = async ({ input, ctx }: EditOptions) => { - const { id, ...data } = input; + const { id, webhookId: _webhookId, ...data } = input; const webhook = await prisma.webhook.findUnique({ where: { diff --git a/packages/trpc/server/routers/viewer/webhook/get.handler.ts b/packages/trpc/server/routers/viewer/webhook/get.handler.ts index 4815316c00b523..10b9708400eaa9 100644 --- a/packages/trpc/server/routers/viewer/webhook/get.handler.ts +++ b/packages/trpc/server/routers/viewer/webhook/get.handler.ts @@ -11,5 +11,5 @@ type GetOptions = { export const getHandler = async ({ ctx: _ctx, input }: GetOptions) => { const { repository: webhookRepository } = getWebhookFeature(); - return await webhookRepository.findByWebhookId(input.webhookId); + return await webhookRepository.findByWebhookId(input.id || input.webhookId); }; diff --git a/packages/trpc/server/routers/viewer/webhook/get.schema.ts b/packages/trpc/server/routers/viewer/webhook/get.schema.ts index 6e97404ca3b486..639248e8f9c01b 100644 --- a/packages/trpc/server/routers/viewer/webhook/get.schema.ts +++ b/packages/trpc/server/routers/viewer/webhook/get.schema.ts @@ -2,8 +2,6 @@ import { z } from "zod"; import { webhookIdAndEventTypeIdSchema } from "./types"; -export const ZGetInputSchema = webhookIdAndEventTypeIdSchema.extend({ - webhookId: z.string().optional(), -}); +export const ZGetInputSchema = webhookIdAndEventTypeIdSchema; export type TGetInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/webhook/types.ts b/packages/trpc/server/routers/viewer/webhook/types.ts index 562f4465045a45..ba1217fe114477 100644 --- a/packages/trpc/server/routers/viewer/webhook/types.ts +++ b/packages/trpc/server/routers/viewer/webhook/types.ts @@ -4,6 +4,7 @@ import { z } from "zod"; export const webhookIdAndEventTypeIdSchema = z.object({ // Webhook ID id: z.string().optional(), + webhookId: z.string().optional(), eventTypeId: z.number().optional(), teamId: z.number().optional(), }); diff --git a/packages/trpc/server/routers/viewer/webhook/util.ts b/packages/trpc/server/routers/viewer/webhook/util.ts index e576ed64659de2..50eabe7879a0ab 100644 --- a/packages/trpc/server/routers/viewer/webhook/util.ts +++ b/packages/trpc/server/routers/viewer/webhook/util.ts @@ -22,13 +22,14 @@ export const createWebhookPbacProcedure = ( // Endpoints that just read the logged in user's data - like 'list' don't necessarily have any input if (!input) return next(); - const { id, teamId, eventTypeId } = input; + const { id, webhookId, teamId, eventTypeId } = input; + const lookupId = id || webhookId; const permissionCheckService = new PermissionCheckService(); - if (id) { + if (lookupId) { // Check if user is authorized to edit webhook const webhook = await prisma.webhook.findUnique({ - where: { id }, + where: { id: lookupId }, select: { id: true, userId: true,