From fd5b0c8dda2336de76dd266f1eeded627de19d82 Mon Sep 17 00:00:00 2001 From: datnq2001 Date: Tue, 12 May 2026 15:58:06 +1000 Subject: [PATCH] Fix profile phone hydration after encrypted profile updates --- model/getUserProfile.js | 31 +++++++++++++++++++++++++++---- model/updateUserProfile.js | 31 +++++++++++++++++++++++++++---- services/userProfileService.js | 12 ++++++------ 3 files changed, 60 insertions(+), 14 deletions(-) diff --git a/model/getUserProfile.js b/model/getUserProfile.js index 74e09b2..19cd98b 100644 --- a/model/getUserProfile.js +++ b/model/getUserProfile.js @@ -1,6 +1,8 @@ const supabase = require("../dbConnection.js"); const { decrypt } = require("../services/encryptionService"); +const LEGACY_HEX_TRIPLET_REGEX = /^[0-9a-f]+:[0-9a-f]+:[0-9a-f]+$/i; + function parseEncryptedPayload(rawValue) { if (typeof rawValue !== "string") return null; const trimmed = rawValue.trim(); @@ -23,6 +25,14 @@ function parseEncryptedPayload(rawValue) { } } +function looksLikeUndecryptedCiphertext(value) { + if (typeof value !== "string") return false; + const trimmed = value.trim(); + if (!trimmed) return false; + if (LEGACY_HEX_TRIPLET_REGEX.test(trimmed)) return true; + return parseEncryptedPayload(trimmed) !== null; +} + async function maybeDecryptLegacyField(value) { if (!value) return value; const encryptedObj = parseEncryptedPayload(value); @@ -31,8 +41,8 @@ async function maybeDecryptLegacyField(value) { try { return await decrypt(encryptedObj.encrypted, encryptedObj.iv, encryptedObj.authTag); } catch (_error) { - // Keep profile fetch resilient for mixed plaintext/encrypted legacy rows. - return value; + // If decryption fails, do not leak encrypted payload text to clients. + return null; } } @@ -44,10 +54,23 @@ async function decryptSensitiveFields(profile) { const decryptedContact = await maybeDecryptLegacyField(profile.contact_number); const decryptedAddress = await maybeDecryptLegacyField(profile.address); + const normalizedContact = + decryptedContact == null + ? null + : looksLikeUndecryptedCiphertext(decryptedContact) + ? null + : decryptedContact; + const normalizedAddress = + decryptedAddress == null + ? null + : looksLikeUndecryptedCiphertext(decryptedAddress) + ? null + : decryptedAddress; + return { ...profile, - contact_number: decryptedContact, - address: decryptedAddress, + contact_number: normalizedContact, + address: normalizedAddress, }; } diff --git a/model/updateUserProfile.js b/model/updateUserProfile.js index dd9497c..e192d07 100644 --- a/model/updateUserProfile.js +++ b/model/updateUserProfile.js @@ -2,6 +2,8 @@ const supabase = require("../dbConnection.js"); const { decode } = require("base64-arraybuffer"); const { encrypt, decrypt } = require("../services/encryptionService"); +const LEGACY_HEX_TRIPLET_REGEX = /^[0-9a-f]+:[0-9a-f]+:[0-9a-f]+$/i; + function parseEncryptedPayload(rawValue) { if (typeof rawValue !== "string") return null; const trimmed = rawValue.trim(); @@ -24,6 +26,14 @@ function parseEncryptedPayload(rawValue) { } } +function looksLikeUndecryptedCiphertext(value) { + if (typeof value !== "string") return false; + const trimmed = value.trim(); + if (!trimmed) return false; + if (LEGACY_HEX_TRIPLET_REGEX.test(trimmed)) return true; + return parseEncryptedPayload(trimmed) !== null; +} + async function maybeDecryptLegacyField(value) { if (!value) return value; const encryptedObj = parseEncryptedPayload(value); @@ -32,8 +42,8 @@ async function maybeDecryptLegacyField(value) { try { return await decrypt(encryptedObj.encrypted, encryptedObj.iv, encryptedObj.authTag); } catch (_error) { - // Keep profile reads resilient for mixed plaintext/encrypted legacy rows. - return value; + // If decryption fails, do not leak encrypted payload text to clients. + return null; } } @@ -45,10 +55,23 @@ async function decryptSensitiveFields(profile) { const decryptedContact = await maybeDecryptLegacyField(profile.contact_number); const decryptedAddress = await maybeDecryptLegacyField(profile.address); + const normalizedContact = + decryptedContact == null + ? null + : looksLikeUndecryptedCiphertext(decryptedContact) + ? null + : decryptedContact; + const normalizedAddress = + decryptedAddress == null + ? null + : looksLikeUndecryptedCiphertext(decryptedAddress) + ? null + : decryptedAddress; + return { ...profile, - contact_number: decryptedContact, - address: decryptedAddress, + contact_number: normalizedContact, + address: normalizedAddress, }; } diff --git a/services/userProfileService.js b/services/userProfileService.js index f7a3e52..8173873 100644 --- a/services/userProfileService.js +++ b/services/userProfileService.js @@ -251,21 +251,21 @@ async function updateCanonicalProfile({ actor, targetLookup, body }) { attributes.address = null; } - const updatedProfile = await updateUser({ + await updateUser({ userId: existingProfile.user_id, attributes }); - const mergedProfile = updatedProfile || existingProfile; - if (updates.userImage) { - mergedProfile.image_url = await saveImage(updates.userImage, existingProfile.user_id); + await saveImage(updates.userImage, existingProfile.user_id); } - const preferences = await fetchUserPreferences(existingProfile.user_id); + // Re-read canonical profile so the response always includes decrypted + // sensitive fields from profile_encrypted (contactNumber/address). + const refreshed = await getCanonicalProfile({ userId: existingProfile.user_id }); return { - ...buildProfileResponse(mergedProfile, preferences), + ...refreshed, message: 'Profile updated successfully', meta: { updatedBy: actor?.userId || null