assistant/scripts/check-profile-popup.mjs

125 lines
6.7 KiB
JavaScript

import assert from 'node:assert/strict';
import { mkdtemp, rm } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { build } from 'esbuild';
// Pure host-policy checks. No Electron process, real account, UI, or network.
const root = fileURLToPath(new URL('../', import.meta.url));
const directory = await mkdtemp(path.join(os.tmpdir(), 'juzi-profile-popup-'));
const note = 'https://www.xiaohongshu.com/explore/'+'a'.repeat(24)+'?xsec_token=private-note-token';
const profile = 'https://www.xiaohongshu.com/user/profile/'+'b'.repeat(24)+'?xsec_token=private-profile-token#keep';
try {
const output = path.join(directory, 'profile-popup.mjs');
await build({ entryPoints: [path.join(root, 'electron/profile-popup.ts')], platform: 'node',
bundle: true, format: 'esm', outfile: output, logLevel: 'silent' });
const { createProfilePopupHandler } = await import(pathToFileURL(output).href);
const checks = [];
const setup = ({ url = note, allowed = true } = {}) => {
const state = { url, destroyed: false, current: true, time: 100, history: [url], fail: false };
const loads = [], logs = [], deferred = [];
const page = {
getURL: () => state.url,
isDestroyed: () => state.destroyed,
loadURL: async value => {
loads.push(value);
if (state.fail) throw new Error('private failure URL '+value);
state.history.push(value); state.url = value;
},
};
const handler = createProfilePopupHandler(page, { taskId: 'fixture-task',
allowed: () => allowed, isCurrent: () => state.current,
diagnose: value => logs.push(value), now: () => state.time,
defer: callback => deferred.push(callback) });
const click = () => { handler.input('mouseDown'); handler.input('mouseUp'); };
const open = (value = profile, extra = {}) => handler.open({ url: value, ...extra });
const flush = async () => { deferred.splice(0).forEach(callback => callback()); await new Promise(setImmediate); };
return { state, loads, logs, deferred, handler, click, open, flush };
};
{
const test = setup(); test.click();
assert.deepEqual(test.open(), { action: 'deny' });
assert.equal(test.loads.length, 0);
await test.flush();
assert.deepEqual(test.loads, [profile]);
assert.deepEqual(test.state.history, [note, profile]);
assert.equal(test.logs.at(-1).action, 'opened_same_view');
checks.push('native-input profile routes exact actual URL in the same view, retaining the note history entry');
}
for (const invalid of [
'https://evil.example/user/profile/'+'b'.repeat(24),
profile.replace('https:', 'http:'), profile.replace('www.xiaohongshu.com', 'www.xiaohongshu.com.evil.example'),
profile.replace('www.xiaohongshu.com', 'www.xiaohongshu.com:9443'),
profile.replace('www.xiaohongshu.com', 'private-user@www.xiaohongshu.com'),
profile.replace('user/profile/', 'explore/'), profile.replace('b'.repeat(24), 'guessed-name'),
'javascript:window.alert(1)', 'data:text/html,hello', 'https://www.xiaohongshu.com/user/profile/%62'+'b'.repeat(23),
]) {
const test = setup(); test.click();
assert.deepEqual(test.open(invalid), { action: 'deny' }); await test.flush();
assert.equal(test.loads.length, 0);
assert.equal(test.logs.at(-1).category, 'other');
}
checks.push('external, HTTP, lookalike, port, credential, non-profile, malformed-ID, encoded-path and non-web popups are denied');
{
const test = setup(); test.open(); await test.flush();
assert.equal(test.logs.at(-1).action, 'deny_no_native_input');
test.handler.input('mouseMove'); test.open(); await test.flush();
test.handler.input('mouseUp'); test.open(); await test.flush();
assert.equal(test.loads.length, 0);
checks.push('only a paired native press and release, not movement or a lone release, can authorize navigation');
}
for (const elapsed of [-1, 751]) {
const test = setup(); test.click(); test.state.time += elapsed;
test.open(); await test.flush(); assert.equal(test.loads.length, 0);
}
checks.push('expired input and a reversed clock cannot authorize a popup');
for (const url of ['about:blank', 'https://www.xiaohongshu.com/search_result?keyword=test',
'https://www.xiaohongshu.com/user/profile/'+'a'.repeat(24),
note.replace('www.xiaohongshu.com', 'evil.example')]) {
const test = setup({ url }); test.click(); test.open(); await test.flush();
assert.equal(test.loads.length, 0);
}
checks.push('only a same-site concrete note can supply the input context');
for (const update of [test => { test.state.url = note.replace('a'.repeat(24), 'c'.repeat(24)); },
test => { test.state.current = false; }, test => { test.state.destroyed = true; }]) {
const test = setup(); test.click(); test.open(); update(test); await test.flush();
assert.equal(test.loads.length, 0); assert.equal(test.logs.at(-1).action, 'cancel_stale');
}
checks.push('queued routing rechecks the live managed view and original current note');
{
const test = setup({ allowed: false }); test.click(); test.open(); await test.flush();
assert.equal(test.loads.length, 0); assert.equal(test.logs.at(-1).action, 'deny_context');
checks.push('the existing allowed-domain guard remains required');
}
{
const test = setup(); test.click(); test.open(profile, { postBody: { data: ['private form'] } });
await test.flush(); assert.equal(test.loads.length, 0);
checks.push('form POST data is never replayed as a profile navigation');
}
{
const test = setup(); test.click(); test.open('https://evil.example/');
test.open(); await test.flush(); assert.equal(test.loads.length, 0);
checks.push('one native input cannot be reused after another popup consumes it');
}
{
const test = setup(); test.click(); test.open(); test.click(); test.open();
await test.flush(); assert.deepEqual(test.loads, [profile]);
checks.push('concurrent popup requests never duplicate the managed-view navigation');
}
{
const test = setup(); test.state.fail = true; test.click(); test.open(); await test.flush();
assert.equal(test.logs.at(-1).action, 'open_failed');
assert.deepEqual(test.state.history, [note]);
const text = JSON.stringify(test.logs);
assert.ok(!/https:|xsec_token|private|xiaohongshu|user\/profile/.test(text));
assert.ok(test.logs.every(value => Object.keys(value).sort().join(',') === 'action,category,taskId'));
checks.push('failures retain the current view and log only task ID, safe category and action');
}
process.stdout.write(JSON.stringify({ kind: 'pure profile-popup host policy check', passed: checks.length,
checks, externalActions: 0 }, null, 2)+'\n');
} finally {
await rm(directory, { recursive: true, force: true });
}