Files
mmis1000--SofaNav/build-userscript.js
T
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

67 lines
3.4 KiB
JavaScript

// Builds dist/sofanav.user.js from extension/content.js.
// Usage: node build-userscript.js (npm run build)
//
// The build is stamped with a hash of content.js so you can tell which copy is
// running (console: "[sofanav] build <hash>", the View on/off toast, the
// description, and a "// Build <hash>" comment after the metadata block, kept
// out of the block itself because @build isn't a userscript key). The
// @version gets a UTC timestamp suffix (0.3.0.202610121930) so userscript
// managers see every code change as an update; rebuilding unchanged code keeps
// the previous version, so the output (and git) stay unchanged.
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const srcPath = path.join(__dirname, 'extension', 'content.js');
const outPath = path.join(__dirname, 'dist', 'sofanav.user.js');
// Normalized to LF so the hash is the same on every checkout (git stores LF, Windows editors may write CRLF).
const src = fs.readFileSync(srcPath, 'utf8').replace(/\r\n/g, '\n');
const { version } = JSON.parse(fs.readFileSync(path.join(__dirname, 'extension', 'manifest.json'), 'utf8'));
const hash = crypto.createHash('sha256').update(src).digest('hex').slice(0, 12);
const PLACEHOLDER = "const BUILD = 'dev';";
if (!src.includes(PLACEHOLDER)) throw new Error(`${PLACEHOLDER} not found in content.js`);
const FLAG = 'const USERSCRIPT = false;';
if (!src.includes(FLAG)) throw new Error(`${FLAG} not found in content.js`);
const body = src.replace(PLACEHOLDER, `const BUILD = '${hash}';`).replace(FLAG, 'const USERSCRIPT = true;');
// Keep the previous version if the code hasn't changed.
let stamp = null;
if (fs.existsSync(outPath)) {
const prev = fs.readFileSync(outPath, 'utf8');
const prevHash = prev.match(/^\/\/ Build ([0-9a-f]+) =/m)?.[1];
const prevVersion = prev.match(/^\/\/ @version\s+(\S+)/m)?.[1];
if (prevHash === hash && prevVersion?.startsWith(version + '.')) stamp = prevVersion.slice(version.length + 1);
}
stamp ??= new Date().toISOString().replace(/[-:T]/g, '').slice(0, 12); // UTC yyyymmddhhmm
const header = `// ==UserScript==
// @name SofaNav
// @namespace https://sofanav.mmis1000.me
// @version ${version}.${stamp}
// @description Navigate web pages with a controller: D-pad / left stick move focus, A click, B back, X enter, Y graph view. (build ${hash})
// @homepageURL https://mmis1000.me/SofaNav/
// @downloadURL https://mmis1000.me/SofaNav/sofanav.user.js
// @updateURL https://mmis1000.me/SofaNav/sofanav.user.js
// @match *://*/*
// @run-at document-idle
// @grant GM_info
// @grant GM_setValue
// @grant GM_deleteValue
// @grant GM_addValueChangeListener
// @sandbox DOM
// @inject-into content
// ==/UserScript==
// Generated from extension/content.js by build-userscript.js. Edit that file, not this one.
// Build ${hash} = first 12 hex digits of sha256(extension/content.js).
// LB/RB (switch tabs) needs the extension; in a userscript they do nothing.
// @sandbox DOM / @inject-into content run SofaNav in an isolated world, where the page can't
// reach it. Only then does it control cross-origin frames (over GM storage, see content.js
// "frame transport"); without isolation it only enters same-origin frames.
`;
fs.mkdirSync(path.dirname(outPath), { recursive: true });
fs.writeFileSync(outPath, header + body);
console.log(`wrote ${path.relative(__dirname, outPath)} version ${version}.${stamp} build ${hash}`);