Skip to content

Commit 21b4d97

Browse files
committed
fix: added stripe fraud prevention, fixed comcast/envelope spam detection, optimize MX -> sqlite storage via databaseMap, fixed archive.finalize() invocation ordering, fixed distinct > aggregate due to slow running query large return values, increased rate limiting for refund download from 36 to 90
1 parent 07011cf commit 21b4d97

13 files changed

Lines changed: 365 additions & 43 deletions

‎app/controllers/api/v1/stripe.js‎

Lines changed: 127 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,15 @@ const humanize = require('humanize-string');
1212
const isSANB = require('is-string-and-not-blank');
1313
const ms = require('ms');
1414
const parseErr = require('parse-err');
15+
const pMapSeries = require('p-map-series');
1516
const titleize = require('titleize');
1617

1718
const { Users, Domains } = require('#models');
1819
const config = require('#config');
1920
const env = require('#config/env');
20-
const syncStripePaymentIntent = require('#helpers/sync-stripe-payment-intent');
2121
const emailHelper = require('#helpers/email');
22+
const logger = require('#helpers/logger');
23+
const syncStripePaymentIntent = require('#helpers/sync-stripe-payment-intent');
2224

2325
const stripe = new Stripe(env.STRIPE_SECRET_KEY);
2426
const { STRIPE_PRODUCTS } = config.payments;
@@ -30,6 +32,107 @@ async function processEvent(ctx, event) {
3032
// <https://stripe.com/docs/cli/trigger#trigger-event>
3133
//
3234
switch (event.type) {
35+
//
36+
// NOTE: due to unprecedented Stripe credit card fraud (which Stripe has refused to help mitigate)
37+
// we've implemented our own logic here to prevent fraud (user's doing client-side attacks with generated numbers)
38+
// <https://docs.stripe.com/disputes/prevention/card-testing>
39+
//
40+
// prevent fraud by checking for users with 5+ failed charges in < 30 days
41+
// with zero verified domains on their account and/or unverified email address
42+
// ban user and notify admins, and refund all other charges from them
43+
//
44+
case 'charge.failed': {
45+
// exit early if it wasn't a charge failure
46+
if (event?.data?.object?.object !== 'charge') break;
47+
if (typeof event?.data?.object?.customer !== 'string')
48+
throw new Error('Charge did not have customer');
49+
const user = await Users.findOne({
50+
[config.userFields.stripeCustomerID]: event.data.object.customer
51+
});
52+
if (!user) throw new Error('User did not exist for customer');
53+
// <https://docs.stripe.com/api/charges/list>
54+
const charges = await stripe.charges.list({
55+
customer: event.data.object.customer,
56+
created: {
57+
gte: dayjs().subtract(1, 'month').unix() // only search last 30 days to prevent false positives
58+
}
59+
});
60+
61+
const filtered = charges.data.filter(
62+
(d) => d.status === 'failed' && d.failure_code === 'card_declined'
63+
);
64+
65+
// if not more than 5 then return early
66+
if (filtered.length < 5) break;
67+
68+
// TODO: we may want to use payment methods count here too instead of just failed charges
69+
// (see `jobs/stripe/fraud-check.js` which uses this approach on a recurring basis)
70+
71+
// if user had verified domains then alert admins
72+
// otherwise ban the user and refund all their payments
73+
const count = await Domains.countDocuments({
74+
members: {
75+
$elemMatch: {
76+
user: user._id,
77+
group: 'admin'
78+
}
79+
},
80+
plan: { $in: ['enhanced_protection', 'team'] },
81+
has_txt_record: true
82+
});
83+
84+
const subject = `${user.email} - ${event.data.object.customer} - ${filtered.length} declined charges and ${count} verified domains`;
85+
86+
emailHelper({
87+
template: 'alert',
88+
message: {
89+
to: config.email.message.from,
90+
subject: `${
91+
count > 0
92+
? 'Potential Fraud to Investigate'
93+
: 'Banned User for Fraud Alert'
94+
}: ${subject}`
95+
},
96+
locals: {
97+
message: `<p><a href="https://dashboard.stripe.com/customers/${event.data.object.customer}" class="btn btn-dark btn-lg" target="_blank" rel="noopener noreferrer">Review Stripe Customer</a></p>`
98+
}
99+
})
100+
.then()
101+
.catch((err) => logger.fatal(err));
102+
103+
if (count === 0) {
104+
user.is_banned = true;
105+
await user.save();
106+
107+
const [charges, subscriptions] = await Promise.all([
108+
stripe.charges.list({
109+
customer: event.data.object.customer
110+
}),
111+
stripe.subscriptions.list({
112+
customer: event.data.object.customer
113+
})
114+
]);
115+
116+
// refund all payments as fraudulent
117+
if (charges?.data?.length > 0)
118+
await pMapSeries(charges.data, async (charge) => {
119+
if (charge.status !== 'succeeded' || charge.paid !== true) return;
120+
await stripe.refunds.create({
121+
charge: charge.id
122+
});
123+
});
124+
125+
// cancel all subscriptions
126+
if (subscriptions?.data?.length > 0)
127+
await pMapSeries(subscriptions.data, async (subscription) => {
128+
if (subscription.status !== 'canceled') return;
129+
await stripe.subscriptions.cancel(subscription.id);
130+
});
131+
}
132+
133+
break;
134+
}
135+
33136
// create or update existing payment
34137
// (we may also want to upgrade plan; e.g. in case redirect does not occur)
35138
// (also need to ensure no conflicts with redirect)
@@ -387,18 +490,37 @@ async function processEvent(ctx, event) {
387490
// event.data.object is a subscription object
388491
if (event.data.object.object !== 'subscription')
389492
throw new Error('Event object was not a subscription');
390-
const subscription = event.data.object;
391-
if (['active', 'trialing'].includes(subscription.status))
493+
if (['active', 'trialing'].includes(event.data.object.status))
392494
await Users.findOneAndUpdate(
393495
{
394-
[config.userFields.stripeCustomerID]: subscription.customer
496+
[config.userFields.stripeCustomerID]: event.data.object.customer
395497
},
396498
{
397499
$set: {
398-
[config.userFields.stripeSubscriptionID]: subscription.id
500+
[config.userFields.stripeSubscriptionID]: event.data.object.id
399501
}
400502
}
401503
);
504+
// if user had more than one subscription then notify admins by email
505+
const subscriptions = await stripe.subscriptions.list({
506+
customer: event.data.object.customer
507+
});
508+
const filtered = subscriptions.filter((s) => s.status !== 'canceled');
509+
if (filtered.length > 1) {
510+
emailHelper({
511+
template: 'alert',
512+
message: {
513+
to: config.email.message.from,
514+
subject: `Multiple Subscriptions Detected: ${event.data.object.customer}`
515+
},
516+
locals: {
517+
message: `<p><a href="https://dashboard.stripe.com/customers/${event.data.object.customer}" class="btn btn-dark btn-lg" target="_blank" rel="noopener noreferrer">Review Stripe Customer</a></p>`
518+
}
519+
})
520+
.then()
521+
.catch((err) => logger.fatal(err));
522+
}
523+
402524
break;
403525
}
404526

‎app/models/users.js‎

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -490,10 +490,6 @@ Users.pre('validate', async function (next) {
490490
Users.pre('save', async function (next) {
491491
const user = this;
492492

493-
// arbitrary block due to stripe spam unresolved in november 2024
494-
if (typeof user.email === 'string' && user.email.startsWith('hbrzi'))
495-
return next(new Error('Try again later'));
496-
497493
// If user has a paid plan then consider their email verified
498494
if (user.plan !== 'free') user[config.userFields.hasVerifiedEmail] = true;
499495

‎helpers/get-bounce-info.js‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ function getBounceInfo(err) {
4747
if (
4848
bounceInfo.message === 'Unknown' ||
4949
(bounceInfo.action === 'reject' &&
50-
['blocklist', 'policy', 'message', 'block', 'other'].includes(
50+
['blocklist', 'envelope', 'policy', 'message', 'block', 'other'].includes(
5151
bounceInfo.category
5252
))
5353
) {
@@ -68,7 +68,9 @@ function getBounceInfo(err) {
6868
// <https://learn.microsoft.com/en-us/exchange/troubleshoot/email-delivery/send-receive-emails-socketerror>
6969
}
7070

71-
if (response.includes('Connection dropped due to SocketError')) {
71+
if (response.includes('Comcast block for spam')) {
72+
bounceInfo.category = 'blocklist';
73+
} else if (response.includes('Connection dropped due to SocketError')) {
7274
// modify message to include URL for debugging
7375
err.message +=
7476
' ; Resolve this issue by visiting https://learn.microsoft.com/en-us/exchange/troubleshoot/email-delivery/send-receive-emails-socketerror#cause ;';

‎helpers/parse-payload.js‎

Lines changed: 79 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ const isFQDN = require('is-fqdn');
2222
const isSANB = require('is-string-and-not-blank');
2323
const mongoose = require('mongoose');
2424
const ms = require('ms');
25-
const pEvent = require('p-event');
25+
// const pEvent = require('p-event');
2626
const pMap = require('p-map');
2727
const parseErr = require('parse-err');
2828
const pify = require('pify');
@@ -889,6 +889,81 @@ async function parsePayload(data, ws) {
889889
)} was available`
890890
);
891891

892+
// we should only use in-memory database is if was connected (IMAP session open)
893+
if (
894+
this.databaseMap &&
895+
this.databaseMap.has(session.user.alias_id) &&
896+
this.databaseMap.get(session.user.alias_id).open === true
897+
)
898+
session.db = this.databaseMap.get(session.user.alias_id);
899+
900+
if (session.db) {
901+
try {
902+
// since we use onAppend it re-uses addEntries
903+
// which notifies all connected imap users via EXISTS
904+
await onAppendPromise.call(
905+
this,
906+
'INBOX',
907+
[],
908+
_.isDate(payload.date)
909+
? payload.date
910+
: new Date(payload.date),
911+
payload.raw,
912+
{
913+
user: {
914+
...session.user
915+
// NOTE: we don't have the password since we're using in-memory mapping
916+
// password: user.password
917+
},
918+
db: session.db,
919+
remoteAddress: payload.remoteAddress,
920+
resolvedRootClientHostname:
921+
payload.resolvedRootClientHostname,
922+
resolvedClientHostname: payload.resolvedClientHostname,
923+
allowlistValue: payload.allowlistValue,
924+
925+
// don't emit wss.broadcast
926+
selected: false,
927+
928+
// don't append duplicate messages
929+
checkForExisting: true
930+
}
931+
);
932+
933+
//
934+
// increase rate limiting size and count
935+
//
936+
try {
937+
await increaseRateLimiting(
938+
this.client,
939+
date,
940+
sender,
941+
root,
942+
byteLength
943+
);
944+
} catch (err) {
945+
err.isCodeBug = true;
946+
err.payload = _.omit(payload, 'raw');
947+
logger.fatal(err);
948+
}
949+
} catch (_err) {
950+
// in order to ensure tmp write still occurs
951+
delete session.db;
952+
953+
const err = Array.isArray(_err) ? _err[0] : _err;
954+
if (isRetryableError(err)) {
955+
err.isCodeBug = true;
956+
err.payload = _.omit(payload, 'raw');
957+
logger.error(err);
958+
} else {
959+
err.isCodeBug = true;
960+
err.payload = _.omit(payload, 'raw');
961+
logger.error(err);
962+
}
963+
}
964+
}
965+
966+
/*
892967
//
893968
// attempt to get in-memory password from IMAP servers
894969
//
@@ -970,11 +1045,13 @@ async function parsePayload(data, ws) {
9701045
logger.error(err);
9711046
}
9721047
}
1048+
*/
9731049

9741050
//
9751051
// fallback to writing to temporary database storage
9761052
//
977-
if (fallback) {
1053+
// if (fallback)
1054+
if (!session.db) {
9781055
const tmpDb = await getTemporaryDatabase.call(this, session);
9791056

9801057
let err;

‎helpers/worker.js‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -717,14 +717,14 @@ async function backup(payload) {
717717
stream.end();
718718
}
719719

720-
archive.finalize();
721720
archive.on('warning', (err) => {
722721
logger.warn(err);
723722
});
724723
await new Promise((resolve, reject) => {
725724
archive.once('error', reject);
726725
archive.once('end', resolve);
727726
});
727+
archive.finalize();
728728
break;
729729
}
730730

@@ -787,14 +787,14 @@ async function backup(payload) {
787787
}
788788
}
789789

790-
archive.finalize();
791790
archive.on('warning', (err) => {
792791
logger.warn(err);
793792
});
794793
await new Promise((resolve, reject) => {
795794
archive.on('error', reject);
796795
archive.on('end', resolve);
797796
});
797+
archive.finalize();
798798
break;
799799
}
800800
// No default

‎imap-server.js‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,6 @@ const pRetry = require('p-retry');
2424
const pWaitFor = require('p-wait-for');
2525
const pify = require('pify');
2626
const ms = require('ms');
27-
const safeStringify = require('fast-safe-stringify');
2827
const { IMAPServer } = require('wildduck/imap-core');
2928

3029
const Aliases = require('#models/aliases');
@@ -270,7 +269,7 @@ class IMAP {
270269

271270
this.subscriber.on('message', async (channel, id) => {
272271
if (
273-
channel !== 'sqlite_auth_request' &&
272+
// channel !== 'sqlite_auth_request' &&
274273
channel !== 'sqlite_auth_reset' &&
275274
channel !== 'pgp_reload'
276275
)
@@ -290,6 +289,7 @@ class IMAP {
290289
return;
291290
}
292291

292+
/*
293293
if (channel === 'sqlite_auth_request') {
294294
for (const connection of this.server.connections) {
295295
if (connection?.session?.user?.alias_id === id) {
@@ -304,6 +304,7 @@ class IMAP {
304304
305305
return;
306306
}
307+
*/
307308

308309
if (channel === 'pgp_reload') {
309310
const alias = await Aliases.findOne({ id })
@@ -374,13 +375,13 @@ class IMAP {
374375
}
375376

376377
async listen(port = env.IMAP_PORT, host = '::', ...args) {
377-
this.subscriber.subscribe('sqlite_auth_request');
378+
// this.subscriber.subscribe('sqlite_auth_request');
378379
this.subscriber.subscribe('sqlite_auth_reset');
379380
await pify(this.server.listen).bind(this.server)(port, host, ...args);
380381
}
381382

382383
async close() {
383-
this.subscriber.unsubscribe('sqlite_auth_request');
384+
// this.subscriber.unsubscribe('sqlite_auth_request');
384385
this.subscriber.unsubscribe('sqlite_auth_reset');
385386
await pify(this.server.close).bind(this.server)();
386387
}

0 commit comments

Comments
 (0)