// Safe Browsing screen for advertiser destinations (Marty, 2026-09-24).
//
// Why this exists: on 24 September Google marked instantadpay.com "some pages on this site are
// unsafe" (deceptive pages). The probable cause was campaign #225, a verified-visits ad that
// opened llclickpro.com from our pages, and llclickpro.com carries exactly that flag. We put
// other people's destinations in front of members, so their reputation becomes ours. This
// screens every destination when a campaign is saved and re-screens the live ones daily.
//
// Two sources, the better one wins:
// 1. Google Safe Browsing Lookup API v4, when SAFE_BROWSING_KEY is set: full-URL, exact,
// batched, no rate-limit trouble. The right tool; needs a Google Cloud API key.
// 2. The Transparency Report site-status endpoint: no key, but site-level only and it
// rate-limits by answering an HTML page instead of JSON. Used until a key exists.
//
// Verdicts are three-valued on purpose: flagged, clean, or UNKNOWN. A guard that reads
// "could not check" as "clean" is the failure mode this whole day has been about; one that
// reads it as "flagged" would refuse every advertiser whenever Google rate-limits us. So:
// flagged refuses at save and pauses in the sweep; clean passes; unknown passes at save time,
// is logged, and is retried by the sweep until it resolves. Nothing is silently treated as fine.
'use strict';
const https = require('https');
let X = null;
const STATE = () => X.path.join(X.dataDir, 'sbcheck.json');
function load() { try { return JSON.parse(X.fs.readFileSync(STATE(), 'utf8')); } catch (e) { return { cache: {}, lastFlagged: [] }; } }
function save(d) { try { X.fs.writeFileSync(STATE(), JSON.stringify(d)); } catch (e) {} }
const CACHE_MS = 12 * 60 * 60 * 1000; // a conclusive verdict is good for half a day; the sweep refreshes live hosts anyway
const THREATS = ['MALWARE', 'SOCIAL_ENGINEERING', 'UNWANTED_SOFTWARE', 'POTENTIALLY_HARMFUL_APPLICATION'];
const PACE_MS = 4000; // spacing between site-status calls; faster than this and Google answers HTML
function init(deps) { X = deps; }
function hostOf(url) { try { return new URL(String(url)).hostname.replace(/^www\./, '').toLowerCase(); } catch (e) { return ''; } }
function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
function fetchText(url, opts) {
opts = opts || {};
return new Promise((resolve, reject) => {
const u = new URL(url);
const req = https.request({ hostname: u.hostname, path: u.pathname + u.search, method: opts.method || 'GET',
headers: Object.assign({ 'User-Agent': 'InstantAdPay-sbcheck/1.0' }, opts.headers || {}), timeout: 15000 }, res => {
let body = ''; res.setEncoding('utf8'); res.on('data', d => { body += d; }); res.on('end', () => resolve({ status: res.statusCode, body }));
});
req.on('timeout', () => { req.destroy(new Error('timeout')); });
req.on('error', reject);
if (opts.body) req.write(opts.body);
req.end();
});
}
// --- source 1: the official Lookup API. Returns null when there is no key. ---
async function lookupV4(urls) {
const key = String(process.env.SAFE_BROWSING_KEY || '').trim();
if (!key) return null;
const body = JSON.stringify({ client: { clientId: 'instantadpay', clientVersion: '1.0' },
threatInfo: { threatTypes: THREATS, platformTypes: ['ANY_PLATFORM'], threatEntryTypes: ['URL'], threatEntries: urls.map(u => ({ url: u })) } });
const r = await fetchText('https://safebrowsing.googleapis.com/v4/threatMatches:find?key=' + encodeURIComponent(key),
{ method: 'POST', headers: { 'Content-Type': 'application/json' }, body });
if (r.status !== 200) throw new Error('lookup api http ' + r.status);
const j = JSON.parse(r.body || '{}');
const bad = {};
for (const m of (j.matches || [])) { const u = m.threat && m.threat.url; if (u) bad[u] = m.threatType; }
return urls.map(u => ({ url: u, verdict: bad[u] ? 'flagged' : 'clean', threat: bad[u] || null }));
}
// --- source 2: the Transparency Report, site level, no key. ---
async function siteStatus(host) {
const r = await fetchText('https://transparencyreport.google.com/transparencyreport/api/v3/safebrowsing/status?site=' + encodeURIComponent(host),
{ headers: { 'User-Agent': 'Mozilla/5.0 (compatible; InstantAdPay-sbcheck/1.0)' } });
const line = String(r.body || '').split('\n').find(l => l.startsWith('[["sb.ssr"'));
if (!line) return { verdict: 'unknown', why: 'no answer (rate-limited or the endpoint changed)' };
let arr; try { arr = JSON.parse(line)[0]; } catch (e) { return { verdict: 'unknown', why: 'unparseable answer' }; }
// ["sb.ssr", status, malware, unwantedSoftware, socialEngineering, ?, ?, updatedAt, site, ?]
// status 1 = "No unsafe content found"; 3 with a true flag = "Some pages on this site are unsafe";
// anything else (4, 6...) = no data. Decoded against testsafebrowsing.appspot.com (3,true,true,true)
// and instantadpay.com itself on 2026-09-24 (3,false,false,true = deceptive pages).
const flags = { malware: !!arr[2], 'unwanted software': !!arr[3], 'social engineering': !!arr[4] };
const named = Object.keys(flags).filter(k => flags[k]);
if (named.length) return { verdict: 'flagged', threat: named.join(', ') };
if (arr[1] === 1) return { verdict: 'clean' };
return { verdict: 'unknown', why: 'no data (status ' + arr[1] + ')' };
}
function verdictFor(host, v) {
if (v.verdict === 'flagged') {
return { ok: false, verdict: 'flagged', host, threat: v.threat,
reason: 'Google Safe Browsing currently lists ' + host + ' as unsafe (' + (v.threat || 'harmful content') + '). '
+ 'We cannot send members there: choose a different destination, or get the listing cleared with Google first.' };
}
return { ok: true, verdict: v.verdict, host, why: v.why || null };
}
// Verdict for one destination, used at save time. Cached per host while conclusive.
async function check(url) {
const host = hostOf(url);
if (!host || (X && host === X.selfHost)) return { ok: true, verdict: 'skipped', host };
const st = load();
const c = st.cache[host];
if (c && c.verdict !== 'unknown' && Date.now() - c.at < CACHE_MS) return verdictFor(host, c);
let v;
try {
const v4 = await lookupV4([String(url)]);
v = v4 ? { verdict: v4[0].verdict, threat: v4[0].threat, via: 'lookup' } : Object.assign(await siteStatus(host), { via: 'site-status' });
} catch (e) { v = { verdict: 'unknown', why: e.message, via: 'error' }; }
v.at = Date.now();
st.cache[host] = v; save(st);
if (v.verdict === 'unknown') console.log('sbcheck: could not screen ' + host + ' at save time (' + v.why + '); the sweep will retry');
return verdictFor(host, v);
}
// One pass over every live campaign. Pauses what is flagged, names what could not be checked.
async function run(opts) {
const dry = !!(opts && opts.dry);
if (!X || !X.db || !X.db.enabled()) return { error: 'no database' };
const rows = await X.db.q("SELECT id, type, owner_email, name, target_url, image_url, status FROM campaigns WHERE status='active'");
const byHost = {};
for (const c of rows) for (const u of [c.target_url, c.image_url]) {
const h = hostOf(u); if (!h || h === X.selfHost) continue;
const e = byHost[h] || (byHost[h] = { urls: new Set(), camps: [] });
e.urls.add(String(u)); if (!e.camps.some(x => x.id === c.id)) e.camps.push({ id: c.id, type: c.type, name: c.name, owner: c.owner_email });
}
const hosts = Object.keys(byHost);
const out = { hosts: hosts.length, flagged: [], unknown: [], paused: [], failed: [] };
const st = load();
const verdicts = {};
if (String(process.env.SAFE_BROWSING_KEY || '').trim()) {
try {
const all = []; for (const h of hosts) for (const u of byHost[h].urls) all.push(u);
for (let i = 0; i < all.length; i += 400) {
for (const r of await lookupV4(all.slice(i, i + 400))) {
const h = hostOf(r.url);
if (r.verdict === 'flagged' || !verdicts[h]) verdicts[h] = { verdict: r.verdict, threat: r.threat, via: 'lookup' };
}
}
} catch (e) { for (const h of hosts) verdicts[h] = { verdict: 'unknown', why: e.message, via: 'error' }; }
} else {
for (const h of hosts) {
const c = st.cache[h];
if (c && c.verdict !== 'unknown' && Date.now() - c.at < CACHE_MS) { verdicts[h] = c; continue; }
try { verdicts[h] = Object.assign(await siteStatus(h), { via: 'site-status' }); }
catch (e) { verdicts[h] = { verdict: 'unknown', why: e.message, via: 'error' }; }
await sleep(PACE_MS);
}
}
for (const h of hosts) {
const v = verdicts[h]; v.at = Date.now(); st.cache[h] = v;
if (v.verdict === 'flagged') out.flagged.push({ host: h, threat: v.threat, campaigns: byHost[h].camps });
else if (v.verdict === 'unknown') out.unknown.push({ host: h, why: v.why });
}
if (!dry) {
for (const f of out.flagged) for (const c of f.campaigns) {
try { await X.db.q("UPDATE campaigns SET status='paused' WHERE id=? AND status='active'", [c.id]); out.paused.push(c.id); }
catch (e) {
// never swallow this: the guard exists so a flagged destination cannot keep serving
out.failed.push({ id: c.id, error: e.message });
console.error('sbcheck could not pause #' + c.id + ': ' + e.message);
}
}
st.at = Date.now(); st.lastFlagged = out.flagged.map(f => f.host).sort(); save(st);
if (out.paused.length) {
const lines = out.flagged.map(f => '' + f.host + ' (' + f.threat + ')\n'
+ f.campaigns.map(c => ' #' + c.id + ' ' + c.type + ' "' + c.name + '" (' + c.owner + ')').join('\n'));
console.log('sbcheck paused ' + out.paused.length + ' campaign(s) on flagged hosts: ' + out.paused.join(', '));
if (X.alert) {
X.alert('\u{1F6D1} Advertiser destinations Google lists as unsafe\n\n' + lines.join('\n')
+ '\n\nPaused, so our pages stop sending members there and the site’s own Safe Browsing standing is not dragged down again. '
+ 'Credits stay with the advertiser; they can point the campaign somewhere else.').catch(() => {});
}
}
if (out.unknown.length) console.log('sbcheck: ' + out.unknown.length + ' host(s) could not be screened this pass: ' + out.unknown.map(u => u.host + ' (' + u.why + ')').join('; '));
}
return out;
}
// Daily. The first pass runs a couple of minutes after boot so a restart re-screens promptly.
function start() {
const tick = () => { run({}).catch(e => console.error('sbcheck', e.message)); };
setTimeout(tick, 150000);
setInterval(tick, 24 * 60 * 60 * 1000);
}
module.exports = { init, check, run, start, hostOf };