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
+1
View File
@@ -52,6 +52,7 @@ FACTS:
- Ad packages: Micro $5/500 credits, Activation $20/2,000, Builder $50/5,500, Growth $100/12,000, Leader $250/32,500. Dollar-priced, settled in POL (Polygon) at the live Chainlink rate. 1 credit = 1 cent of ad delivery.
- Live formats: display banners (per impression), text ads (per impression), login ads (per day). Coming: inbox ads, featured rotation with disclosed rotation size, verified-visit packs.
- Members EARN credits by attention: in the Earn credits section of Members, each ad in the daily set opens FULL SCREEN in its own tab, showing the advertiser's real website. A countdown runs while you watch (it pauses if you leave the tab), then a quick human check (click the named icon) must be passed before the view counts. Finish the daily set, claim a small daily credit batch. Earned credits spend on banner and text campaigns; attention earns advertising, referrals earn money, and viewer rewards are never cash. Advertisers get real, verified visits to their site.
- Campaign target URLs are checked the moment they are submitted: the page must be reachable and must ALLOW framing (no X-Frame-Options deny/sameorigin, no blocking CSP frame-ancestors), because surf views show the real site full screen. Frame-blocking or dead URLs are rejected with the exact reason; the fix is a landing page that allows framing. Login-ad targets skip the frame check (they are click-through only).
- Every purchase is split by an immutable smart contract in the same transaction: 50% direct sponsor, 20% level 2, 10% level 3, 20% platform. No withdrawals exist; money lands in members' own wallets instantly.
- Qualification: level 1 open to all; 2 buyers of $20+ unlock level 2; 5 unlock level 3. Unqualified shares pass up the sponsor line, checking up to 25 positions, else the platform receives them. Qualification cannot be bought and never expires.
- Every member gets a share link immediately (site code, /join/<code>); the contract locks a buyer to their sponsor at the buyer's FIRST purchase, so members should switch on payouts before their referrals buy.
+2
View File
@@ -215,6 +215,8 @@
<p><input id="cBudget" type="number" placeholder="Budget (credits)" min="10" style="width:100%"></p>
</div>
<p><input id="cTarget" placeholder="Target URL (https://…)" style="width:100%"></p>
<p class="small muted" style="margin-top:-6px">Banner and text targets are shown full screen in the
ad viewer, so the URL must allow framing — we check it the moment you submit.</p>
<p id="cImageRow"><input id="cImage" placeholder="Image URL (banner/login ads)" style="width:100%"></p>
<p id="cTitleRow" hidden><input id="cTitle" placeholder="Headline (max 60)" style="width:100%"></p>
<p id="cBodyRow" hidden><input id="cBody" placeholder="Ad text (max 140)" style="width:100%"></p>
+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);
}