// media_host_exposure.mjs — la 70. ¿CUANTA superficie de fuga de IP hay de verdad? // // Alguien aviso hoy en #asknostr (1 respuesta) de algo correcto: si tu cliente carga una // imagen alojada en el servidor de otra persona, ESA PERSONA VE TU IP y ademas sabe que // vienes de Nostr. Y lo peor no son las imagenes de las notas, que pasan por el feed: son // los AVATARES, que se cargan solos en cuanto alguien te aparece en pantalla. // // El aviso es cierto y no lleva ninguna cifra. Esto la pone: no mide que hace tu cliente // (eso no se ve desde fuera), mide CUANTA SUPERFICIE EXISTE — cuantos perfiles activos // tienen su avatar en un dominio cualquiera en vez de en un servidor de medios conocido. // // LO QUE MIDE Y LO QUE NO, y va en la publicacion: // SI - cuantos avatares/banners de cuentas activas viven en dominios arbitrarios. // NO - si tu cliente los cachea, los proxya o los bloquea. Eso depende del cliente y no // se observa desde aqui. Un numero alto aqui NO prueba que te esten espiando: prueba // cuantas ocasiones hay. Confundir ocasion con dano seria exactamente el error de // publicar un riesgo como si fuera un incidente. // // CONTROLES: // 1. CONTROL POSITIVO DE EXTRACCION: los servidores de medios conocidos TIENEN que aparecer. // Si no sale ninguno, mi extraccion de URLs esta rota y cualquier cifra mide mi parser. // 2. CONTROL DE MUESTRA: perfiles leidos frente a autores pedidos. // 3. TRI-STATE: con avatar / sin avatar / kind 0 ilegible. "Sin avatar" no es "protegido", // es otra cosa, y va aparte. // 4. NO SE VISITA NINGUNA DE ESAS URLS. Solo se mira el dominio. Pedirlas seria justo el // trafico que esta medicion describe, y encima desde mi IP. // // Uso: node media_host_exposure.mjs [--horas 6] [--max 800] [--json salida.json] import { SimplePool } from 'nostr-tools/pool' import { writeFileSync } from 'node:fs' const args = process.argv.slice(2) const num = (n, d) => { const i = args.indexOf(n); if (i < 0) return d; const v = Number(args[i + 1]); return Number.isFinite(v) ? v : d } const HORAS = num('--horas', 6) const MAX = num('--max', 800) const jsonOut = (() => { const i = args.indexOf('--json'); return i < 0 ? null : args[i + 1] })() const RELAYS = ['wss://relay.damus.io', 'wss://nos.lol', 'wss://relay.primal.net', 'wss://nostr.wine', 'wss://nostr.oxtr.dev', 'wss://offchain.pub'] const PERFILES = ['wss://purplepag.es', 'wss://relay.nostr.band', 'wss://relay.damus.io', 'wss://relay.primal.net', 'wss://nos.lol'] // Servidores de medios de uso comun. No son "seguros": son COMPARTIDOS, y esa es la // diferencia que importa. En uno compartido el operador ve muchas IPs y no sabe de quien es // cada perfil; en un dominio personal, quien lo opera ve exactamente quien mira su perfil. const COMUNES = [ 'nostr.build', 'image.nostr.build', 'i.nostr.build', 'void.cat', 'nostrimg.com', 'imgur.com', 'i.imgur.com', 'primal.net', 'blossom.band', 'nostr.download', 'cdn.satellite.earth', 'nostrcheck.me', 'cdn.nostrcheck.me', 'sovbit.host', 'files.sovbit.host', 'cdn.sovbit.host', 'nostpic.com', 'nostr.wine', 'media.tenor.com', 'pbs.twimg.com', 'i.ibb.co', 'lh3.googleusercontent.com', 'avatars.githubusercontent.com', 'gravatar.com', 'secure.gravatar.com', 'm.primal.net', 'blossom.primal.net', 'image.nostr.wine', 'i.current.fyi', 'cdn.zbd.gg', 'nostrich.zone', 'nostr.download', ] const esComun = (h) => COMUNES.some((c) => h === c || h.endsWith('.' + c)) const dominio = (u) => { try { return new URL(u).hostname.toLowerCase() } catch { return null } } const pool = new SimplePool() const desde = Math.floor(Date.now() / 1000) - HORAS * 3600 console.log(`leyendo autores activos (${HORAS} h)…`) let notas = [] try { notas = await pool.querySync(RELAYS, { kinds: [1], since: desde, limit: MAX }, { maxWait: 25000 }) } catch (e) { console.log('error:', e.message) } const autores = [...new Set(notas.map((n) => n.pubkey))].slice(0, MAX) console.log(` ${notas.length} notas, ${autores.length} autores distintos.`) if (autores.length < 100) { console.log('NOT_OBSERVED — muestra insuficiente; el cero mediria mi lectura.'); process.exit(1) } console.log('bajando perfiles…') const perfiles = new Map() for (let i = 0; i < autores.length; i += 200) { let ev = [] try { ev = await pool.querySync(PERFILES, { kinds: [0], authors: autores.slice(i, i + 200) }, { maxWait: 15000 }) } catch {} for (const e of ev) { const prev = perfiles.get(e.pubkey) if (!prev || e.created_at > prev.created_at) perfiles.set(e.pubkey, e) } process.stdout.write(`\r ${perfiles.size}…`) } console.log(`\n ${perfiles.size} perfiles de ${autores.length} autores (${Math.round(100 * perfiles.size / autores.length)}%).`) const cuenta = { comun: 0, propio: 0, sinAvatar: 0, ilegible: 0, noHttp: 0 } const dominiosPropios = new Map() const dominiosComunes = new Map() // CONTROL 5 (añadido tras la primera tirada): varios de los dominios que salian arriba // olian a cuentas automatizadas o PUENTEADAS desde otra red — generadores de avatares, // servidores de datos personales de otro protocolo. Si la mayoria de los "dominios propios" // son puentes y bots, la cifra NO habla de la gente: habla de la infraestructura de los // puentes, y publicarla como "4 de cada 10 usuarios" seria falso. // Un evento es PUENTE si declara `proxy` (NIP-48) o trae tags de origen externo. const propioBot = { declarado: 0, puente: 0, humano_aparente: 0 } const esPuente = (ev, c) => ev.tags.some((t) => t[0] === 'proxy') || /mostr\.pub|momostr|activitypub|bridge|\bpds\b/i.test(JSON.stringify(c).slice(0, 600)) for (const [, ev] of perfiles) { let c try { c = JSON.parse(ev.content) } catch { cuenta.ilegible++; continue } const pic = (c.picture || '').trim() if (!pic) { cuenta.sinAvatar++; continue } const h = dominio(pic) if (!h) { cuenta.noHttp++; continue } if (esComun(h)) { cuenta.comun++; dominiosComunes.set(h, (dominiosComunes.get(h) || 0) + 1) } else { cuenta.propio++ dominiosPropios.set(h, (dominiosPropios.get(h) || 0) + 1) if (c.bot === true) propioBot.declarado++ else if (esPuente(ev, c)) propioBot.puente++ else propioBot.humano_aparente++ } } // CONTROL 1: si no aparece NINGUN servidor comun, la extraccion esta rota console.log(`\nCONTROL DE EXTRACCION: ${dominiosComunes.size} servidores comunes distintos detectados.`) if (dominiosComunes.size === 0) { console.log('⚠️ ningun servidor conocido: mi extraccion esta rota. NO_EVALUABLE.'); process.exit(1) } console.log(' ok: la extraccion reconoce servidores conocidos.') const conAvatar = cuenta.comun + cuenta.propio const conAvatarPre = conAvatar const total = perfiles.size const pc = (n, d) => d ? (100 * n / d).toFixed(1) + '%' : '—' console.log(`\n=== AVATARES DE ${total} CUENTAS ACTIVAS (${HORAS} h) ===`) console.log(` en servidor de medios COMPARTIDO: ${cuenta.comun} (${pc(cuenta.comun, conAvatar)} de los que tienen avatar)`) console.log(` en dominio PROPIO o arbitrario: ${cuenta.propio} (${pc(cuenta.propio, conAvatar)})`) console.log(` sin avatar: ${cuenta.sinAvatar} (no es "protegido", es otra cosa)`) console.log(` perfil ilegible: ${cuenta.ilegible} | url no http: ${cuenta.noHttp}`) console.log(` dominios propios DISTINTOS: ${dominiosPropios.size}`) console.log(`\n=== ¿DE QUIEN SON ESOS ${cuenta.propio} PERFILES? (el control que decide la cifra) ===`) console.log(` se declaran bot (NIP-24): ${propioBot.declarado}`) console.log(` parecen cuentas PUENTE: ${propioBot.puente}`) console.log(` sin marca de bot ni de puente: ${propioBot.humano_aparente} <- la cifra que se puede publicar`) const sobreConAvatar = conAvatarPre ? (100 * propioBot.humano_aparente / conAvatarPre).toFixed(1) : '—' console.log(` -> ${sobreConAvatar}% de los perfiles CON avatar, no el ${pc(cuenta.propio, conAvatarPre)} bruto.`) console.log(` "sin marca" no es "humano": es que no se declara. Se dice asi, no al reves.`) const top = [...dominiosPropios.entries()].sort((a, b) => b[1] - a[1]).slice(0, 12) console.log(`\n los dominios propios mas repetidos (cada uno ve la IP de quien mire esos perfiles):`) for (const [h, n] of top) console.log(` ${String(n).padStart(3)}× ${h}`) console.log(`\n=== LO QUE ESTO SIGNIFICA Y LO QUE NO ===`) console.log(` Significa: hay ${dominiosPropios.size} operadores distintos que, si un cliente carga`) console.log(` esos avatares directamente, ven la IP de quien pasa por esos perfiles.`) console.log(` NO significa que te esten espiando. Mide OCASIONES, no incidentes, y no observa`) console.log(` que hace tu cliente (cachear, proxyar o bloquear). Confundir las dos cosas seria`) console.log(` publicar un riesgo como si fuera un dano.`) console.log(` NO se ha visitado ninguna de esas URLs: solo se ha mirado el dominio.`) if (jsonOut) { writeFileSync(jsonOut, JSON.stringify({ herramienta: 'media_host_exposure.mjs', generado: new Date().toISOString(), horas: HORAS, notas: notas.length, autores: autores.length, perfiles: total, cuenta, dominios_propios_distintos: dominiosPropios.size, top_propios: top, top_comunes: [...dominiosComunes.entries()].sort((a, b) => b[1] - a[1]).slice(0, 12), }, null, 2)) console.log(`\njson: ${jsonOut}`) } pool.close([...RELAYS, ...PERFILES]) process.exit(0)