mirror of
https://github.com/mmis1000/SofaNav.git
synced 2026-10-17 19:01:07 +02:00
Follow-up to E29. When the userscript runs in an isolated world (Tampermonkey @sandbox DOM, Violentmonkey @inject-into content, checked via GM_info), frame commands travel through the manager's per-script storage (GM_setValue + GM_addValueChangeListener on random per-instance keys), which pages can't write. Without isolation it stays same-origin only: in page mode the attacker page can fake navigator.getGamepads for SofaNav in its own top frame (shown by a negative control). Also: activate() passes document.defaultView as the event view, since userscript managers may wrap window (PointerEvent threw in VM page mode). test:security now installs the userscript in a pinned, sha256-checked Violentmonkey and adds a fake-controller attack. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
125 lines
6.9 KiB
JavaScript
125 lines
6.9 KiB
JavaScript
// Security regression: a page that frames another site must not be able to make
|
||
// SofaNav click things inside it (see docs/test-cases.md, E29).
|
||
// The attacker page forges SofaNav's frame messages and fakes a controller (getGamepads) for
|
||
// SofaNav in its own top frame. Runs in three setups:
|
||
// page script the test page loads content.js itself (like sofanav.js on a site)
|
||
// extension the real extension, loaded into Chromium
|
||
// userscript dist/sofanav.user.js installed in Violentmonkey (pinned release, downloaded
|
||
// by scripts/fetch-violentmonkey.js)
|
||
// Usage: npm run serve (another terminal), then: npm run test:security
|
||
// CONTENT_JS / EXTENSION_DIR / USERSCRIPT test another copy (e.g. an old commit).
|
||
const { chromium } = require('playwright');
|
||
const fs = require('fs');
|
||
const os = require('os');
|
||
const path = require('path');
|
||
|
||
const BASE = process.env.TEST_URL || 'http://localhost:8765';
|
||
const EXT_DIR = path.resolve(process.env.EXTENSION_DIR || path.join(__dirname, '..', 'extension'));
|
||
const USER_JS = path.resolve(process.env.USERSCRIPT || path.join(__dirname, '..', 'dist', 'sofanav.user.js'));
|
||
const { execFileSync } = require('child_process');
|
||
|
||
async function attack(context, ext) {
|
||
const page = await context.newPage();
|
||
if (process.env.CONTENT_JS) {
|
||
const body = fs.readFileSync(process.env.CONTENT_JS, 'utf8');
|
||
await context.route('**/extension/content.js', (r) => r.fulfill({ body, contentType: 'text/javascript' }));
|
||
}
|
||
await page.goto(`${BASE}/test/attack/attacker.html${ext ? '?ext' : ''}`);
|
||
await page.waitForTimeout(4000); // the attacker keeps trying for ~2 s
|
||
const victim = page.frames().find((f) => f.url().includes('/attack/victim.html'));
|
||
const r = await victim.evaluate(() => ({
|
||
submitted: window.__submitted, clicked: window.__clicked,
|
||
sofanav: document.documentElement.hasAttribute('data-sofanav'),
|
||
}));
|
||
const replies = await page.evaluate(() => window.__attackLog.length);
|
||
await page.close();
|
||
return { ok: r.sofanav && r.submitted === 0 && r.clicked.length === 0,
|
||
detail: `SofaNav running in victim=${r.sofanav}, form submitted ${r.submitted}×, clicks [${r.clicked}], replies to attacker: ${replies}` };
|
||
}
|
||
|
||
// With the extension, frames must still work for the real user: the engine in the top
|
||
// frame reaches the cross-origin frame through extension messaging. The whole-screen graph
|
||
// (Alt+Shift+G twice) draws boxes for the frame's elements only if it got their snapshot.
|
||
async function crossOriginStillWorks(context, how) {
|
||
const page = await context.newPage();
|
||
await page.goto(`${BASE}/test/?ext`);
|
||
await page.waitForTimeout(1500);
|
||
await page.evaluate(() => document.getElementById('crossFrame').scrollIntoView({ block: 'center' }));
|
||
await page.waitForTimeout(500);
|
||
for (let i = 0; i < 2; i++) { await page.keyboard.press('Alt+Shift+G'); await page.waitForTimeout(600); }
|
||
await page.waitForTimeout(800);
|
||
const r = await page.evaluate(() => {
|
||
const f = document.getElementById('crossFrame').getBoundingClientRect();
|
||
const boxes = [...document.querySelectorAll('svg rect')].map((b) => b.getBoundingClientRect())
|
||
.filter((b) => b.left >= f.left && b.right <= f.right && b.top >= f.top && b.bottom <= f.bottom && b.width < f.width);
|
||
// The graph outlines containers in purple. The cross-origin test frame scrolls, so with a
|
||
// working client it is a scroll container (its item count came through extension messaging);
|
||
// without one it would be a plain element with a grey outline.
|
||
const frameBox = [...document.querySelectorAll('svg rect')].find((b) => {
|
||
const q = b.getBoundingClientRect(); return Math.abs(q.left - f.left) < 3 && Math.abs(q.width - f.width) < 6; });
|
||
return { outline: frameBox?.getAttribute('stroke') || 'none' };
|
||
});
|
||
await page.close();
|
||
const ok = r.outline === '#bf5af2';
|
||
return { ok, detail: `cross-origin frame outline in the graph: ${r.outline} (${ok ? `container: client reached through ${how}` : 'not a container: no client'})` };
|
||
}
|
||
|
||
async function launchWith(dir) {
|
||
const profile = fs.mkdtempSync(path.join(os.tmpdir(), 'sofanav-sec-'));
|
||
const ctx = await chromium.launchPersistentContext(profile, {
|
||
channel: 'chromium', headless: true,
|
||
args: [`--disable-extensions-except=${dir}`, `--load-extension=${dir}`],
|
||
});
|
||
return { ctx, done: async () => { await ctx.close(); fs.rmSync(profile, { recursive: true, force: true }); } };
|
||
}
|
||
|
||
// Violentmonkey with the userscript installed. Chrome runs userscript managers only with the
|
||
// extension's "Allow user scripts" switch on, so flip it on its details page first.
|
||
async function launchViolentmonkey() {
|
||
const vm = execFileSync(process.execPath, [path.join(__dirname, 'fetch-violentmonkey.js')], { encoding: 'utf8' }).trim();
|
||
const run = await launchWith(vm);
|
||
const { ctx } = run;
|
||
const sw = ctx.serviceWorkers()[0] || await ctx.waitForEvent('serviceworker');
|
||
const id = sw.url().split('/')[2];
|
||
const settings = await ctx.newPage();
|
||
await settings.goto(`chrome://extensions/?id=${id}`);
|
||
await settings.waitForTimeout(800);
|
||
const on = await settings.evaluate(() => {
|
||
const view = document.querySelector('extensions-manager').shadowRoot.querySelector('extensions-detail-view');
|
||
const row = view.shadowRoot.querySelector('#allow-user-scripts');
|
||
const toggle = row?.shadowRoot.querySelector('cr-toggle');
|
||
if (toggle && !toggle.checked) toggle.click();
|
||
return !!toggle;
|
||
});
|
||
if (!on) throw new Error('Allow user scripts switch not found');
|
||
await settings.goto(`chrome-extension://${id}/options/index.html`);
|
||
await settings.waitForTimeout(1000);
|
||
const res = await settings.evaluate((code) => chrome.runtime.sendMessage({ cmd: 'ParseScript', data: { code } }), fs.readFileSync(USER_JS, 'utf8'));
|
||
if (res?.errors?.length) throw new Error(`install failed: ${JSON.stringify(res.errors)}`);
|
||
await settings.close();
|
||
return run;
|
||
}
|
||
|
||
(async () => {
|
||
let failed = 0;
|
||
const report = (name, res) => { console.log(`${res.ok ? 'PASS' : 'FAIL'} ${name}\n ${res.detail}`); if (!res.ok) failed++; };
|
||
|
||
const browser = await chromium.launch();
|
||
report('E29 forged frame commands do nothing (page script)', await attack(await browser.newContext(), false));
|
||
await browser.close();
|
||
|
||
let run = await launchWith(EXT_DIR);
|
||
try {
|
||
report('E29 forged frame commands do nothing (extension)', await attack(run.ctx, true));
|
||
report('E29 cross-origin frames still work through extension messaging', await crossOriginStillWorks(run.ctx, 'extension messaging'));
|
||
} finally { await run.done(); }
|
||
|
||
run = await launchViolentmonkey();
|
||
try {
|
||
report('E29 forged frame commands do nothing (userscript in Violentmonkey)', await attack(run.ctx, true));
|
||
report('E29 cross-origin frames still work through GM storage (userscript in Violentmonkey)', await crossOriginStillWorks(run.ctx, 'GM storage'));
|
||
} finally { await run.done(); }
|
||
console.log(failed ? `${failed} failed` : 'all passed');
|
||
process.exitCode = failed ? 1 : 0;
|
||
})();
|