Sign-up deny list beside the allow list
fraud-allow.json says "never block this person". There was nothing for the opposite case. fraud-block.json: an address on it cannot open an account through /api/signup or the email-code door, and the admin fraud report carries the list with a matching /api/admin/fraud/block route. Existing accounts are untouched; Suspend covers those. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -55,6 +55,22 @@ function allowAdd(email, note, by) {
|
|||||||
}
|
}
|
||||||
function allowRemove(email) { const e = norm(email); const d = allowLoad(); if (d[e]) { delete d[e]; allowSave(); } return { ok: true, list: allowList() }; }
|
function allowRemove(email) { const e = norm(email); const d = allowLoad(); if (d[e]) { delete d[e]; allowSave(); } return { ok: true, list: allowList() }; }
|
||||||
|
|
||||||
|
// The opposite list. An address here can never open an account, through any door. Same
|
||||||
|
// shape and file pattern as the allow list so it behaves identically in DB and JSON mode.
|
||||||
|
const BLOCK_FILE = () => path.join(DATA_DIR, 'fraud-block.json');
|
||||||
|
let blockDb = null; // { "<email>": { note, at, by } }
|
||||||
|
function blockLoad() { if (blockDb) return blockDb; try { blockDb = JSON.parse(fs.readFileSync(BLOCK_FILE(), 'utf8')); } catch (e) { blockDb = {}; } if (!blockDb || typeof blockDb !== 'object') blockDb = {}; return blockDb; }
|
||||||
|
function blockSave() { try { fs.writeFileSync(BLOCK_FILE(), JSON.stringify(blockLoad())); } catch (e) { console.error('fraud block save', e.message); } }
|
||||||
|
function isBlocked(email) { const e = norm(email); return !!(e && blockLoad()[e]); }
|
||||||
|
function blockList() { const d = blockLoad(); return Object.keys(d).sort().map(e => ({ email: e, note: d[e].note || '', at: d[e].at || 0, by: d[e].by || '' })); }
|
||||||
|
function blockAdd(email, note, by) {
|
||||||
|
const e = norm(email); if (!e || !e.includes('@')) return { error: 'Enter an email address.' };
|
||||||
|
blockLoad()[e] = { note: String(note || '').slice(0, 200), at: Date.now(), by: String(by || '').slice(0, 120) };
|
||||||
|
blockSave();
|
||||||
|
return { ok: true, list: blockList() };
|
||||||
|
}
|
||||||
|
function blockRemove(email) { const e = norm(email); const d = blockLoad(); if (d[e]) { delete d[e]; blockSave(); } return { ok: true, list: blockList() }; }
|
||||||
|
|
||||||
async function init(opts) {
|
async function init(opts) {
|
||||||
DATA_DIR = opts.dataDir;
|
DATA_DIR = opts.dataDir;
|
||||||
if (db.enabled()) {
|
if (db.enabled()) {
|
||||||
@@ -199,4 +215,4 @@ async function report() {
|
|||||||
function mask(e) { return String(e || '').replace(/^(.{2}).*(@.*)$/, '$1***$2'); }
|
function mask(e) { return String(e || '').replace(/^(.{2}).*(@.*)$/, '$1***$2'); }
|
||||||
|
|
||||||
module.exports = { init, checkSignup, recordSignup, recordSeen, addFlags, clearFlags, suspend, unsuspend, isSuspended, excluded, report, get, deviceOf, hasAccountOnDevice, newDeviceId, deviceCookie, ipOf, HARD,
|
module.exports = { init, checkSignup, recordSignup, recordSeen, addFlags, clearFlags, suspend, unsuspend, isSuspended, excluded, report, get, deviceOf, hasAccountOnDevice, newDeviceId, deviceCookie, ipOf, HARD,
|
||||||
isAllowed, allowList, allowAdd, allowRemove };
|
isAllowed, allowList, allowAdd, allowRemove, isBlocked, blockList, blockAdd, blockRemove };
|
||||||
|
|||||||
@@ -1214,6 +1214,7 @@ const server = http.createServer(async (req, res) => {
|
|||||||
// out only at purchase / payout-activation time and gets linked then)
|
// out only at purchase / payout-activation time and gets linked then)
|
||||||
if (p === '/api/signup' && req.method === 'POST') {
|
if (p === '/api/signup' && req.method === 'POST') {
|
||||||
const b = await readBody(req);
|
const b = await readBody(req);
|
||||||
|
if (fraud.isBlocked(b.email)) return json(res, 403, { error: 'This address cannot open an account.' });
|
||||||
const ref = parseCookies(req)['iap.sponsor'] || ''; // last-touch attribution, locked at account creation
|
const ref = parseCookies(req)['iap.sponsor'] || ''; // last-touch attribution, locked at account creation
|
||||||
const r = await accounts.signup(b.email, b.password, ref);
|
const r = await accounts.signup(b.email, b.password, ref);
|
||||||
if (r.error) return json(res, 400, r);
|
if (r.error) return json(res, 400, r);
|
||||||
@@ -1239,6 +1240,7 @@ const server = http.createServer(async (req, res) => {
|
|||||||
const e = String(b.email || '').trim().toLowerCase();
|
const e = String(b.email || '').trim().toLowerCase();
|
||||||
const devHdr = fraud.deviceOf(req) ? undefined : { 'Set-Cookie': fraud.deviceCookie(fraud.newDeviceId(), IS_PROD) }; // browser id for one-account-per-person checks
|
const devHdr = fraud.deviceOf(req) ? undefined : { 'Set-Cookie': fraud.deviceCookie(fraud.newDeviceId(), IS_PROD) }; // browser id for one-account-per-person checks
|
||||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(e)) return json(res, 400, { error: 'That email address does not look right.' });
|
if (!/^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(e)) return json(res, 400, { error: 'That email address does not look right.' });
|
||||||
|
if (fraud.isBlocked(e)) return json(res, 403, { error: 'This address cannot open an account.' });
|
||||||
const prev = emailCodes.get(e);
|
const prev = emailCodes.get(e);
|
||||||
if (prev && Date.now() < prev.nextAt) { console.log('signup-guard cooldown', clientIp(req), e.replace(/^(.).*(@.*)$/, '$1***$2')); return json(res, 429, { error: 'Code already sent. Give it a minute, then try again.' }, devHdr); }
|
if (prev && Date.now() < prev.nextAt) { console.log('signup-guard cooldown', clientIp(req), e.replace(/^(.).*(@.*)$/, '$1***$2')); return json(res, 429, { error: 'Code already sent. Give it a minute, then try again.' }, devHdr); }
|
||||||
const guard = codeGuard(req, b); // honeypot, form age, per-IP + global limits, icon check once limited
|
const guard = codeGuard(req, b); // honeypot, form age, per-IP + global limits, icon check once limited
|
||||||
@@ -2817,11 +2819,16 @@ const server = http.createServer(async (req, res) => {
|
|||||||
}
|
}
|
||||||
if (p === '/api/admin/fraud' && req.method === 'GET') { // duplicate signals: shared browsers / IPs, flagged and suspended accounts
|
if (p === '/api/admin/fraud' && req.method === 'GET') { // duplicate signals: shared browsers / IPs, flagged and suspended accounts
|
||||||
if (!isAdmin(req)) return json(res, 401, { error: 'auth' });
|
if (!isAdmin(req)) return json(res, 401, { error: 'auth' });
|
||||||
return json(res, 200, Object.assign(await fraud.report(), { allow: fraud.allowList() }));
|
return json(res, 200, Object.assign(await fraud.report(), { allow: fraud.allowList(), block: fraud.blockList() }));
|
||||||
}
|
}
|
||||||
// Approved exceptions: people Marty has okayed to hold more than one account. Adding an
|
// Approved exceptions: people Marty has okayed to hold more than one account. Adding an
|
||||||
// address here stops the duplicate checks blocking or hard-flagging them, and stops them
|
// address here stops the duplicate checks blocking or hard-flagging them, and stops them
|
||||||
// being dropped from the leaderboard or barred from adopting.
|
// being dropped from the leaderboard or barred from adopting.
|
||||||
|
if (p === '/api/admin/fraud/block' && req.method === 'POST') {
|
||||||
|
const b = await readBody(req);
|
||||||
|
const r = b.remove ? fraud.blockRemove(b.email) : fraud.blockAdd(b.email, b.note, ADMIN_EMAIL || 'admin');
|
||||||
|
return json(res, r.error ? 400 : 200, r);
|
||||||
|
}
|
||||||
if (p === '/api/admin/fraud/allow' && req.method === 'POST') {
|
if (p === '/api/admin/fraud/allow' && req.method === 'POST') {
|
||||||
if (!isAdmin(req)) return json(res, 401, { error: 'auth' });
|
if (!isAdmin(req)) return json(res, 401, { error: 'auth' });
|
||||||
const b = await readBody(req);
|
const b = await readBody(req);
|
||||||
|
|||||||
Reference in New Issue
Block a user