Security Probe

index.jsx 270 lines

Files · 2
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270// Browser-level isolation probes. Each probe reports
//   { probe, outcome: 'blocked' | 'open' | 'error' | 'info', detail, origin }
// through lattice.events.emit('probe-result', ...), so results land in the
// host's event log (events.sqlite) where a harness can read them.
// 'open' means a channel outside host mediation (or an escape) works.
//
// The companion app org.lattice.probe-canary should be launched first: it
// plants canaries in its own origin's storage and answers on a
// BroadcastChannel.

const CANARY = 'CANARY-' + 'b7e1c0de';
const results = [];
// Every CSP violation the page sees (evidence for 'blocked' outcomes whose
// API gives no direct signal, such as sendBeacon).
const violations = [];
document.addEventListener('securitypolicyviolation', (e) => violations.push({ directive: e.violatedDirective, uri: e.blockedURI }));
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

async function report(probe, outcome, detail) {
  const r = { probe, outcome, detail: String(detail || '').slice(0, 500), origin: location.origin };
  results.push(r);
  const li = document.createElement('li');
  li.textContent = `${outcome.toUpperCase()}  ${probe}  ${r.detail}`;
  document.getElementById('out').appendChild(li);
  try { await window.lattice.events.emit('probe-result', r); } catch (e) { li.textContent += ' (emit failed: ' + e + ')'; }
}

function withTimeout(p, ms) {
  return Promise.race([p, sleep(ms).then(() => { throw new Error('timeout'); })]);
}

async function probe(name, fn) {
  try { await fn(); } catch (e) { await report(name, 'error', (e && (e.name + ': ' + e.message)) || e); }
}

// -- Shared browser state between app origins --

async function storageProbes() {
  await probe('origin', async () => report('origin', 'info', location.origin + ' href=' + location.href));
  await probe('localStorage-shared', async () => {
    const v = localStorage.getItem('lattice-canary');
    await report('localStorage-shared', v === CANARY ? 'open' : 'blocked', 'read=' + v);
  });
  await probe('sessionStorage-shared', async () => {
    const v = sessionStorage.getItem('lattice-canary');
    await report('sessionStorage-shared', v === CANARY ? 'open' : 'blocked', 'read=' + v);
  });
  await probe('cookie-shared', async () => {
    const open = document.cookie.includes(CANARY);
    await report('cookie-shared', open ? 'open' : 'blocked', 'cookie=' + document.cookie);
  });
  await probe('indexedDB-shared', async () => {
    const v = await withTimeout(new Promise((resolve, reject) => {
      const req = indexedDB.open('lattice-canary');
      req.onerror = () => reject(req.error);
      req.onsuccess = () => {
        const db = req.result;
        if (!db.objectStoreNames.contains('kv')) return resolve(null);
        const get = db.transaction('kv').objectStore('kv').get('canary');
        get.onsuccess = () => resolve(get.result);
        get.onerror = () => reject(get.error);
      };
    }), 3000);
    await report('indexedDB-shared', v === CANARY ? 'open' : 'blocked', 'read=' + v);
  });
  await probe('cacheStorage-shared', async () => {
    const has = await caches.has('lattice-canary');
    await report('cacheStorage-shared', has ? 'open' : 'blocked', 'has=' + has);
  });
  await probe('broadcastChannel', async () => {
    const bc = new BroadcastChannel('lattice-probe');
    const got = new Promise((resolve) => { bc.onmessage = (m) => resolve(m.data); });
    bc.postMessage('ping');
    const reply = await Promise.race([got, sleep(2000).then(() => null)]);
    bc.close();
    await report('broadcastChannel', reply && reply.pong === CANARY ? 'open' : 'blocked', JSON.stringify(reply));
  });
}

// -- Network / fetch bypass (CSP connect-src etc.) --

