// 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 };