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:
Gustavo
2026-07-31 22:04:19 -03:00
parent 29f9f3a905
commit 0c36f27cb3
6 changed files with 123 additions and 10 deletions
+9 -3
View File
@@ -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(', ');
+6 -4
View File
@@ -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 (
+6 -3
View File
@@ -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(