Files
mmis1000andClaude Opus 5.5 36b24f846d SECURITY: userscript reaches cross-origin frames again, over GM storage (E30)
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>
2026-10-13 00:47:29 +08:00

55 lines
2.7 KiB
JavaScript

// Downloads and unpacks the Violentmonkey release the userscript security test runs under.
// Pinned by version and sha256, so CI tests the same build every time.
// Output: .cache/violentmonkey-<version>/ (an unpacked MV3 extension). Prints that path.
const fs = require('fs');
const path = require('path');
const zlib = require('zlib');
const crypto = require('crypto');
const VERSION = '2.49.0';
const URL = `https://github.com/violentmonkey/violentmonkey/releases/download/v${VERSION}/Violentmonkey-mv3-v${VERSION}.zip`;
const SHA256 = '3fa676c803a9698453ed23dfe496d4bc29a4bec52f6a95e55c43c2955921f8ee';
const CACHE = path.join(__dirname, '..', '.cache');
const ZIP = path.join(CACHE, 'dl', `Violentmonkey-mv3-v${VERSION}.zip`);
const OUT = path.join(CACHE, `violentmonkey-${VERSION}`);
// Minimal zip reader (stored and deflated entries, via the central directory).
function unzip(buf, dir) {
let eocd = buf.length - 22;
while (eocd >= 0 && buf.readUInt32LE(eocd) !== 0x06054b50) eocd--;
if (eocd < 0) throw new Error('not a zip');
const count = buf.readUInt16LE(eocd + 10);
let p = buf.readUInt32LE(eocd + 16);
for (let i = 0; i < count; i++) {
const method = buf.readUInt16LE(p + 10), csize = buf.readUInt32LE(p + 20);
const nlen = buf.readUInt16LE(p + 28), xlen = buf.readUInt16LE(p + 30), clen = buf.readUInt16LE(p + 32);
const local = buf.readUInt32LE(p + 42);
const name = buf.toString('utf8', p + 46, p + 46 + nlen);
p += 46 + nlen + xlen + clen;
const target = path.resolve(dir, name);
if (!target.startsWith(path.resolve(dir) + path.sep)) throw new Error(`unsafe path in zip: ${name}`);
if (name.endsWith('/')) { fs.mkdirSync(target, { recursive: true }); continue; }
const start = local + 30 + buf.readUInt16LE(local + 26) + buf.readUInt16LE(local + 28);
const data = buf.subarray(start, start + csize);
fs.mkdirSync(path.dirname(target), { recursive: true });
fs.writeFileSync(target, method === 0 ? data : zlib.inflateRawSync(data));
}
}
(async () => {
if (fs.existsSync(path.join(OUT, 'manifest.json'))) { console.log(OUT); return; }
let buf = fs.existsSync(ZIP) ? fs.readFileSync(ZIP) : null;
if (!buf) {
const res = await fetch(URL);
if (!res.ok) throw new Error(`download failed: ${res.status}`);
buf = Buffer.from(await res.arrayBuffer());
}
const hash = crypto.createHash('sha256').update(buf).digest('hex');
if (hash !== SHA256) throw new Error(`sha256 mismatch: ${hash}`);
fs.mkdirSync(path.dirname(ZIP), { recursive: true });
fs.writeFileSync(ZIP, buf);
fs.rmSync(OUT, { recursive: true, force: true });
unzip(buf, OUT);
console.log(OUT);
})().catch((e) => { console.error(e.message); process.exit(1); });