R4 hardening (A9): retry ricevuta + webhook GDPR + rate-limit prune + fix PII/UX

Da review avversariale indipendente:
- Retry invio email (trySend, 3 tentativi backoff) per ricevuta cliente e notifica merchant.
- Webhook GDPR implementati (erano stub) con idempotenza (skip se gia' processed):
  * customers/redact: pseudonimizza PII (nome/email/dichiarazione) nelle
    WithdrawalRequest del cliente, mantiene il record legale (ordine+timestamp)
    come prova art. 54-bis (base art. 17(3) GDPR).
  * shop/redact: purge completa dei dati shop (Settings/Exclusion/Withdrawal/
    Session/AuditLog + WebhookEvent stale).
  * customers/data_request: registra la richiesta + n. record (merchant li recupera
    dalla dashboard Recessi).
- Rate-limit: pruning periodico del bucket in-memory (fix memory leak).
- Ricevuta fallita: messaggio di successo onesto (successMessage riceve receipt.ok)
  invece del falso 'ti abbiamo inviato la ricevuta'.
- PII: rimossa dai detail audit persistiti degli errori SMTP (ricevuta/notifica).

Non modificati (verificati): off-by-1 finestra = permissivo, non blocca a torto;
'doppio escape' = falso allarme (template e valori escapati una volta ciascuno).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mv83a29B4eFv5ixoj6PoE1
This commit is contained in:
2026-07-07 17:07:06 +02:00
parent ce36f1a657
commit ad9f76b6be
7 changed files with 132 additions and 34 deletions

View File

@@ -34,6 +34,28 @@ function buildTransport() {
});
}
type Transport = NonNullable<ReturnType<typeof buildTransport>>;
/** Invio con retry (backoff lineare). Riduce le ricevute perse per glitch SMTP. */
async function trySend(
transport: Transport,
message: Parameters<Transport["sendMail"]>[0],
attempts = 3,
) {
let lastErr: unknown;
for (let i = 0; i < attempts; i++) {
try {
return await transport.sendMail(message);
} catch (e) {
lastErr = e;
if (i < attempts - 1) {
await new Promise((r) => setTimeout(r, 400 * (i + 1)));
}
}
}
throw lastErr;
}
/** Versione testo grezza dell'HTML (fallback per client senza HTML). */
function htmlToText(html: string): string {
return html
@@ -83,7 +105,7 @@ export async function sendWithdrawalReceipt(params: {
const text = htmlToText(html);
try {
const info = await transport.sendMail({
const info = await trySend(transport, {
from: process.env.MAIL_FROM ?? "no-reply@localhost",
to: params.to,
subject,
@@ -171,7 +193,7 @@ ${orderBtn}
.join("\n");
try {
const info = await transport.sendMail({
const info = await trySend(transport, {
from: process.env.MAIL_FROM ?? "no-reply@localhost",
to: params.to,
subject,

View File

@@ -58,11 +58,14 @@ export function successMessage(
orderName: string,
transmittedAt: string,
email: string,
receiptSent = true,
): { line1: string; line2: string; line3: string } {
return {
line1: "Recesso trasmesso",
line2: `Registrato per l'ordine ${orderName} il ${transmittedAt}.`,
line3: `Ti abbiamo inviato una ricevuta a ${email}.`,
line3: receiptSent
? `Ti abbiamo inviato una ricevuta a ${email}.`
: `La ricevuta verrà inviata a ${email}. Se non la ricevi a breve, contattaci.`,
};
}

View File

@@ -98,9 +98,20 @@ const RATE_MAX_ATTEMPTS = 8; // tentativi di lookup per finestra, per shop+IP
const rateBucket = new Map<string, { count: number; resetAt: number }>();
/** Ritorna true se la richiesta è consentita, false se ha superato la soglia. */
let lastRatePruneAt = 0;
/** Rimuove le voci scadute dal bucket (evita crescita illimitata della Map). */
function pruneRateBucket(now: number): void {
if (now - lastRatePruneAt < 60_000) return;
lastRatePruneAt = now;
for (const [k, v] of rateBucket) {
if (v.resetAt <= now) rateBucket.delete(k);
}
}
export function checkRateLimit(shop: string, ip: string): boolean {
const key = `${shop}:${ip}`;
const now = Date.now();
pruneRateBucket(now);
const entry = rateBucket.get(key);
if (!entry || entry.resetAt <= now) {
rateBucket.set(key, { count: 1, resetAt: now + RATE_WINDOW_MS });

View File

@@ -412,11 +412,12 @@ export const action = async ({ request }: ActionFunctionArgs) => {
data: {
shop,
event: "receipt_failed",
detail: receipt.error.slice(0, 200),
// no PII in audit: l'errore SMTP puo' contenere l'email.
detail: "invio ricevuta fallito",
},
});
// TODO(A9): coda/retry per la ricevuta fallita ("senza ritardo") +
// messaggio di successo che rifletta l'esito reale dell'invio.
// Retry in-request nel mailer (trySend) + messaggio di successo onesto
// (successMessage riceve receipt.ok). Coda persistente = eventuale futuro.
}
} catch (e) {
console.error("[recesso] aggiornamento stato ricevuta fallito:", e);
@@ -533,11 +534,15 @@ export const action = async ({ request }: ActionFunctionArgs) => {
transmittedAt: transmittedLabel,
returnStatus,
});
if (!notif.ok) {
console.error("[recesso] notifica merchant fallita:", notif.error);
}
await db.auditLog.create({
data: {
shop,
event: notif.ok ? "merchant_notified" : "merchant_notify_failed",
detail: notif.ok ? match.orderName : notif.error.slice(0, 200),
// no PII in audit: l'errore SMTP puo' contenere l'email.
detail: notif.ok ? match.orderName : "notifica merchant fallita",
},
});
} catch (e) {
@@ -545,7 +550,12 @@ export const action = async ({ request }: ActionFunctionArgs) => {
}
}
const msg = successMessage(match.orderName, transmittedLabel, email);
const msg = successMessage(
match.orderName,
transmittedLabel,
email,
receipt.ok,
);
return htmlResponse(renderStep4(msg));
}

View File

@@ -17,23 +17,39 @@ export const action = async ({ request }: ActionFunctionArgs) => {
.update(JSON.stringify(payload ?? {}))
.digest("hex");
// Idempotency: record the webhook once (webhookId is unique when present).
// Idempotency: se gia' lavorato, esci.
const dedupeKey = webhookId ?? `${topic}:${payloadHash}`;
const existing = await db.webhookEvent.findUnique({
where: { webhookId: dedupeKey },
});
if (existing?.processed) return new Response();
await db.webhookEvent.upsert({
where: { webhookId: dedupeKey },
create: { shop, topic, webhookId: dedupeKey, processed: false },
update: {},
});
// I dati del cliente (richieste di recesso) sono consultabili dal merchant
// (titolare) nella dashboard Recessi, che li relaziona al data subject.
// Registriamo la richiesta e quanti record esistono.
const email = ((payload as { customer?: { email?: unknown } } | null)?.customer
?.email ?? null) as string | null;
const count =
typeof email === "string" && email
? await db.withdrawalRequest.count({ where: { shop, email } })
: 0;
await db.auditLog.create({
data: { shop, event: `gdpr.${topic}`, payloadHash, detail: "customers/data_request received" },
data: {
shop,
event: `gdpr.${topic}`,
payloadHash,
detail: `customers/data_request: ${count} record disponibili nella dashboard Recessi`,
},
});
await db.webhookEvent.update({
where: { webhookId: dedupeKey },
data: { processed: true },
});
// TODO(A8): gather every stored personal data point for this customer
// (WithdrawalRequest rows matched by email / customer id: name, email,
// statement text, transmission timestamp, order refs) and hand it to the
// merchant (data controller), who relays it to the data subject. Then mark
// the WebhookEvent processed = true.
return new Response();
};

View File

@@ -18,22 +18,47 @@ export const action = async ({ request }: ActionFunctionArgs) => {
.update(JSON.stringify(payload ?? {}))
.digest("hex");
// Idempotency: record the webhook once (webhookId is unique when present).
// Idempotency: se gia' lavorato, esci.
const dedupeKey = webhookId ?? `${topic}:${payloadHash}`;
const existing = await db.webhookEvent.findUnique({
where: { webhookId: dedupeKey },
});
if (existing?.processed) return new Response();
await db.webhookEvent.upsert({
where: { webhookId: dedupeKey },
create: { shop, topic, webhookId: dedupeKey, processed: false },
update: {},
});
// Pseudonimizza la PII del cliente nelle richieste di recesso, mantenendo il
// record legale (ordine, timestamp) come prova ex art. 54-bis. Base di
// conservazione: obbligo legale / difesa in giudizio (art. 17(3) GDPR).
const email = ((payload as { customer?: { email?: unknown } } | null)?.customer
?.email ?? null) as string | null;
let redacted = 0;
if (typeof email === "string" && email) {
const res = await db.withdrawalRequest.updateMany({
where: { shop, email },
data: {
customerName: "[redatto]",
email: "[redatto]",
statementText: "[redatto]",
},
});
redacted = res.count;
}
await db.auditLog.create({
data: { shop, event: `gdpr.${topic}`, payloadHash, detail: "customers/redact received" },
data: {
shop,
event: `gdpr.${topic}`,
payloadHash,
detail: `customers/redact: ${redacted} record pseudonimizzati`,
},
});
await db.webhookEvent.update({
where: { webhookId: dedupeKey },
data: { processed: true },
});
// TODO(A8): redact/anonymize this customer's PII in WithdrawalRequest
// (customerName, email, statementText) for the given shop + customer/orders,
// WITHOUT destroying the legal audit trail (keep AuditLog + hashed refs).
// Then mark the WebhookEvent processed = true.
return new Response();
};

View File

@@ -19,22 +19,33 @@ export const action = async ({ request }: ActionFunctionArgs) => {
.update(JSON.stringify(payload ?? {}))
.digest("hex");
// Idempotency: record the webhook once (webhookId is unique when present).
// Idempotency: se gia' lavorato, esci.
const dedupeKey = webhookId ?? `${topic}:${payloadHash}`;
const existing = await db.webhookEvent.findUnique({
where: { webhookId: dedupeKey },
});
if (existing?.processed) return new Response();
await db.webhookEvent.upsert({
where: { webhookId: dedupeKey },
create: { shop, topic, webhookId: dedupeKey, processed: false },
update: {},
});
await db.auditLog.create({
data: { shop, event: `gdpr.${topic}`, payloadHash, detail: "shop/redact received" },
// Purge completa dei dati dello shop (app disinstallata + ~48h). Il merchant,
// come titolare, deve aver esportato prima cio' che gli serve. Cancelliamo
// anche l'AuditLog: cessata la relazione, non c'e' piu' base per conservarlo.
await db.withdrawalRequest.deleteMany({ where: { shop } });
await db.exclusionRule.deleteMany({ where: { shop } });
await db.settings.deleteMany({ where: { shop } });
await db.session.deleteMany({ where: { shop } });
await db.auditLog.deleteMany({ where: { shop } });
await db.webhookEvent.deleteMany({
where: { shop, webhookId: { not: dedupeKey } },
});
// Manteniamo SOLO la WebhookEvent corrente (marcata processata) per idempotenza.
await db.webhookEvent.update({
where: { webhookId: dedupeKey },
data: { processed: true },
});
// TODO(A8): delete all data for this shop (Settings, ExclusionRule,
// WithdrawalRequest, Session, and stale WebhookEvent rows). Decide the legal
// retention policy for AuditLog before wiring the real deletion. Then mark
// the WebhookEvent processed = true.
return new Response();
};