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(
+28
View File
@@ -624,3 +624,31 @@ test('variantes 8 e 9 digitos do mesmo numero contam como compartilhado na fila'
assert.equal(queue.some(({ lead_id }) => [nineDigits, eightDigits].includes(lead_id)), false);
db.close();
});
// Opt-out registrado na outra forma de um par 12/13 digitos assimetrico
// precisa excluir o lead da fila nos dois sentidos (mesma logica de chave
// canonica ja usada para numero compartilhado nesta consulta).
test('opt-out na outra forma de um par 12/13 digitos assimetrico exclui o lead da fila', async () => {
for (const [leadNumero, optOutNumero] of [
['557533334444', '5575933334444'],
['5575933334444', '557533334444']
]) {
const { db, campaign } = setupCampaign();
const lead = insertLead(db, { key: 'assim-optout', whatsapp: leadNumero, score: 100 });
confirmed(db, leadNumero);
addOptOut(db, optOutNumero, 'pediu para nao receber mais');
await prepareDailyQueue({
db,
whatsapp: noSendWhatsapp(async () => { throw new Error('numero possui cache'); }),
campaignId: campaign.id,
now: new Date('2026-07-29T14:00:00-03:00')
});
assert.equal(
listCampaignQueue(db, campaign.id).some((row) => row.lead_id === lead),
false,
`${leadNumero} / opt-out em ${optOutNumero}`
);
db.close();
}
});
+45
View File
@@ -588,6 +588,51 @@ test('ultima milha bloqueia os dois lados de um par 12/13 digitos assimetrico',
}
});
// Mesmo par assimetrico, agora para opt-out: addOptOut/isOptedOut usavam
// equivalentPhoneNumbers diretamente, entao um opt-out registrado numa forma
// do numero podia nao bloquear o envio quando a fila tinha a outra forma.
test('ultima milha bloqueia opt-out registrado na outra forma de um par 12/13 digitos assimetrico', async () => {
const { runDueCampaignQueue } = await workerModule();
for (const [queued, optedOutForm] of [
['557533334444', '5575933334444'],
['5575933334444', '557533334444']
]) {
const { db, campaign } = setup();
const leadId = Number(db.prepare(
`INSERT INTO leads (dedup_key, empresa, whatsapp, segmento, telefone_valido)
VALUES ('assimetrico-optout', 'Lead na fila', ?, 'restaurantes', 1)`
).run(queued).lastInsertRowid);
saveWhatsAppVerification(db, { numero: queued, status: 'confirmado' });
const variantId = db.prepare(
'SELECT id FROM outreach_campaign_variants WHERE campaign_id = ? ORDER BY id LIMIT 1'
).get(campaign.id).id;
const queueId = Number(db.prepare(
`INSERT INTO outreach_campaign_queue
(campaign_id, lead_id, variant_id, produto, numero, segmento,
rendered_message, selection_reason, status, scheduled_at)
VALUES (?, ?, ?, ?, ?, 'restaurantes', 'Mensagem', 'teste', 'scheduled', '2026-07-29T12:00:00.000Z')`
).run(campaign.id, leadId, variantId, campaign.produto, queued).lastInsertRowid);
addOptOut(db, optedOutForm, 'pediu para nao receber mais');
const whatsapp = whatsappFake();
const summary = await runDueCampaignQueue({
db,
whatsapp,
now: new Date('2026-07-29T12:00:00.000Z'),
log: () => {}
});
assert.equal(summary.enviados, 0, queued);
assert.equal(whatsapp.providerCalls, 0, queued);
assert.deepEqual(
{ ...db.prepare('SELECT status, cancel_reason FROM outreach_campaign_queue WHERE id = ?').get(queueId) },
{ status: 'cancelled', cancel_reason: 'opt_out' },
queued
);
db.close();
}
});
test('afterSend finaliza fila e historico uma unica vez por resultado', async () => {
const { runDueCampaignQueue } = await workerModule();
for (const [outcome, queueStatus, sendStatus] of [
+29
View File
@@ -69,6 +69,35 @@ test('opt-out bloqueia e remove as formas equivalentes com e sem nono digito', a
db.close();
});
// equivalentPhoneNumbers nao e simetrica para um par 12/13 digitos fora da
// faixa 6-9 (ver phone.mjs). addOptOut/isOptedOut usavam essa expansao
// diretamente: bloquear numa forma do numero podia nao bloquear a outra forma
// do mesmo numero, deixando passar um envio para quem pediu para nao receber.
test('opt-out bloqueia os dois lados de um par 12/13 digitos assimetrico', async () => {
const { openDb, addOptOut, isOptedOut, removeOptOut, reserveOutreachSend } = await import('../src/db.mjs');
const db = openDb(':memory:');
db.prepare(
`INSERT INTO leads (dedup_key, empresa, whatsapp, telefone_valido)
VALUES ('assim-optout', 'Empresa Assimetrica', '557533334444', 1)`
).run();
addOptOut(db, '5575933334444', 'pedido do contato');
for (const numero of ['557533334444', '5575933334444']) {
assert.equal(isOptedOut(db, numero), true, numero);
assert.throws(
() => reserveOutreachSend(db, { numero, mensagem: 'oi' }),
(error) => error.statusCode === 409,
numero
);
}
assert.equal(removeOptOut(db, '557533334444'), true);
for (const numero of ['557533334444', '5575933334444']) {
assert.equal(isOptedOut(db, numero), false, numero);
}
db.close();
});
test('listagem e detalhe derivam o estado da verificacao equivalente mais recente', async () => {
const { openDb, saveWhatsAppVerification } = await import('../src/db.mjs');
const { getLead, listLeads } = await import('../src/treatment/queries.mjs');