Fix opt-out asymmetric 8/9-digit bypass letting sends reach blocked numbers
isOptedOut/addOptOut/removeOptOut used equivalentPhoneNumbers, which is not symmetric for a 12-digit local outside the 6-9 range vs its 13-digit counterpart (same root cause already fixed for shared-number detection). An opt-out registered against one form of such a number silently failed to block sends to a lead whose number was stored in the other form, on both the manual send path (reserveOutreachSend) and, independently, in two inlined checks that bypassed isOptedOut entirely: the last-mile recheck in campaign-worker.mjs's reserveCampaignQueueSend, and the opt-out NOT EXISTS clause in campaign-queue.mjs's candidate query. All three now use the symmetric canonicalPhoneVariants key already introduced for shared-number overrides, and the worker's last-mile check now calls isOptedOut directly instead of re-deriving its own number list. Found via a full-project audit; reproduced and verified before fixing.
This commit is contained in:
+9
-3
@@ -929,8 +929,14 @@ export function updateMessageTemplate(db, produto, { mensagem, ativo }) {
|
||||
return db.prepare('SELECT id, produto, mensagem, ativo, updated_at FROM message_templates WHERE produto = ?').get(produto);
|
||||
}
|
||||
|
||||
// Usa canonicalPhoneVariants (e nao equivalentPhoneNumbers): a relacao
|
||||
// precisa ser simetrica, senao um opt-out registrado numa forma do numero
|
||||
// (ex.: com o nono digito) pode nao bater contra a outra forma do mesmo
|
||||
// numero (sem o nono digito) quando o local nao comeca com 6-9 — deixando o
|
||||
// bloqueio de opt-out furavel dependendo de qual forma foi usada em cada
|
||||
// ponta. Mesmo raciocinio de leadsSharingNumber/addSharedNumberOverride.
|
||||
export function isOptedOut(db, numero) {
|
||||
const numbers = equivalentPhoneNumbers(numero);
|
||||
const numbers = canonicalPhoneVariants(numero);
|
||||
if (!numbers.length) return false;
|
||||
const placeholders = numbers.map(() => '?').join(', ');
|
||||
return Boolean(db.prepare(`SELECT 1 FROM outreach_opt_outs WHERE numero IN (${placeholders})`).get(...numbers));
|
||||
@@ -941,7 +947,7 @@ export function listOptOuts(db) {
|
||||
}
|
||||
|
||||
export function addOptOut(db, numero, motivo = '') {
|
||||
const numbers = equivalentPhoneNumbers(numero);
|
||||
const numbers = canonicalPhoneVariants(numero);
|
||||
if (!numbers.length) throw new Error('numero obrigatorio');
|
||||
return transaction(db, () => {
|
||||
const placeholders = numbers.map(() => '?').join(', ');
|
||||
@@ -953,7 +959,7 @@ export function addOptOut(db, numero, motivo = '') {
|
||||
}
|
||||
|
||||
export function removeOptOut(db, numero) {
|
||||
const numbers = equivalentPhoneNumbers(numero);
|
||||
const numbers = canonicalPhoneVariants(numero);
|
||||
if (!numbers.length) return false;
|
||||
return transaction(db, () => {
|
||||
const placeholders = numbers.map(() => '?').join(', ');
|
||||
|
||||
@@ -33,9 +33,11 @@ function candidateQuery(confirmed) {
|
||||
// See task-3-report.md for the full investigation and evidence.
|
||||
const leadNumberExpr = normalizedSql('raw.whatsapp');
|
||||
const leadEquivalentExpr = equivalentSql('raw.whatsapp');
|
||||
// Chave canonica + a outra forma dela: par simetrico usado so na deteccao de
|
||||
// numero compartilhado (opt-out/verificacao/inbound seguem a expansao
|
||||
// unidirecional de equivalentPhoneNumbers).
|
||||
// Chave canonica + a outra forma dela: par simetrico usado na deteccao de
|
||||
// numero compartilhado E no opt-out (a asimetria de equivalentPhoneNumbers
|
||||
// podia deixar um opt-out registrado numa forma do numero nao bloquear a
|
||||
// outra forma do mesmo numero). Verificacao/inbound continuam na expansao
|
||||
// unidirecional de equivalentPhoneNumbers.
|
||||
const leadCanonicalExpr = canonicalKeySql('raw.whatsapp');
|
||||
const leadCanonicalVariantExpr = canonicalVariantSql('raw.whatsapp');
|
||||
const leadNumber = 'candidates.lead_number';
|
||||
@@ -83,7 +85,7 @@ function candidateQuery(confirmed) {
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM outreach_opt_outs opt_out
|
||||
WHERE opt_out.numero IN (${leadNumber}, ${leadEquivalent})
|
||||
WHERE opt_out.numero IN (${leadCanonical}, ${leadCanonicalVariant})
|
||||
)
|
||||
AND (
|
||||
NOT EXISTS (
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
} from '../phone.mjs';
|
||||
import {
|
||||
isNumberSharedAcrossLeads,
|
||||
isOptedOut,
|
||||
isSharedNumberOverridden,
|
||||
setSetting
|
||||
} from '../db.mjs';
|
||||
@@ -86,9 +87,11 @@ export function reserveCampaignQueueSend(db, { queueId, now = new Date() }) {
|
||||
let numbers;
|
||||
try {
|
||||
numbers = requireConfirmedNumber(db, row.numero);
|
||||
if (db.prepare(
|
||||
`SELECT 1 FROM outreach_opt_outs WHERE numero IN (${placeholders(numbers)}) LIMIT 1`
|
||||
).get(...numbers)) {
|
||||
// isOptedOut usa a chave canonica simetrica (nao a expansao de
|
||||
// equivalentPhoneNumbers de `numbers` acima, que e assimetrica e podia
|
||||
// deixar passar um numero bloqueado dependendo de qual forma foi usada
|
||||
// para registrar o opt-out).
|
||||
if (isOptedOut(db, row.numero)) {
|
||||
throw permanentPolicyConflict('numero bloqueado por opt-out', 'opt_out');
|
||||
}
|
||||
if (db.prepare(
|
||||
|
||||
Reference in New Issue
Block a user