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:
@@ -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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
||||||
|
|||||||
@@ -215,6 +215,8 @@
|
|||||||
<p><input id="cBudget" type="number" placeholder="Budget (credits)" min="10" style="width:100%"></p>
|
<p><input id="cBudget" type="number" placeholder="Budget (credits)" min="10" style="width:100%"></p>
|
||||||
</div>
|
</div>
|
||||||
<p><input id="cTarget" placeholder="Target URL (https://…)" style="width:100%"></p>
|
<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="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="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>
|
<p id="cBodyRow" hidden><input id="cBody" placeholder="Ad text (max 140)" style="width:100%"></p>
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
// Chain selection (Amoy rehearsal vs mainnet) lives in data/config.json —
|
// Chain selection (Amoy rehearsal vs mainnet) lives in data/config.json —
|
||||||
// see chain.js. Wipe the volume's accounts/sessions + flip config = launch.
|
// see chain.js. Wipe the volume's accounts/sessions + flip config = launch.
|
||||||
const http = require('http');
|
const http = require('http');
|
||||||
|
const https = require('https');
|
||||||
|
const dns = require('dns');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const crypto = require('crypto');
|
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]
|
// human-check pairs for the view verifier: [emoji shown, word named in the prompt]
|
||||||
const CAPTCHA = [['🚀', 'rocket'], ['⚡', 'lightning bolt'], ['🔑', 'key'], ['🎯', 'target'],
|
const CAPTCHA = [['🚀', 'rocket'], ['⚡', 'lightning bolt'], ['🔑', 'key'], ['🎯', 'target'],
|
||||||
['🌊', 'wave'], ['🔥', 'flame'], ['💎', 'diamond'], ['🧲', 'magnet'], ['🔔', 'bell'], ['🌙', 'moon']];
|
['🌊', '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() {
|
async function boot() {
|
||||||
await db.init({ dataDir: DATA_DIR }); // no-op without DATABASE_URL (JSON mode)
|
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)) });
|
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.' });
|
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 memberId = await auth.refreshMemberId(s); // 0 is fine: earned credits fund banner/text
|
||||||
const b = await readBody(req);
|
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);
|
const r = await ads.createCampaign(s.email, memberId, b);
|
||||||
return json(res, r.error ? 400 : 200, r);
|
return json(res, r.error ? 400 : 200, r);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user