Frame-breaking check at campaign submit: reject XFO/frame-ancestors blockers, SSRF-guarded

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
martbost
2026-09-05 07:48:55 -05:00
parent 15b036b940
commit 5c945a1b09
3 changed files with 58 additions and 0 deletions
+55
View File
@@ -5,6 +5,8 @@
// Chain selection (Amoy rehearsal vs mainnet) lives in data/config.json —
// see chain.js. Wipe the volume's accounts/sessions + flip config = launch.
const http = require('http');
const https = require('https');
const dns = require('dns');
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
@@ -40,6 +42,55 @@ const earnTokens = new Map();
// human-check pairs for the view verifier: [emoji shown, word named in the prompt]
const CAPTCHA = [['🚀', 'rocket'], ['⚡', 'lightning bolt'], ['🔑', 'key'], ['🎯', 'target'],
['🌊', 'wave'], ['🔥', 'flame'], ['💎', 'diamond'], ['🧲', 'magnet'], ['🔔', 'bell'], ['🌙', 'moon']];
// ── frame-breaking check ─────────────────────────────────
// Surf views frame the advertiser's URL full screen, so a target that refuses
// framing (X-Frame-Options / CSP frame-ancestors) would burn members' views on
// a blank frame. Catch it the moment the campaign is submitted. The lookup
// also refuses private/internal addresses so member URLs can't probe our LAN.
const PRIVATE_IP = /^(127\.|10\.|192\.168\.|169\.254\.|0\.|172\.(1[6-9]|2\d|3[01])\.|::1$|::$|f[cd])/i;
function frameFetch(url, depth) {
return new Promise(resolve => {
let u;
try { u = new URL(String(url || '')); } catch (e) { return resolve({ error: 'that is not a valid URL' }); }
if (!/^https?:$/.test(u.protocol)) return resolve({ error: 'only http(s) URLs work' });
if (u.port && u.port !== '80' && u.port !== '443') return resolve({ error: 'custom ports are not allowed' });
if (u.hostname === 'localhost' || u.hostname.endsWith('.local')) return resolve({ error: 'that address is not reachable from here' });
dns.lookup(u.hostname, (de, addr) => {
if (de) return resolve({ error: 'that domain does not resolve' });
if (PRIVATE_IP.test(addr)) return resolve({ error: 'that address is not reachable from here' });
const mod = u.protocol === 'https:' ? https : http;
const rq = mod.get(u.href, { timeout: 8000,
headers: { 'User-Agent': 'Mozilla/5.0 (compatible; InstantAdPay-FrameCheck/1.0)', Accept: 'text/html' } }, r => {
const loc = r.headers.location;
r.resume(); // headers are all we need
if ([301, 302, 303, 307, 308].includes(r.statusCode) && loc && depth < 4) {
rq.destroy();
let next; try { next = new URL(loc, u.href).href; } catch (e2) { return resolve({ error: 'it redirects somewhere invalid' }); }
return resolve(frameFetch(next, depth + 1)); // every hop re-runs the private-IP guard
}
resolve({ status: r.statusCode, xfo: r.headers['x-frame-options'] || '', csp: r.headers['content-security-policy'] || '' });
rq.destroy();
});
rq.on('timeout', () => { rq.destroy(); resolve({ error: 'it did not answer within 8 seconds' }); });
rq.on('error', e2 => resolve({ error: 'it did not answer (' + (e2.code || 'connection failed') + ')' }));
});
});
}
async function frameCheck(url) {
const h = await frameFetch(url, 0);
if (h.error) return { ok: false, reason: 'We checked your URL and ' + h.error + '. Fix the URL and try again.' };
if (h.status >= 400) return { ok: false, reason: 'Your URL answers with HTTP ' + h.status + '. Point the campaign at a working page.' };
if (/deny|sameorigin/i.test(String(h.xfo)))
return { ok: false, reason: 'That site blocks framing (X-Frame-Options), so it would show members a blank page in the ad viewer. Use a landing page that allows framing.' };
const fa = /frame-ancestors\s+([^;]+)/i.exec(String(h.csp));
if (fa && !fa[1].split(/\s+/).some(x => {
const v = x.replace(/['"]/g, '').toLowerCase();
return v === '*' || v === 'https:' || v.includes('instantadpay.com');
}))
return { ok: false, reason: 'That site blocks framing (CSP frame-ancestors), so it would show members a blank page in the ad viewer. Use a landing page that allows framing.' };
return { ok: true };
}
async function boot() {
await db.init({ dataDir: DATA_DIR }); // no-op without DATABASE_URL (JSON mode)
chain.init({ onEvent: ev => attachNames([ev]).then(a => pushFeed(a[0])).catch(() => pushFeed(ev)) });
@@ -494,6 +545,10 @@ const server = http.createServer(async (req, res) => {
if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' });
const memberId = await auth.refreshMemberId(s); // 0 is fine: earned credits fund banner/text
const b = await readBody(req);
if (b.type !== 'login') { // surf views frame the target: catch frame-breakers at the door
const fc = await frameCheck(b.targetUrl);
if (!fc.ok) return json(res, 400, { error: fc.reason });
}
const r = await ads.createCampaign(s.email, memberId, b);
return json(res, r.error ? 400 : 200, r);
}