From 5c945a1b090b0228a556fb88a48b7e197881d7fc Mon Sep 17 00:00:00 2001 From: martbost Date: Sat, 5 Sep 2026 07:48:55 -0500 Subject: [PATCH] Frame-breaking check at campaign submit: reject XFO/frame-ancestors blockers, SSRF-guarded Co-Authored-By: Claude Fable 5 --- chatbot.js | 1 + public/my.html | 2 ++ server.js | 55 ++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 58 insertions(+) diff --git a/chatbot.js b/chatbot.js index b51069f..2e4fd00 100644 --- a/chatbot.js +++ b/chatbot.js @@ -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/); the contract locks a buyer to their sponsor at the buyer's FIRST purchase, so members should switch on payouts before their referrals buy. diff --git a/public/my.html b/public/my.html index efc4186..6e99086 100644 --- a/public/my.html +++ b/public/my.html @@ -215,6 +215,8 @@

+

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.

diff --git a/server.js b/server.js index aa63f17..49faa58 100644 --- a/server.js +++ b/server.js @@ -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); }