From 31a3b505afe96cf1f0e0f3f3245ea1f211d8971d Mon Sep 17 00:00:00 2001 From: Darren Clarke Date: Thu, 20 Nov 2025 13:12:56 +0100 Subject: [PATCH] Fix phone sanitization and signal group lookup --- apps/bridge-worker/lib/zammad.ts | 59 ++++++++++++++++--- .../formstack/create-ticket-from-form.ts | 29 ++++++++- .../channels_cdr_signal_controller.rb | 10 ++-- 3 files changed, 81 insertions(+), 17 deletions(-) diff --git a/apps/bridge-worker/lib/zammad.ts b/apps/bridge-worker/lib/zammad.ts index 2f19774..ea99041 100644 --- a/apps/bridge-worker/lib/zammad.ts +++ b/apps/bridge-worker/lib/zammad.ts @@ -100,19 +100,57 @@ export const Zammad = ( }; }; -export const getUser = async (zammad: ZammadClient, phoneNumber: string) => { - // Sanitize phone number: only allow digits and + symbol - const mungedNumber = phoneNumber.replace(/[^\d+]/g, ""); +/** + * Sanitizes phone number to E.164 format: +15554446666 + * Strips all non-digit characters except +, ensures + prefix + * @param phoneNumber - Raw phone number (e.g., "(555) 444-6666", "5554446666", "+1 555 444 6666") + * @returns E.164 formatted phone number (e.g., "+15554446666") + * @throws Error if phone number is invalid + */ +export const sanitizePhoneNumber = (phoneNumber: string): string => { + // Remove all characters except digits and + + let cleaned = phoneNumber.replace(/[^\d+]/g, ""); - // Validate phone number format (10-15 digits, optional + prefix) - if (!/^\+?\d{10,15}$/.test(mungedNumber)) { + // Ensure it starts with + + if (!cleaned.startsWith("+")) { + // Assume US/Canada if no country code (11 digits starting with 1, or 10 digits) + if (cleaned.length === 10) { + cleaned = "+1" + cleaned; + } else if (cleaned.length === 11 && cleaned.startsWith("1")) { + cleaned = "+" + cleaned; + } else if (cleaned.length >= 10) { + // International number without +, add it + cleaned = "+" + cleaned; + } + } + + // Validate E.164 format: + followed by 10-15 digits + if (!/^\+\d{10,15}$/.test(cleaned)) { throw new Error(`Invalid phone number format: ${phoneNumber}`); } - // Remove + for search query - const searchNumber = mungedNumber.replace("+", ""); - const results = await zammad.user.search(`phone:${searchNumber}`); + return cleaned; +}; + +export const getUser = async (zammad: ZammadClient, phoneNumber: string) => { + // Sanitize to E.164 format + const sanitized = sanitizePhoneNumber(phoneNumber); + + // Remove + for Zammad search query + const searchNumber = sanitized.replace("+", ""); + + // Try sanitized format first (e.g., "6464229653" for "+16464229653") + let results = await zammad.user.search(`phone:${searchNumber}`); if (results.length > 0) return results[0]; + + // Fall back to searching for original input (handles legacy formatted numbers) + // This ensures we can find users with "(646) 422-9653" format in database + const originalCleaned = phoneNumber.replace(/[^\d+]/g, "").replace("+", ""); + if (originalCleaned !== searchNumber) { + results = await zammad.user.search(`phone:${originalCleaned}`); + if (results.length > 0) return results[0]; + } + return undefined; }; @@ -123,8 +161,11 @@ export const getOrCreateUser = async ( const customer = await getUser(zammad, phoneNumber); if (customer) return customer; + // Sanitize phone number to E.164 format before storing + const sanitized = sanitizePhoneNumber(phoneNumber); + return zammad.user.create({ - phone: phoneNumber, + phone: sanitized, note: "User created from incoming voice call", }); }; diff --git a/apps/bridge-worker/tasks/formstack/create-ticket-from-form.ts b/apps/bridge-worker/tasks/formstack/create-ticket-from-form.ts index 7776a81..3acd62a 100644 --- a/apps/bridge-worker/tasks/formstack/create-ticket-from-form.ts +++ b/apps/bridge-worker/tasks/formstack/create-ticket-from-form.ts @@ -1,6 +1,6 @@ import { createLogger } from "@link-stack/logger"; import { db } from "@link-stack/bridge-common"; -import { Zammad, getUser } from "../../lib/zammad.js"; +import { Zammad, getUser, sanitizePhoneNumber } from "../../lib/zammad.js"; import { loadFieldMapping, getFieldValue, @@ -55,12 +55,35 @@ const createTicketFromFormTask = async ( // Extract well-known fields used for special logic (all optional) const email = getFieldValue(formData, "email", mapping); - const phone = getFieldValue(formData, "phone", mapping); - const signalAccount = getFieldValue(formData, "signalAccount", mapping); + const rawPhone = getFieldValue(formData, "phone", mapping); + const rawSignalAccount = getFieldValue(formData, "signalAccount", mapping); const organization = getFieldValue(formData, "organization", mapping); const typeOfSupport = getFieldValue(formData, "typeOfSupport", mapping); const descriptionOfIssue = getFieldValue(formData, "descriptionOfIssue", mapping); + // Sanitize phone numbers to E.164 format (+15554446666) + let phone: string | undefined; + if (rawPhone) { + try { + phone = sanitizePhoneNumber(rawPhone); + logger.info({ rawPhone, sanitized: phone }, "Sanitized phone number"); + } catch (error: any) { + logger.warn({ rawPhone, error: error.message }, "Invalid phone number format, ignoring"); + phone = undefined; + } + } + + let signalAccount: string | undefined; + if (rawSignalAccount) { + try { + signalAccount = sanitizePhoneNumber(rawSignalAccount); + logger.info({ rawSignalAccount, sanitized: signalAccount }, "Sanitized signal account"); + } catch (error: any) { + logger.warn({ rawSignalAccount, error: error.message }, "Invalid signal account format, ignoring"); + signalAccount = undefined; + } + } + // Validate that at least one contact method is provided if (!email && !phone && !signalAccount) { logger.error( diff --git a/packages/zammad-addon-bridge/src/app/controllers/channels_cdr_signal_controller.rb b/packages/zammad-addon-bridge/src/app/controllers/channels_cdr_signal_controller.rb index 3d75b2f..f09c458 100644 --- a/packages/zammad-addon-bridge/src/app/controllers/channels_cdr_signal_controller.rb +++ b/packages/zammad-addon-bridge/src/app/controllers/channels_cdr_signal_controller.rb @@ -222,11 +222,11 @@ class ChannelsCdrSignalController < ApplicationController Rails.logger.info "Channel ID: #{channel.id}" begin - # Use PostgreSQL JSONB queries to efficiently search preferences without loading all tickets into memory + # Use text search on preferences YAML to efficiently find tickets without loading all into memory # This prevents DoS attacks from memory exhaustion ticket = Ticket.where.not(state_id: state_ids) - .where("preferences->>'channel_id' = ?", channel.id.to_s) - .where("preferences->'cdr_signal'->>'chat_id' = ?", receiver_phone_number) + .where("preferences LIKE ?", "%channel_id: #{channel.id}%") + .where("preferences LIKE ?", "%chat_id: #{receiver_phone_number}%") .order(updated_at: :desc) .first @@ -420,11 +420,11 @@ class ChannelsCdrSignalController < ApplicationController end # Find ticket(s) with this group_id in preferences - # Use PostgreSQL JSONB queries for efficient lookup (prevents DoS from loading all tickets) + # Use text search on preferences YAML for efficient lookup (prevents DoS from loading all tickets) state_ids = Ticket::State.where(name: %w[closed merged removed]).pluck(:id) ticket = Ticket.where.not(state_id: state_ids) - .where("preferences->'cdr_signal'->>'chat_id' = ?", params[:group_id]) + .where("preferences LIKE ?", "%chat_id: #{params[:group_id]}%") .order(updated_at: :desc) .first