Skip to content

Commit

Permalink
fix: added stripe fraud prevention, fixed comcast/envelope spam detec…
Browse files Browse the repository at this point in the history
…tion, 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
  • Loading branch information
titanism committed Nov 26, 2024
1 parent 07011cf commit 21b4d97
Show file tree
Hide file tree
Showing 13 changed files with 365 additions and 43 deletions.
132 changes: 127 additions & 5 deletions app/controllers/api/v1/stripe.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,15 @@ const humanize = require('humanize-string');
const isSANB = require('is-string-and-not-blank');
const ms = require('ms');
const parseErr = require('parse-err');
const pMapSeries = require('p-map-series');
const titleize = require('titleize');

const { Users, Domains } = require('#models');
const config = require('#config');
const env = require('#config/env');
const syncStripePaymentIntent = require('#helpers/sync-stripe-payment-intent');
const emailHelper = require('#helpers/email');
const logger = require('#helpers/logger');
const syncStripePaymentIntent = require('#helpers/sync-stripe-payment-intent');

const stripe = new Stripe(env.STRIPE_SECRET_KEY);
const { STRIPE_PRODUCTS } = config.payments;
Expand All @@ -30,6 +32,107 @@ async function processEvent(ctx, event) {
// <https://stripe.com/docs/cli/trigger#trigger-event>
//
switch (event.type) {
//
// NOTE: due to unprecedented Stripe credit card fraud (which Stripe has refused to help mitigate)
// we've implemented our own logic here to prevent fraud (user's doing client-side attacks with generated numbers)
// <https://docs.stripe.com/disputes/prevention/card-testing>
//
// prevent fraud by checking for users with 5+ failed charges in < 30 days
// with zero verified domains on their account and/or unverified email address
// ban user and notify admins, and refund all other charges from them
//
case 'charge.failed': {
// exit early if it wasn't a charge failure
if (event?.data?.object?.object !== 'charge') break;
if (typeof event?.data?.object?.customer !== 'string')
throw new Error('Charge did not have customer');
const user = await Users.findOne({
[config.userFields.stripeCustomerID]: event.data.object.customer
});
if (!user) throw new Error('User did not exist for customer');
// <https://docs.stripe.com/api/charges/list>
const charges = await stripe.charges.list({
customer: event.data.object.customer,
created: {
gte: dayjs().subtract(1, 'month').unix() // only search last 30 days to prevent false positives
}
});

const filtered = charges.data.filter(
(d) => d.status === 'failed' && d.failure_code === 'card_declined'
);

// if not more than 5 then return early
if (filtered.length < 5) break;

// TODO: we may want to use payment methods count here too instead of just failed charges
// (see `jobs/stripe/fraud-check.js` which uses this approach on a recurring basis)

// if user had verified domains then alert admins
// otherwise ban the user and refund all their payments
const count = await Domains.countDocuments({
members: {
$elemMatch: {
user: user._id,
group: 'admin'
}
},
plan: { $in: ['enhanced_protection', 'team'] },
has_txt_record: true
});

const subject = `${user.email} - ${event.data.object.customer} - ${filtered.length} declined charges and ${count} verified domains`;

emailHelper({
template: 'alert',
message: {
to: config.email.message.from,
subject: `${
count > 0
? 'Potential Fraud to Investigate'
: 'Banned User for Fraud Alert'
}: ${subject}`
},
locals: {
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>`
}
})
.then()
.catch((err) => logger.fatal(err));

if (count === 0) {
user.is_banned = true;
await user.save();

const [charges, subscriptions] = await Promise.all([
stripe.charges.list({
customer: event.data.object.customer
}),
stripe.subscriptions.list({
customer: event.data.object.customer
})
]);

// refund all payments as fraudulent
if (charges?.data?.length > 0)
await pMapSeries(charges.data, async (charge) => {
if (charge.status !== 'succeeded' || charge.paid !== true) return;
await stripe.refunds.create({
charge: charge.id
});
});

// cancel all subscriptions
if (subscriptions?.data?.length > 0)
await pMapSeries(subscriptions.data, async (subscription) => {
if (subscription.status !== 'canceled') return;
await stripe.subscriptions.cancel(subscription.id);
});
}

break;
}

// create or update existing payment
// (we may also want to upgrade plan; e.g. in case redirect does not occur)
// (also need to ensure no conflicts with redirect)
Expand Down Expand Up @@ -387,18 +490,37 @@ async function processEvent(ctx, event) {
// event.data.object is a subscription object
if (event.data.object.object !== 'subscription')
throw new Error('Event object was not a subscription');
const subscription = event.data.object;
if (['active', 'trialing'].includes(subscription.status))
if (['active', 'trialing'].includes(event.data.object.status))
await Users.findOneAndUpdate(
{
[config.userFields.stripeCustomerID]: subscription.customer
[config.userFields.stripeCustomerID]: event.data.object.customer
},
{
$set: {
[config.userFields.stripeSubscriptionID]: subscription.id
[config.userFields.stripeSubscriptionID]: event.data.object.id
}
}
);
// if user had more than one subscription then notify admins by email
const subscriptions = await stripe.subscriptions.list({
customer: event.data.object.customer
});
const filtered = subscriptions.filter((s) => s.status !== 'canceled');
if (filtered.length > 1) {
emailHelper({
template: 'alert',
message: {
to: config.email.message.from,
subject: `Multiple Subscriptions Detected: ${event.data.object.customer}`
},
locals: {
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>`
}
})
.then()
.catch((err) => logger.fatal(err));
}

break;
}

Expand Down
4 changes: 0 additions & 4 deletions app/models/users.js
Original file line number Diff line number Diff line change
Expand Up @@ -490,10 +490,6 @@ Users.pre('validate', async function (next) {
Users.pre('save', async function (next) {
const user = this;

// arbitrary block due to stripe spam unresolved in november 2024
if (typeof user.email === 'string' && user.email.startsWith('hbrzi'))
return next(new Error('Try again later'));

// If user has a paid plan then consider their email verified
if (user.plan !== 'free') user[config.userFields.hasVerifiedEmail] = true;

Expand Down
6 changes: 4 additions & 2 deletions helpers/get-bounce-info.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ function getBounceInfo(err) {
if (
bounceInfo.message === 'Unknown' ||
(bounceInfo.action === 'reject' &&
['blocklist', 'policy', 'message', 'block', 'other'].includes(
['blocklist', 'envelope', 'policy', 'message', 'block', 'other'].includes(
bounceInfo.category
))
) {
Expand All @@ -68,7 +68,9 @@ function getBounceInfo(err) {
// <https://learn.microsoft.com/en-us/exchange/troubleshoot/email-delivery/send-receive-emails-socketerror>
}

if (response.includes('Connection dropped due to SocketError')) {
if (response.includes('Comcast block for spam')) {
bounceInfo.category = 'blocklist';
} else if (response.includes('Connection dropped due to SocketError')) {
// modify message to include URL for debugging
err.message +=
' ; Resolve this issue by visiting https://learn.microsoft.com/en-us/exchange/troubleshoot/email-delivery/send-receive-emails-socketerror#cause ;';
Expand Down
81 changes: 79 additions & 2 deletions helpers/parse-payload.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ const isFQDN = require('is-fqdn');
const isSANB = require('is-string-and-not-blank');
const mongoose = require('mongoose');
const ms = require('ms');
const pEvent = require('p-event');
// const pEvent = require('p-event');
const pMap = require('p-map');
const parseErr = require('parse-err');
const pify = require('pify');
Expand Down Expand Up @@ -889,6 +889,81 @@ async function parsePayload(data, ws) {
)} was available`
);

// we should only use in-memory database is if was connected (IMAP session open)
if (
this.databaseMap &&
this.databaseMap.has(session.user.alias_id) &&
this.databaseMap.get(session.user.alias_id).open === true
)
session.db = this.databaseMap.get(session.user.alias_id);

if (session.db) {
try {
// since we use onAppend it re-uses addEntries
// which notifies all connected imap users via EXISTS
await onAppendPromise.call(
this,
'INBOX',
[],
_.isDate(payload.date)
? payload.date
: new Date(payload.date),
payload.raw,
{
user: {
...session.user
// NOTE: we don't have the password since we're using in-memory mapping
// password: user.password
},
db: session.db,
remoteAddress: payload.remoteAddress,
resolvedRootClientHostname:
payload.resolvedRootClientHostname,
resolvedClientHostname: payload.resolvedClientHostname,
allowlistValue: payload.allowlistValue,

// don't emit wss.broadcast
selected: false,

// don't append duplicate messages
checkForExisting: true
}
);

//
// increase rate limiting size and count
//
try {
await increaseRateLimiting(
this.client,
date,
sender,
root,
byteLength
);
} catch (err) {
err.isCodeBug = true;
err.payload = _.omit(payload, 'raw');
logger.fatal(err);
}
} catch (_err) {
// in order to ensure tmp write still occurs
delete session.db;

const err = Array.isArray(_err) ? _err[0] : _err;
if (isRetryableError(err)) {
err.isCodeBug = true;
err.payload = _.omit(payload, 'raw');
logger.error(err);
} else {
err.isCodeBug = true;
err.payload = _.omit(payload, 'raw');
logger.error(err);
}
}
}

/*
//
// attempt to get in-memory password from IMAP servers
//
Expand Down Expand Up @@ -970,11 +1045,13 @@ async function parsePayload(data, ws) {
logger.error(err);
}
}
*/

//
// fallback to writing to temporary database storage
//
if (fallback) {
// if (fallback)
if (!session.db) {
const tmpDb = await getTemporaryDatabase.call(this, session);

let err;
Expand Down
4 changes: 2 additions & 2 deletions helpers/worker.js
Original file line number Diff line number Diff line change
Expand Up @@ -717,14 +717,14 @@ async function backup(payload) {
stream.end();
}

archive.finalize();
archive.on('warning', (err) => {
logger.warn(err);
});
await new Promise((resolve, reject) => {
archive.once('error', reject);
archive.once('end', resolve);
});
archive.finalize();
break;
}

Expand Down Expand Up @@ -787,14 +787,14 @@ async function backup(payload) {
}
}

archive.finalize();
archive.on('warning', (err) => {
logger.warn(err);
});
await new Promise((resolve, reject) => {
archive.on('error', reject);
archive.on('end', resolve);
});
archive.finalize();
break;
}
// No default
Expand Down
9 changes: 5 additions & 4 deletions imap-server.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@ const pRetry = require('p-retry');
const pWaitFor = require('p-wait-for');
const pify = require('pify');
const ms = require('ms');
const safeStringify = require('fast-safe-stringify');
const { IMAPServer } = require('wildduck/imap-core');

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

this.subscriber.on('message', async (channel, id) => {
if (
channel !== 'sqlite_auth_request' &&
// channel !== 'sqlite_auth_request' &&
channel !== 'sqlite_auth_reset' &&
channel !== 'pgp_reload'
)
Expand All @@ -290,6 +289,7 @@ class IMAP {
return;
}

/*
if (channel === 'sqlite_auth_request') {
for (const connection of this.server.connections) {
if (connection?.session?.user?.alias_id === id) {
Expand All @@ -304,6 +304,7 @@ class IMAP {
return;
}
*/

if (channel === 'pgp_reload') {
const alias = await Aliases.findOne({ id })
Expand Down Expand Up @@ -374,13 +375,13 @@ class IMAP {
}

async listen(port = env.IMAP_PORT, host = '::', ...args) {
this.subscriber.subscribe('sqlite_auth_request');
// this.subscriber.subscribe('sqlite_auth_request');
this.subscriber.subscribe('sqlite_auth_reset');
await pify(this.server.listen).bind(this.server)(port, host, ...args);
}

async close() {
this.subscriber.unsubscribe('sqlite_auth_request');
// this.subscriber.unsubscribe('sqlite_auth_request');
this.subscriber.unsubscribe('sqlite_auth_reset');
await pify(this.server.close).bind(this.server)();
}
Expand Down
Loading

0 comments on commit 21b4d97

Please sign in to comment.