308 lines
10 KiB
JavaScript
308 lines
10 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
import { createHash } from 'node:crypto';
|
|
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
import { dirname, resolve } from 'node:path';
|
|
|
|
const DEFAULT_PORT = process.env.LWLT_CDP_PORT || '9223';
|
|
|
|
function parseArgs(argv) {
|
|
const args = { _: [] };
|
|
for (let i = 0; i < argv.length; i += 1) {
|
|
const arg = argv[i];
|
|
if (!arg.startsWith('--')) {
|
|
args._.push(arg);
|
|
continue;
|
|
}
|
|
const key = arg.slice(2);
|
|
const next = argv[i + 1];
|
|
if (!next || next.startsWith('--')) args[key] = true;
|
|
else {
|
|
args[key] = next;
|
|
i += 1;
|
|
}
|
|
}
|
|
return args;
|
|
}
|
|
|
|
function usage() {
|
|
return [
|
|
'Usage:',
|
|
' node tools/browser_order_add_submit_intercept.mjs --call-submit --out reports/submit-intercept.json',
|
|
'',
|
|
'Options:',
|
|
` --port <number> Chrome DevTools port. Default: ${DEFAULT_PORT}`,
|
|
' --call-submit Required. Calls SubmitInfoForm() after installing the ajax intercept.',
|
|
' --out <path> Write redacted intercept report.',
|
|
' --payload-out <path> Write intercepted Act=DoInfoJH payload. Requires --unsafe-include-values.',
|
|
' --unsafe-include-values Keep real intercepted request values in output artifacts.',
|
|
' --help Show this help.',
|
|
'',
|
|
'This tool monkey-patches jQuery.ajax before calling SubmitInfoForm(). It never lets DoInfoJH reach the network.'
|
|
].join('\n');
|
|
}
|
|
|
|
function writeJson(path, data) {
|
|
mkdirSync(dirname(resolve(path)), { recursive: true });
|
|
writeFileSync(path, `${JSON.stringify(data, null, 2)}\n`);
|
|
}
|
|
|
|
function writeText(path, data) {
|
|
mkdirSync(dirname(resolve(path)), { recursive: true });
|
|
writeFileSync(path, data);
|
|
}
|
|
|
|
async function getJson(url) {
|
|
const res = await fetch(url);
|
|
if (!res.ok) throw new Error(`${res.status} ${res.statusText}: ${url}`);
|
|
return res.json();
|
|
}
|
|
|
|
async function getTarget(port) {
|
|
const targets = await getJson(`http://127.0.0.1:${port}/json`);
|
|
const target = targets.find((item) => item.type === 'page' && item.url.includes('ltjt.yunzhi.run'));
|
|
if (!target) throw new Error(`No ltjt.yunzhi.run page target on port ${port}`);
|
|
return target;
|
|
}
|
|
|
|
class CDP {
|
|
constructor(wsUrl) {
|
|
this.wsUrl = wsUrl;
|
|
this.id = 0;
|
|
this.pending = new Map();
|
|
}
|
|
|
|
connect() {
|
|
return new Promise((resolve, reject) => {
|
|
this.ws = new WebSocket(this.wsUrl);
|
|
this.ws.onopen = resolve;
|
|
this.ws.onerror = (event) => reject(new Error(event.message || event.type || 'WebSocket error'));
|
|
this.ws.onmessage = (event) => {
|
|
const msg = JSON.parse(event.data);
|
|
if (!msg.id || !this.pending.has(msg.id)) return;
|
|
const { resolve: ok, reject: fail, timer } = this.pending.get(msg.id);
|
|
clearTimeout(timer);
|
|
this.pending.delete(msg.id);
|
|
msg.error ? fail(new Error(msg.error.message)) : ok(msg.result);
|
|
};
|
|
});
|
|
}
|
|
|
|
send(method, params = {}, timeoutMs = 30000) {
|
|
const id = ++this.id;
|
|
this.ws.send(JSON.stringify({ id, method, params }));
|
|
return new Promise((resolve, reject) => {
|
|
const timer = setTimeout(() => {
|
|
this.pending.delete(id);
|
|
reject(new Error(`Timeout: ${method}`));
|
|
}, timeoutMs);
|
|
this.pending.set(id, { resolve, reject, timer });
|
|
});
|
|
}
|
|
|
|
close() {
|
|
this.ws?.close();
|
|
}
|
|
}
|
|
|
|
async function evaluate(cdp, expression, timeoutMs = 30000) {
|
|
const result = await cdp.send('Runtime.evaluate', {
|
|
expression,
|
|
returnByValue: true,
|
|
awaitPromise: true
|
|
}, timeoutMs);
|
|
if (result.exceptionDetails) throw new Error(result.exceptionDetails.text || 'Runtime.evaluate failed');
|
|
return result.result.value;
|
|
}
|
|
|
|
function submitInterceptExpression() {
|
|
return `(async () => {
|
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
|
|
function findOrderWindow() {
|
|
const queue = [window];
|
|
while (queue.length) {
|
|
const win = queue.shift();
|
|
let doc;
|
|
try { doc = win.document; } catch (err) { continue; }
|
|
if (doc.querySelector('#ListForm') && doc.location.href.includes('/orders_add.asp')) return win;
|
|
Array.from(win.frames).forEach((frame) => queue.push(frame));
|
|
}
|
|
return null;
|
|
}
|
|
|
|
const orderWin = findOrderWindow();
|
|
if (!orderWin) return { ok: false, status: 'orders_add_form_not_found', blockers: ['orders_add_form_not_found'] };
|
|
const doc = orderWin.document;
|
|
const form = doc.querySelector('#ListForm');
|
|
if (!form || form.elements.length < 800) {
|
|
return { ok: false, status: 'orders_add_form_not_ready', blockers: ['orders_add_form_not_ready'], form_element_count: form?.elements?.length || 0 };
|
|
}
|
|
|
|
const $ = orderWin.jQuery || orderWin.$;
|
|
if (!$ || typeof $.ajax !== 'function') {
|
|
return { ok: false, status: 'jquery_ajax_not_found', blockers: ['jquery_ajax_not_found'] };
|
|
}
|
|
if (typeof orderWin.SubmitInfoForm !== 'function') {
|
|
return { ok: false, status: 'SubmitInfoForm_not_found', blockers: ['SubmitInfoForm_not_found'] };
|
|
}
|
|
|
|
const intercepted = [];
|
|
const passthroughAjax = [];
|
|
const alerts = [];
|
|
const originalAjax = $.ajax;
|
|
const originalAlert = orderWin.alert;
|
|
const originalDialogAlert = $.dialog?.alert;
|
|
|
|
function summarizePayload(data) {
|
|
const text = String(data || '');
|
|
const body = text.startsWith('Act=DoInfoJH&') ? text : '';
|
|
const paramsText = body ? body.slice('Act=DoInfoJH&'.length) : text;
|
|
const params = new URLSearchParams(paramsText);
|
|
return {
|
|
starts_with_DoInfoJH: body.startsWith('Act=DoInfoJH&'),
|
|
byte_length: new Blob([text]).size,
|
|
field_count: Array.from(params.keys()).length,
|
|
field_names: Array.from(new Set(Array.from(params.keys()))).sort(),
|
|
value_redacted: true
|
|
};
|
|
}
|
|
|
|
orderWin.alert = function interceptedAlert(message) {
|
|
alerts.push({ type: 'alert', message_length: String(message || '').length, message_redacted: true });
|
|
return undefined;
|
|
};
|
|
if ($.dialog && typeof $.dialog.alert === 'function') {
|
|
$.dialog.alert = function interceptedDialogAlert(message) {
|
|
alerts.push({ type: 'dialog.alert', message_length: String(message || '').length, message_redacted: true });
|
|
return undefined;
|
|
};
|
|
}
|
|
|
|
$.ajax = function ajaxIntercept(options, ...rest) {
|
|
const config = typeof options === 'string' ? { url: options } : { ...(options || {}) };
|
|
const url = String(config.url || '');
|
|
const data = String(config.data || '');
|
|
const isSubmit = /\\/System\\/DAT\\/orders\\.asp/i.test(url) && data.startsWith('Act=DoInfoJH&');
|
|
if (!isSubmit) {
|
|
passthroughAjax.push({ url_redacted: true, url_length: url.length, data_length: data.length });
|
|
return originalAjax.call(this, options, ...rest);
|
|
}
|
|
|
|
const summary = summarizePayload(data);
|
|
intercepted.push({
|
|
url_redacted: '/System/DAT/orders.asp',
|
|
method: config.type || config.method || 'GET',
|
|
dataType: config.dataType || '',
|
|
prevented_from_network: true,
|
|
payload_summary: summary,
|
|
payload: data
|
|
});
|
|
return {
|
|
readyState: 4,
|
|
status: 0,
|
|
statusText: 'intercepted_by_browser_order_add_submit_intercept',
|
|
abort() {}
|
|
};
|
|
};
|
|
|
|
let thrown = '';
|
|
try {
|
|
orderWin.SubmitInfoForm();
|
|
} catch (err) {
|
|
thrown = err.message || String(err);
|
|
}
|
|
await sleep(1000);
|
|
|
|
$.ajax = originalAjax;
|
|
orderWin.alert = originalAlert;
|
|
if ($.dialog && typeof originalDialogAlert === 'function') $.dialog.alert = originalDialogAlert;
|
|
|
|
const blockers = [];
|
|
if (thrown) blockers.push('SubmitInfoForm threw before ajax: ' + thrown.slice(0, 160));
|
|
if (!intercepted.length) blockers.push('SubmitInfoForm did not attempt a DoInfoJH ajax submit while intercepted');
|
|
return {
|
|
ok: true,
|
|
status: blockers.length ? 'submit_intercept_blocked' : 'submit_intercept_captured',
|
|
page: { url: doc.location.href, title: doc.title, form_element_count: form.elements.length },
|
|
intercepted_submit_count: intercepted.length,
|
|
intercepted_submits: intercepted,
|
|
passthrough_ajax_count: passthroughAjax.length,
|
|
alerts,
|
|
blockers,
|
|
submit_safety: {
|
|
live_submit_attempted: false,
|
|
DoInfoJH_network_prevented: true,
|
|
note: 'jQuery.ajax was patched before SubmitInfoForm() was called; intercepted DoInfoJH requests were not sent.'
|
|
}
|
|
};
|
|
})()`;
|
|
}
|
|
|
|
function redactReport(output, includeValues) {
|
|
const report = {
|
|
...output,
|
|
intercepted_submits: (output.intercepted_submits || []).map((item) => {
|
|
const payload = item.payload || '';
|
|
const sha256 = payload ? createHash('sha256').update(payload).digest('hex') : '';
|
|
const redacted = {
|
|
...item,
|
|
payload_sha256: sha256,
|
|
payload_redacted: !includeValues
|
|
};
|
|
if (!includeValues) delete redacted.payload;
|
|
return redacted;
|
|
})
|
|
};
|
|
return report;
|
|
}
|
|
|
|
async function main() {
|
|
const args = parseArgs(process.argv.slice(2));
|
|
if (args.help) {
|
|
console.log(usage());
|
|
return;
|
|
}
|
|
if (!args['call-submit']) {
|
|
console.error(usage());
|
|
process.exit(1);
|
|
}
|
|
|
|
const target = await getTarget(args.port || DEFAULT_PORT);
|
|
const cdp = new CDP(target.webSocketDebuggerUrl);
|
|
await cdp.connect();
|
|
try {
|
|
await cdp.send('Runtime.enable');
|
|
const output = await evaluate(cdp, submitInterceptExpression(), 30000);
|
|
output.generated_at = new Date().toISOString();
|
|
const includeValues = Boolean(args['unsafe-include-values']);
|
|
const report = redactReport(output, includeValues);
|
|
if (args.out) writeJson(args.out, report);
|
|
if (args['payload-out']) {
|
|
if (!includeValues) {
|
|
console.error('Refusing to write payload-out without --unsafe-include-values.');
|
|
} else if (output.blockers?.length) {
|
|
console.error('Refusing to write payload-out because submit intercept is blocked.');
|
|
} else {
|
|
writeText(args['payload-out'], output.intercepted_submits?.[0]?.payload || '');
|
|
}
|
|
}
|
|
console.log(JSON.stringify({
|
|
status: output.status,
|
|
blockers: output.blockers?.length || 0,
|
|
intercepted_submit_count: output.intercepted_submit_count || 0,
|
|
alerts: output.alerts?.length || 0,
|
|
out: args.out || '',
|
|
payload_out: includeValues && !(output.blockers?.length) ? (args['payload-out'] || '') : ''
|
|
}, null, 2));
|
|
if (output.blockers?.length) process.exit(2);
|
|
} finally {
|
|
cdp.close();
|
|
}
|
|
}
|
|
|
|
main().catch((error) => {
|
|
console.error(error.stack || error.message);
|
|
process.exit(1);
|
|
});
|