async function networkProbes(otherOrigin) {
  const attempt = async (name, fn) => probe(name, async () => {
    try {
      const detail = await withTimeout(fn(), 4000);
      await report(name, 'open', detail);
    } catch (e) {
      await report(name, 'blocked', (e && (e.name + ': ' + e.message)) || e);
    }
  });
  await attempt('fetch-https', async () => { const r = await fetch('https://example.com/'); return 'status ' + r.status; });
  await attempt('fetch-http-loopback', async () => { const r = await fetch('http://127.0.0.1:9/'); return 'status ' + r.status; });
  await attempt('xhr-https', () => new Promise((resolve, reject) => {
    const x = new XMLHttpRequest();
    x.open('GET', 'https://example.com/');
    x.onload = () => resolve('status ' + x.status);
    x.onerror = () => reject(new Error('xhr error'));
    x.send();
  }));
  await attempt('websocket', () => new Promise((resolve, reject) => {
    const ws = new WebSocket('wss://echo.websocket.org/');
    ws.onopen = () => { resolve('connected'); ws.close(); };
    ws.onerror = () => reject(new Error('ws error'));
  }));
  await attempt('eventsource', () => new Promise((resolve, reject) => {
    const es = new EventSource('https://example.com/stream');
    es.onopen = () => { resolve('opened'); es.close(); };
    es.onerror = () => { es.close(); reject(new Error('es error')); };
  }));
  await attempt('img-external', () => new Promise((resolve, reject) => {
    const img = new Image();
    img.onload = () => resolve('loaded');
    img.onerror = () => reject(new Error('img error'));
    img.src = 'https://example.com/favicon.ico?' + Date.now();
  }));
  await attempt('script-external', () => new Promise((resolve, reject) => {
    const s = document.createElement('script');
    s.onload = () => resolve('loaded');
    s.onerror = () => reject(new Error('script error'));
    s.src = 'https://example.com/x.js';
    document.head.appendChild(s);
  }));
  await probe('sendBeacon', async () => {
    let queued = false;
    try { queued = navigator.sendBeacon('https://example.com/beacon', 'x'); } catch (e) { queued = false; }
    await sleep(1000);
    const csp = violations.find((v) => String(v.uri).includes('example.com/beacon'));
    // Queued alone is not delivery; a connect-src violation for the beacon
    // URL shows CSP stopped it.
    await report('sendBeacon', !queued || csp ? 'blocked' : 'open', 'queued=' + queued + ' csp=' + (csp ? csp.directive : 'none'));
  });
  if (otherOrigin) {
    await attempt('fetch-other-app-origin', async () => { const r = await fetch(otherOrigin + '/index.html'); return 'status ' + r.status + ' ' + (await r.text()).slice(0, 60); });
  }
  await attempt('worker', () => new Promise((resolve, reject) => {
    const w = new Worker(URL.createObjectURL(new Blob(['postMessage(1)'], { type: 'text/javascript' })));
    w.onmessage = () => resolve('worker ran');
    w.onerror = (e) => reject(new Error('worker error ' + (e && e.message)));
  }));
  await attempt('serviceWorker', async () => {
    if (!('serviceWorker' in navigator)) throw new Error('unavailable');
    await navigator.serviceWorker.register('sw.js');
    return 'registered';
  });
}

// -- Framework internals / bridge integrity --

async function bridgeProbes() {
  await probe('tauri-internals', async () => {
    const found = ['__TAURI_INTERNALS__', '__TAURI__', '__TAURI_IPC__', '__TAURI_METADATA__']
      .filter((k) => typeof window[k] !== 'undefined');
    await report('tauri-internals', found.length ? 'open' : 'blocked', 'globals=' + found.join(','));
  });
  await probe('raw-ipc-channel', async () => {
    const wk = window.webkit && window.webkit.messageHandlers && Object.keys(window.webkit.messageHandlers);
    const chrome = window.chrome && window.chrome.webview ? 'chrome.webview' : '';
    const ipc = typeof window.ipc !== 'undefined' ? 'window.ipc' : '';
    const present = [wk && wk.length ? 'webkit.messageHandlers:' + wk.join('|') : '', chrome, ipc].filter(Boolean);
    // Presence alone is not an escape (commands still check the window
    // label); 'info' records what a guest can see.
    await report('raw-ipc-channel', 'info', present.join(' ') || 'none visible');
  });
  await probe('ipc-protocol-without-key', async () => {
    try {
      const r = await withTimeout(fetch('ipc://localhost/list_apps', { method: 'POST', body: '{}', headers: { 'Content-Type': 'application/json' } }), 3000);
      const text = await r.text();
      await report('ipc-protocol-without-key', r.ok && text.includes('"id"') ? 'open' : 'blocked', 'status ' + r.status + ' ' + text.slice(0, 120));
    } catch (e) {
      await report('ipc-protocol-without-key', 'blocked', e.message);
    }
  });
  await probe('lattice-api-frozen', async () => {
    const orig = window.lattice.kv.get;
    try { window.lattice.kv.get = () => 'hijacked'; } catch (e) { /* strict-mode TypeError */ }
    let redefined = false;
    try { Object.defineProperty(window, 'lattice', { value: {} }); redefined = true; } catch (e) { /* expected */ }
    await report('lattice-api-frozen', window.lattice.kv.get !== orig || redefined ? 'open' : 'blocked', 'frozen=' + Object.isFrozen(window.lattice));
  });
  await probe('cross-app-storage-via-bridge', async () => {
    const v = await window.lattice.kv.get('canary');
    const v2 = await window.lattice.kv.get('canary', { _ce_app_id: 'org.lattice.probe-canary' });
    await report('cross-app-storage-via-bridge', v === CANARY || v2 === CANARY ? 'open' : 'blocked', 'read=' + v + '/' + v2);
  });
  await probe('custom-events-api', async () => {
    await report('custom-events-api', window.lattice.customEvents ? 'open' : 'blocked', 'keys=' + Object.keys(window.lattice).join(','));
  });
}

