openvibers/openvibe.connect Commits

The signed probe: a credential trap behind a share goes to staff review (CON-9) (#18)

OP OpenVibers committed 10 days ago · 1bfc868a7d · 6 files, +229 −2

Once a day, a little after a share's computer connects, Connect reads its front page through the tunnel with a signed
x-openvibe-probe header and looks for a password, card or wallet-phrase field on a page that names a brand, or with
an urgent account warning. A likely trap becomes one automatic report for staff; Connect never closes a share here,
and an ordinary sign-in page for a person's own app is not reported.

Changes

ddiff --git a/README.md b/README.md
iindex de228d0..2b738cd 100644
---- a/README.md
++++ b/README.md
@@@ -86,6 +86,13 @@ wrong and what they saw. OpenVibe staff read the open reports at `/admin/reports
is disconnected and its address then says it was closed after a report. A report keeps the reporter's account only
when they were signed in, never an address.
++Connect also checks each share itself, once a day, a little after its computer connects (CON-9, the signed probe). It
++reads the share's front page through the tunnel with an `x-openvibe-probe: t=<unix>,v1=<hmac>` header, so your app can
++tell the request is Connect's, and looks for the signs of a credential trap: a password, card or wallet-phrase field on a
++page that names a brand (PayPal, Google, a bank…), or one with an urgent "verify your account" story. A page that looks
++like one becomes an automatic report for staff; Connect never closes a share by itself here, and nothing of the page is
++kept beyond the report's one line. An ordinary sign-in page for your own app is not reported.
++
## API
With a person's OpenVibe token as a Bearer (or the session on connect.openvibe.website):
ddiff --git a/server/app.js b/server/app.js
iindex c7e871f..13acd96 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -32,6 +32,7 @@ const { createNodeApi } = require('./http/node-api');
const { createNodeAuth } = require('./auth/node-auth');
const { createMachines } = require('./machines');
const { createHooks } = require('./hooks');
++const { createProbe } = require('./probe');
const { createHookRoutes } = require('./http/hooks');
const { createServiceOutbox } = require('openvibe-sdk/events');
const { createPageRoutes } = require('./http/pages');
@@@ -73,6 +74,10 @@ async function createApp(opts = {}) {
// Webhooks that waited for this computer (CON-15) go at once, oldest first; Events' later retry finds them
// settled. One that fails waits for that retry.
ctx.deliverWaiting(share).catch((err) => log.warn('[OpenVibe.Connect] waiting webhooks not handed over:', err.message));
++ // The signed probe (CON-9): once a day per share, a little after its computer connects (the app may still be
++ // starting), its front page is read for the signs of a credential trap; a likely one goes to staff review.
++ const probeTimer = setTimeout(() => { ctx.probe.run(share).catch(() => {}); }, ctx.probeDelayMs);
++ if (probeTimer.unref) probeTimer.unref();
const waiting = await shares.takeWaiters(share.id);
for (const subject of waiting) await notifier.shareBack(subject, share);
if (waiting.length) log.log(`[OpenVibe.Connect] told ${waiting.length} person(s) ${share.host} is back`);
@@@ -96,6 +101,8 @@ async function createApp(opts = {}) {
const hooks = createHooks(s, { config, outbox, now: s.now });
const ctx = { config, s, keys, sso, principal, shares, tunnels, gate, proxy, notifier, nodeAuth, machines, hooks, outbox, accountData, accountSend, log };
++ ctx.probe = createProbe({ shares, proxy, config, log, now: s.now });
++ ctx.probeDelayMs = opts.probeDelayMs != null ? opts.probeDelayMs : 10_000;
/** A share's computer is connected: its waiting webhooks go to its app, one at a time, oldest first. → delivered count */
ctx.deliverWaiting = async (share) => {
ddiff --git a/server/edge/proxy.js b/server/edge/proxy.js
iindex ee43a15..ccf284a 100644
---- a/server/edge/proxy.js
++++ b/server/edge/proxy.js
@@@ -418,7 +418,47 @@ function createProxy({ config, shares, tunnels, gate, log = console, now = () =>
});
}
-- return { handle, upgrade, isShareHost, flush, forget, denied, stop, responseHeaders, forwardHeaders, deliverHook, inspector };
++ /**
++ * One GET through a share's tunnel for Connect itself (CON-9's probe): the answer's status, content type and the
++ * first maxBytes of its body. → { status, type, body } | { offline: true } | { error }
++ */
++ function fetchThrough(share, { path = '/', headers = [], maxBytes = 512 * 1024 } = {}) {
++ const tunnel = tunnels.get(share.id);
++ if (!tunnel) return Promise.resolve({ offline: true });
++ const port = (tunnel.clientInfo && tunnel.clientInfo.port) || share.local_port;
++ return new Promise((resolve) => {
++ let done = false;
++ let status = 0;
++ let type = '';
++ const chunks = [];
++ let size = 0;
++ const finish = (out) => { if (done) return; done = true; clearTimeout(timer); resolve(out); };
++ const id = tunnel.open({
++ onRes(m) {
++ status = Number(m.status) || 0;
++ for (const pair of Array.isArray(m.headers) ? m.headers : []) if (Array.isArray(pair) && String(pair[0]).toLowerCase() === 'content-type') type = String(pair[1]).slice(0, 120);
++ },
++ onData(buf) {
++ if (size >= maxBytes) return;
++ const take = buf.subarray(0, maxBytes - size);
++ chunks.push(take);
++ size += take.length;
++ if (size >= maxBytes) { tunnel.abort(id, 'enough'); finish({ status, type, body: Buffer.concat(chunks) }); }
++ },
++ onEnd() { tunnel.release(id); finish({ status, type, body: Buffer.concat(chunks) }); },
++ onAbort(m) { finish(size ? { status, type, body: Buffer.concat(chunks) } : { error: (m && m.code) || 'local_error' }); },
++ onPause() {}, onResume() {},
++ });
++ const timer = setTimeout(() => { tunnel.abort(id, 'timeout'); finish(size ? { status, type, body: Buffer.concat(chunks) } : { error: 'timeout' }); }, config.shares.requestTimeoutMs);
++ timer.unref();
++ const out = (Array.isArray(headers) ? headers : []).filter((h) => Array.isArray(h) && h.length === 2);
++ out.push(['host', `localhost:${port}`], ['x-forwarded-host', share.host], ['x-forwarded-proto', config.shares.scheme], ['accept', 'text/html,*/*;q=0.5'], ['content-length', '0']);
++ tunnel.send({ t: 'req', id, method: 'GET', path, headers: out });
++ tunnel.end(id);
++ });
++ }
++
++ return { handle, upgrade, isShareHost, flush, forget, denied, stop, responseHeaders, forwardHeaders, deliverHook, fetchThrough, inspector };
}
/** Write an HTTP answer straight to an upgrade's socket (the gate's 302/401/404/502 before any tunnel opens). */
ddiff --git a/server/probe.js b/server/probe.js
nnew file mode 100644
iindex 0000000..08ecfac
---- /dev/null
++++ b/server/probe.js
@@@ -0,0 +1,84 @@
++'use strict';
++
++/**
++ * The signed probe (CON-9, the abuse gate before public shares, ADR-057 decision 6). When a share's computer connects,
++ * at most once a day, Connect reads the share's front page itself, through the tunnel, with a signed
++ * `x-openvibe-probe: t=<unix>,v1=<hmac>` header so the app can tell it is Connect. It looks for the signs of a credential
++ * trap: a password, card or wallet-phrase field on a page that names a brand the share is not. A page that looks like
++ * one becomes an automatic report for OpenVibe staff (/admin/reports). Connect never closes a share by itself here: a
++ * person decides. Nothing of the page is kept beyond the report's one-line reason.
++ *
++ * assess(html, share) → { verdict: 'ok' | 'review', flags: [..], brands: [..], why }
++ * createProbe({ shares, proxy, config, log, now }).run(share) → the assessment, or null when it did not run
++ * signature(shareId, secret, t) → the header value
++ */
++const crypto = require('crypto');
++
++const DAY_MS = 86400_000;
++// Brands people are tricked into signing in to. Matched as whole words in the page's title, headings, labels and
++// buttons, never in a share's own address (a share named "paypal-demo" by its owner is still flagged: staff decide).
++const BRANDS = ['paypal', 'google', 'gmail', 'microsoft', 'outlook', 'office 365', 'apple', 'icloud', 'amazon', 'facebook',
++ 'instagram', 'netflix', 'steam', 'discord', 'twitch', 'coinbase', 'binance', 'metamask', 'chase', 'wells fargo',
++ 'bank of america', 'citibank', 'hsbc', 'barclays', 'santander', 'dhl', 'fedex', 'ups', 'usps', 'irs', 'openvibe'];
++const textOf = (html) => String(html || '')
++ .replace(/<script[\s\S]*?<\/script>/gi, ' ').replace(/<style[\s\S]*?<\/style>/gi, ' ')
++ .replace(/<[^>]+>/g, ' ').replace(/&nbsp;/g, ' ').replace(/\s+/g, ' ').toLowerCase();
++const visibleBits = (html) => {
++ const pick = (re) => [...String(html || '').matchAll(re)].map((m) => m[1]).join(' ');
++ return [pick(/<title[^>]*>([\s\S]*?)<\/title>/gi), pick(/<h[1-3][^>]*>([\s\S]*?)<\/h[1-3]>/gi), pick(/<label[^>]*>([\s\S]*?)<\/label>/gi),
++ pick(/<button[^>]*>([\s\S]*?)<\/button>/gi), pick(/\b(?:placeholder|aria-label|alt|value)\s*=\s*"([^"]{1,120})"/gi)]
++ .join(' ').replace(/<[^>]+>/g, ' ').toLowerCase();
++};
++
++function assess(html, share = {}) {
++ const raw = String(html || '');
++ const flags = [];
++ if (/<input[^>]+type\s*=\s*["']?password/i.test(raw) || /autocomplete\s*=\s*["']?(?:current|new)-password/i.test(raw)) flags.push('password_field');
++ if (/autocomplete\s*=\s*["']?cc-(?:number|csc|exp)/i.test(raw) || /\b(?:card number|cvv|cvc|security code)\b/i.test(textOf(raw))) flags.push('card_field');
++ if (/\b(?:seed phrase|recovery phrase|secret phrase|12[- ]word|24[- ]word|private key|mnemonic)\b/i.test(textOf(raw))) flags.push('wallet_phrase');
++ if (/\b(?:verify your account|account (?:has been )?(?:suspended|locked|limited)|unusual (?:sign-?in|activity)|confirm your identity)\b/i.test(textOf(raw))) flags.push('urgency');
++ const bits = visibleBits(raw);
++ const brands = BRANDS.filter((b) => new RegExp(`\\b${b.replace(/ /g, '\\s+')}\\b`, 'i').test(bits));
++ const asks = flags.some((f) => f === 'password_field' || f === 'card_field' || f === 'wallet_phrase');
++ // A sign-in or payment form is ordinary (people share their own apps). It is worth a person's look when it names a
++ // brand, or when it pairs a secret field with an urgent "verify your account" story.
++ const review = asks && (brands.length > 0 || flags.includes('urgency') || flags.includes('wallet_phrase'));
++ const why = review
++ ? `Automatic check: the front page asks for ${flags.filter((f) => f !== 'urgency').map((f) => ({ password_field: 'a password', card_field: 'card details', wallet_phrase: 'a wallet phrase' }[f])).join(' and ')}${brands.length ? ` and names ${brands.slice(0, 3).join(', ')}` : ''}${flags.includes('urgency') ? ', with an urgent account warning' : ''}.`
++ : null;
++ return { verdict: review ? 'review' : 'ok', flags, brands, why };
++}
++
++function signature(shareId, secret, t = Math.floor(Date.now() / 1000)) {
++ const mac = crypto.createHmac('sha256', String(secret || '')).update(`${t}.${shareId}`).digest('hex');
++ return `t=${t},v1=${mac}`;
++}
++
++function createProbe({ shares, proxy, config, log = console, now = () => Date.now() }) {
++ const last = new Map(); // share id → when it was last probed (in memory: a restart probes again, which is fine)
++ const base = (config.shares && config.shares.sessionSecret) || (config.session && config.session.secret) || '';
++ const secret = `probe:${base}`;
++
++ async function run(share, { force = false } = {}) {
++ if (!share || share.status !== 'active') return null;
++ const at = last.get(share.id) || 0;
++ if (!force && now() - at < DAY_MS) return null;
++ last.set(share.id, now());
++ if (last.size > 10000) last.delete(last.keys().next().value);
++ const out = await proxy.fetchThrough(share, { path: '/', headers: [['x-openvibe-probe', signature(share.id, secret, Math.floor(now() / 1000))], ['user-agent', 'OpenVibeConnectProbe/1 (+https://connect.openvibe.website/report)']] });
++ if (!out || out.offline || out.error || !out.body) return null;
++ if (!/html/i.test(out.type || '') && !/<html|<form/i.test(out.body.toString('utf8', 0, 2048))) return { verdict: 'ok', flags: [], brands: [], why: null };
++ const a = assess(out.body.toString('utf8'), share);
++ if (a.verdict === 'review') {
++ try {
++ await shares.report({ host: share.host, reason: 'phishing', details: a.why, reporter: null });
++ log.warn(`[OpenVibe.Connect] probe: ${share.host} sent to staff review (${a.flags.join(', ')}${a.brands.length ? `; ${a.brands.join(', ')}` : ''})`);
++ } catch (err) { log.warn('[OpenVibe.Connect] probe report not filed:', err.message); }
++ }
++ return a;
++ }
++
++ return { run };
++}
++
++module.exports = { assess, signature, createProbe, BRANDS };
ddiff --git a/test/helpers/boot.js b/test/helpers/boot.js
iindex fba2373..12f33b6 100644
---- a/test/helpers/boot.js
++++ b/test/helpers/boot.js
@@@ -44,7 +44,7 @@ async function boot(opts = {}) {
const { createStore } = require('../../server/db');
const testdb = await require('./db').testDb();
const store = createStore(testdb.db, { now: opts.now });
-- const built = await createApp({ config, store, log, telemetrySink: opts.telemetrySink, accountSend: opts.accountSend, notifier: opts.notifier });
++ const built = await createApp({ config, store, log, telemetrySink: opts.telemetrySink, accountSend: opts.accountSend, notifier: opts.notifier, probeDelayMs: opts.probeDelayMs != null ? opts.probeDelayMs : 60_000 });
await built.ctx.keys.ensure();
const server = await new Promise((resolve) => { const s = http.createServer(built.app); s.on('upgrade', built.ctx.onUpgrade); s.listen(0, '127.0.0.1', () => resolve(s)); });
const port = server.address().port;
ddiff --git a/test/probe.test.js b/test/probe.test.js
nnew file mode 100644
iindex 0000000..08e1691
---- /dev/null
++++ b/test/probe.test.js
@@@ -0,0 +1,89 @@
++'use strict';
++/**
++ * The signed probe (CON-9): a share's front page is read through its tunnel once a day after its computer connects,
++ * marked with a signed x-openvibe-probe header; a credential trap (a password, card or wallet-phrase field on a page
++ * that names a brand, or with an urgent "verify your account") becomes an automatic report for staff, once; an
++ * ordinary sign-in page does not.
++ */
++const assert = require('assert');
++const http = require('http');
++const { boot } = require('./helpers/boot');
++const { check, done } = require('./helpers/check');
++const { assess } = require('../server/probe');
++
++const PHISH = `<html><head><title>PayPal: Log in to your account</title></head><body><h1>Log in to PayPal</h1>
++<p>Your account has been limited. Verify your account to restore access.</p>
++<form method="post"><label>Email</label><input name="email"><label>Password</label><input type="password" name="pw"><button>Log In</button></form></body></html>`;
++const OWN_APP = `<html><head><title>My homelab</title></head><body><h1>Sign in to Grafana-lite</h1><form><input name="u"><input type="password" name="p"><button>Sign in</button></form></body></html>`;
++const WALLET = `<html><head><title>Claim your airdrop</title></head><body><h1>Connect wallet</h1><p>Enter your 12-word seed phrase to claim.</p><textarea></textarea></body></html>`;
++
++function appServing(html) {
++ const seen = [];
++ const server = http.createServer((req, res) => { seen.push({ url: req.url, probe: req.headers['x-openvibe-probe'] || null, ua: req.headers['user-agent'] || '' }); res.writeHead(200, { 'content-type': 'text/html' }); res.end(html); });
++ return new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve({ port: server.address().port, seen, close: () => new Promise((r) => server.close(r)) })));
++}
++const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
++
++(async () => {
++ await check('assess: a branded sign-in trap and a wallet phrase go to review; an ordinary sign-in page does not', async () => {
++ const a = assess(PHISH, { label: 'login' });
++ assert.strictEqual(a.verdict, 'review');
++ assert.deepStrictEqual(a.brands, ['paypal']);
++ assert.ok(a.flags.includes('password_field') && a.flags.includes('urgency'));
++ assert.match(a.why, /^Automatic check: the front page asks for a password and names paypal, with an urgent account warning\.$/);
++ assert.strictEqual(assess(OWN_APP).verdict, 'ok', 'a person\'s own sign-in page is ordinary');
++ assert.strictEqual(assess(WALLET).verdict, 'review');
++ assert.ok(assess(WALLET).flags.includes('wallet_phrase'));
++ assert.strictEqual(assess('<html><h1>Google Sheets export of my budget</h1></html>').verdict, 'ok', 'a brand without a secret field is fine');
++ assert.strictEqual(assess('').verdict, 'ok');
++ });
++
++ const t = await boot({ probeDelayMs: 50 });
++ const alex = t.network.addUser('alex');
++
++ await check('a phishing page behind a share: one automatic report, the app sees the signed probe', async () => {
++ const app = await appServing(PHISH);
++ const made = await t.makeShare(alex, { label: 'pp-verify', port: app.port });
++ const c = t.client({ key: made.key, port: app.port });
++ await c.ready();
++ let reports = [];
++ for (let i = 0; i < 100 && !reports.length; i++) { await sleep(30); reports = await t.ctx.s.db.many('SELECT reason, details, reporter_subject FROM share_reports WHERE share_id = $1', [made.share.id]); }
++ assert.strictEqual(reports.length, 1, 'one report');
++ assert.strictEqual(reports[0].reason, 'phishing');
++ assert.strictEqual(reports[0].reporter_subject, null, 'filed by the check, not a person');
++ assert.match(reports[0].details, /names paypal/);
++ const probe = app.seen.find((x) => x.probe);
++ assert.ok(probe, 'the app saw the probe');
++ assert.match(probe.probe, /^t=\d+,v1=[0-9a-f]{64}$/);
++ assert.match(probe.ua, /OpenVibeConnectProbe/);
++ // A reconnect the same day probes nothing more.
++ c.stop();
++ const c2 = t.client({ key: made.key, port: app.port });
++ await c2.ready();
++ await sleep(300);
++ const again = await t.ctx.s.db.many('SELECT id FROM share_reports WHERE share_id = $1', [made.share.id]);
++ assert.strictEqual(again.length, 1, 'once a day');
++ const staff = t.network.addUser('staffer', { role: 'admin' });
++ const page = await t.get('/admin/reports', { as: staff });
++ assert.match(page.text, /Automatic check/);
++ c2.stop();
++ await app.close();
++ });
++
++ await check('an ordinary app behind a share: no report', async () => {
++ const app = await appServing(OWN_APP);
++ const made = await t.makeShare(alex, { label: 'homelab', port: app.port });
++ const c = t.client({ key: made.key, port: app.port });
++ await c.ready();
++ for (let i = 0; i < 40 && !app.seen.some((x) => x.probe); i++) await sleep(30);
++ assert.ok(app.seen.some((x) => x.probe), 'it was probed');
++ await sleep(100);
++ const reports = await t.ctx.s.db.many('SELECT id FROM share_reports WHERE share_id = $1', [made.share.id]);
++ assert.strictEqual(reports.length, 0);
++ c.stop();
++ await app.close();
++ });
++
++ await t.close();
++ done();
++})().catch((e) => { console.error(e); process.exit(1); });