// -- Frames, popups, forms, navigation --

async function navigationProbes(otherOrigin) {
  await probe('iframe-other-app', async () => {
    const f = document.createElement('iframe');
    f.src = (otherOrigin || 'https://example.com') + '/index.html';
    const loaded = new Promise((resolve) => { f.onload = () => resolve(true); });
    document.body.appendChild(f);
    const ok = await Promise.race([loaded, sleep(2500).then(() => false)]);
    let readable = 'n/a';
    try { readable = String(f.contentWindow.location.href); } catch (e) { readable = 'cross-origin'; }
    await report('iframe-other-app', ok && readable !== 'about:blank' && readable !== 'cross-origin' ? 'open' : 'blocked', 'loaded=' + ok + ' href=' + readable);
  });
  await probe('window-open', async () => {
    const w = window.open('https://example.com/', '_blank');
    await report('window-open', w ? 'open' : 'blocked', 'returned=' + (w ? 'WindowProxy' : 'null'));
    try { w && w.close(); } catch (e) { /* ignore */ }
  });
  await probe('form-post-external', async () => {
    // Submitted into a named iframe target so this page survives.
    const name = 'formsink';
    const sink = document.createElement('iframe');
    sink.name = name;
    document.body.appendChild(sink);
    const form = document.createElement('form');
    form.method = 'POST';
    form.action = 'https://example.com/collect';
    form.target = name;
    const input = document.createElement('input');
    input.name = 'canary';
    input.value = CANARY;
    form.appendChild(input);
    document.body.appendChild(form);
    let violation = null;
    document.addEventListener('securitypolicyviolation', (e) => { violation = e.violatedDirective; }, { once: true });
    form.submit();
    await sleep(2000);
    await report('form-post-external', violation ? 'blocked' : 'error', 'csp=' + violation + ' (verify no request reached example.com)');
  });
}

async function topLevelNavigationProbe() {
  // Last: if this succeeds the page is gone and no 'blocked' result arrives.
  // The harness treats a missing result as 'open'.
  await window.lattice.kv.set('nav-attempted', Date.now());
  location.href = 'https://example.com/?from=lattice-probe';
  await sleep(3000);
  await report('top-level-navigation', 'blocked', 'still at ' + location.href);
}

// Origin the host serves an app instance from (see app_manager::origin_host):
// base32 (RFC 4648, lowercase, unpadded) of the instance ID, in DNS labels of
// at most 63 characters, under .localhost.
function originOf(appId) {
  const alphabet = 'abcdefghijklmnopqrstuvwxyz234567';
  let bits = 0, value = 0, out = '';
  for (const byte of new TextEncoder().encode(appId)) {
    value = (value << 8) | byte;
    bits += 8;
    while (bits >= 5) { out += alphabet[(value >>> (bits - 5)) & 31]; bits -= 5; }
    value &= (1 << bits) - 1;
  }
  if (bits > 0) out += alphabet[(value << (5 - bits)) & 31];
  const host = out.match(/.{1,63}/g).join('.') + '.localhost';
  return location.protocol === 'http:' ? 'http://lattice-app.' + host : 'lattice-app://' + host;
}

async function main() {
  document.body.innerHTML = '<h3>Lattice security probe</h3><ul id="out"></ul>';
  const otherOrigin = originOf('org.lattice.probe-canary');
  await storageProbes();
  await networkProbes(otherOrigin);
  await bridgeProbes();
  await navigationProbes(otherOrigin);
  await report('csp-violations', 'info', JSON.stringify(violations).slice(0, 480));
  await report('summary', 'info', results.filter((r) => r.outcome === 'open').map((r) => r.probe).join(',') || 'no open channels');
  await topLevelNavigationProbe();
}

// Give the companion time to plant canaries if launched together.
setTimeout(main, 1500);