515 lines
20 KiB
JavaScript
515 lines
20 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
import { createHash } from 'node:crypto';
|
|
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
import { dirname, resolve } from 'node:path';
|
|
|
|
const DEFAULT_PORT = process.env.LWLT_CDP_PORT || '9223';
|
|
const APPROVAL_TOKEN = 'APPROVE-LTJT-DOINFOJH-SUBMIT';
|
|
|
|
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_approved.mjs --preflight-report reports/real-preflight.json --submit-intercept-report reports/real-intercept.json --execute-live-submit --approval-token APPROVE-LTJT-DOINFOJH-SUBMIT --out reports/live-submit.json',
|
|
'',
|
|
'Options:',
|
|
` --port <number> Chrome DevTools port. Default: ${DEFAULT_PORT}`,
|
|
' --preflight-report <path> Required browser_order_add_preflight report with status browser_preflight_passed.',
|
|
' --submit-intercept-report <path> Required browser_order_add_submit_intercept report with matching payload_sha256.',
|
|
' --execute-live-submit Required. Without this flag the tool refuses to submit.',
|
|
` --approval-token <token> Required exact token: ${APPROVAL_TOKEN}`,
|
|
' --out <path> Write redacted live submit report.',
|
|
' --help Show this help.',
|
|
'',
|
|
'This is the only tool intended to allow a real DoInfoJH submit, and only after preflight and intercept hashes match.'
|
|
].join('\n');
|
|
}
|
|
|
|
function readJson(path) {
|
|
return JSON.parse(readFileSync(path, 'utf8'));
|
|
}
|
|
|
|
function writeJson(path, data) {
|
|
mkdirSync(dirname(resolve(path)), { recursive: true });
|
|
writeFileSync(path, `${JSON.stringify(data, null, 2)}\n`);
|
|
}
|
|
|
|
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) {
|
|
const detail = result.exceptionDetails.exception?.description
|
|
|| result.exceptionDetails.exception?.value
|
|
|| result.exceptionDetails.text
|
|
|| 'Runtime.evaluate failed';
|
|
throw new Error(detail);
|
|
}
|
|
return result.result.value;
|
|
}
|
|
|
|
function validateReports(preflightReport, interceptReport) {
|
|
const blockers = [];
|
|
if (preflightReport.status !== 'browser_preflight_passed') {
|
|
blockers.push(`preflight report status must be browser_preflight_passed, got ${preflightReport.status || '<missing>'}`);
|
|
}
|
|
if (preflightReport.submit_safety?.live_submit_attempted !== false) {
|
|
blockers.push('preflight report must prove live_submit_attempted=false');
|
|
}
|
|
if (preflightReport.final_form?.serialized_form_redacted !== true) {
|
|
blockers.push('preflight report must be redacted by default');
|
|
}
|
|
const preflightSerializedHash = preflightReport.final_form?.serialized_form_sha256 || '';
|
|
const preflightSubmitHash = preflightReport.final_form?.submit_payload_sha256 || '';
|
|
const approvedPreflightHash = preflightSubmitHash || preflightSerializedHash;
|
|
if (!approvedPreflightHash) {
|
|
blockers.push('preflight report is missing final_form.serialized_form_sha256 or final_form.submit_payload_sha256');
|
|
}
|
|
if (preflightReport.final_form?.required_missing?.length) {
|
|
blockers.push('preflight report has required_missing fields');
|
|
}
|
|
|
|
if (interceptReport.status !== 'submit_intercept_captured') {
|
|
blockers.push(`submit intercept report status must be submit_intercept_captured, got ${interceptReport.status || '<missing>'}`);
|
|
}
|
|
if (interceptReport.submit_safety?.DoInfoJH_network_prevented !== true) {
|
|
blockers.push('submit intercept report must prove DoInfoJH_network_prevented=true');
|
|
}
|
|
if (interceptReport.intercepted_submit_count !== 1) {
|
|
blockers.push(`submit intercept report must contain exactly one intercepted submit, got ${interceptReport.intercepted_submit_count || 0}`);
|
|
}
|
|
if (interceptReport.alerts?.length) {
|
|
blockers.push('submit intercept report contains validation alerts');
|
|
}
|
|
|
|
const intercepted = interceptReport.intercepted_submits?.[0];
|
|
if (!intercepted?.payload_sha256) {
|
|
blockers.push('submit intercept report is missing intercepted payload_sha256');
|
|
}
|
|
if (intercepted?.prevented_from_network !== true) {
|
|
blockers.push('submit intercept report must prove intercepted submit was prevented from network');
|
|
}
|
|
if (intercepted?.payload_redacted !== true) {
|
|
blockers.push('submit intercept report must be redacted by default');
|
|
}
|
|
|
|
const interceptHash = intercepted?.payload_sha256 || '';
|
|
if (approvedPreflightHash && interceptHash && approvedPreflightHash !== interceptHash) {
|
|
blockers.push('preflight approved payload hash does not match submit-intercept payload_sha256');
|
|
}
|
|
|
|
return {
|
|
blockers,
|
|
expectedPayloadSha256: approvedPreflightHash,
|
|
expectedFieldCount: preflightReport.final_form?.serialized_field_count || intercepted?.payload_summary?.field_count || 0
|
|
};
|
|
}
|
|
|
|
function submitExpression({ expectedPayloadSha256, expectedFieldCount }) {
|
|
return `(async () => {
|
|
const expectedPayloadSha256 = ${JSON.stringify(expectedPayloadSha256)};
|
|
const expectedFieldCount = ${JSON.stringify(expectedFieldCount || 0)};
|
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
function sha256Sync(ascii) {
|
|
function rightRotate(value, amount) {
|
|
return (value >>> amount) | (value << (32 - amount));
|
|
}
|
|
const mathPow = Math.pow;
|
|
const maxWord = mathPow(2, 32);
|
|
const lengthProperty = 'length';
|
|
let i;
|
|
let j;
|
|
const result = [];
|
|
const words = [];
|
|
const asciiBitLength = ascii[lengthProperty] * 8;
|
|
let hash = sha256Sync.h = sha256Sync.h || [];
|
|
let k = sha256Sync.k = sha256Sync.k || [];
|
|
let primeCounter = k[lengthProperty];
|
|
const isComposite = {};
|
|
for (let candidate = 2; primeCounter < 64; candidate += 1) {
|
|
if (!isComposite[candidate]) {
|
|
for (i = 0; i < 313; i += candidate) isComposite[i] = candidate;
|
|
hash[primeCounter] = (mathPow(candidate, 0.5) * maxWord) | 0;
|
|
k[primeCounter] = (mathPow(candidate, 1 / 3) * maxWord) | 0;
|
|
primeCounter += 1;
|
|
}
|
|
}
|
|
ascii += String.fromCharCode(0x80);
|
|
while (ascii[lengthProperty] % 64 - 56) ascii += String.fromCharCode(0);
|
|
for (i = 0; i < ascii[lengthProperty]; i += 1) {
|
|
j = ascii.charCodeAt(i);
|
|
if (j >> 8) throw new Error('sha256Sync only supports 8-bit input');
|
|
words[i >> 2] |= j << ((3 - i) % 4) * 8;
|
|
}
|
|
words[words[lengthProperty]] = ((asciiBitLength / maxWord) | 0);
|
|
words[words[lengthProperty]] = (asciiBitLength);
|
|
for (j = 0; j < words[lengthProperty];) {
|
|
const w = words.slice(j, j += 16);
|
|
const oldHash = hash;
|
|
hash = hash.slice(0, 8);
|
|
for (i = 0; i < 64; i += 1) {
|
|
const w15 = w[i - 15];
|
|
const w2 = w[i - 2];
|
|
const a = hash[0];
|
|
const e = hash[4];
|
|
const temp1 = hash[7]
|
|
+ (rightRotate(e, 6) ^ rightRotate(e, 11) ^ rightRotate(e, 25))
|
|
+ ((e & hash[5]) ^ ((~e) & hash[6]))
|
|
+ k[i]
|
|
+ (w[i] = (i < 16) ? w[i] : (
|
|
w[i - 16]
|
|
+ (rightRotate(w15, 7) ^ rightRotate(w15, 18) ^ (w15 >>> 3))
|
|
+ w[i - 7]
|
|
+ (rightRotate(w2, 17) ^ rightRotate(w2, 19) ^ (w2 >>> 10))
|
|
) | 0);
|
|
const temp2 = (rightRotate(a, 2) ^ rightRotate(a, 13) ^ rightRotate(a, 22))
|
|
+ ((a & hash[1]) ^ (a & hash[2]) ^ (hash[1] & hash[2]));
|
|
hash = [(temp1 + temp2) | 0].concat(hash);
|
|
hash[4] = (hash[4] + temp1) | 0;
|
|
}
|
|
for (i = 0; i < 8; i += 1) hash[i] = (hash[i] + oldHash[i]) | 0;
|
|
}
|
|
for (i = 0; i < 8; i += 1) {
|
|
for (j = 3; j + 1; j -= 1) {
|
|
const b = (hash[i] >> (j * 8)) & 255;
|
|
result.push((b < 16 ? '0' : '') + b.toString(16));
|
|
}
|
|
}
|
|
return result.join('');
|
|
}
|
|
|
|
async function sha256(text) {
|
|
const buffer = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text));
|
|
return Array.from(new Uint8Array(buffer)).map((byte) => byte.toString(16).padStart(2, '0')).join('');
|
|
}
|
|
|
|
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: 'live_submit_blocked', 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: 'live_submit_blocked', 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: 'live_submit_blocked', blockers: ['jquery_ajax_not_found'] };
|
|
}
|
|
if (typeof orderWin.SubmitInfoForm !== 'function') {
|
|
return { ok: false, status: 'live_submit_blocked', blockers: ['SubmitInfoForm_not_found'] };
|
|
}
|
|
|
|
const serializedForm = orderWin.jQuery
|
|
? orderWin.jQuery(form).serialize()
|
|
: Array.from(new FormData(form).entries()).map(([key, value]) => encodeURIComponent(key) + '=' + encodeURIComponent(value)).join('&');
|
|
const requestBody = 'Act=DoInfoJH&' + serializedForm;
|
|
const currentHash = await sha256(requestBody);
|
|
const params = new URLSearchParams(serializedForm);
|
|
const blockers = [];
|
|
if (expectedFieldCount && Array.from(params.keys()).length !== expectedFieldCount) blockers.push('current browser field count does not match approved report field count');
|
|
if (blockers.length) {
|
|
return {
|
|
ok: true,
|
|
status: 'live_submit_blocked',
|
|
current_payload: {
|
|
payload_sha256: currentHash,
|
|
field_count: Array.from(params.keys()).length,
|
|
value_redacted: true
|
|
},
|
|
blockers,
|
|
submit_safety: {
|
|
live_submit_attempted: false
|
|
}
|
|
};
|
|
}
|
|
|
|
const originalAjax = $.ajax;
|
|
const ajaxRecords = [];
|
|
const alerts = [];
|
|
const originalAlert = orderWin.alert;
|
|
const originalDialogAlert = $.dialog?.alert;
|
|
|
|
orderWin.alert = function liveSubmitAlert(message) {
|
|
alerts.push({ type: 'alert', message_length: String(message || '').length, message_redacted: true });
|
|
return originalAlert ? originalAlert.apply(this, arguments) : undefined;
|
|
};
|
|
if ($.dialog && typeof $.dialog.alert === 'function') {
|
|
$.dialog.alert = function liveSubmitDialogAlert(message) {
|
|
alerts.push({ type: 'dialog.alert', message_length: String(message || '').length, message_redacted: true });
|
|
return originalDialogAlert ? originalDialogAlert.apply(this, arguments) : undefined;
|
|
};
|
|
}
|
|
|
|
$.ajax = function liveSubmitAjaxWrapper(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) return originalAjax.call(this, options, ...rest);
|
|
|
|
const record = {
|
|
url_redacted: '/System/DAT/orders.asp',
|
|
method: config.type || config.method || 'GET',
|
|
dataType: config.dataType || '',
|
|
request_payload_sha256: sha256Sync(data),
|
|
request_field_count: expectedFieldCount,
|
|
live_submit_attempted: true,
|
|
prevented_from_network: false,
|
|
completed: false,
|
|
ok: false,
|
|
text_status: '',
|
|
response_byte_length: 0,
|
|
response_contains_login_timeout: false,
|
|
response_contains_permission_text: false,
|
|
response_contains_success_hint: false,
|
|
response_contains_error_hint: false,
|
|
response_redacted: true
|
|
};
|
|
ajaxRecords.push(record);
|
|
if (record.request_payload_sha256 !== expectedPayloadSha256) {
|
|
record.prevented_from_network = true;
|
|
record.completed = true;
|
|
record.ok = false;
|
|
record.text_status = 'blocked_hash_mismatch_before_network';
|
|
return {
|
|
readyState: 4,
|
|
status: 0,
|
|
statusText: 'blocked_hash_mismatch_before_network',
|
|
abort() {}
|
|
};
|
|
}
|
|
|
|
const originalSuccess = config.success;
|
|
const originalError = config.error;
|
|
const originalComplete = config.complete;
|
|
config.success = function successWrapper(responseText, textStatus) {
|
|
const text = String(responseText || '');
|
|
record.ok = true;
|
|
record.text_status = textStatus || '';
|
|
record.response_byte_length = new Blob([text]).size;
|
|
record.response_contains_login_timeout = /登陆|登录|login/i.test(text);
|
|
record.response_contains_permission_text = /权限|permission/i.test(text);
|
|
record.response_contains_success_hint = /成功|保存|添加|完成|ok|success/i.test(text);
|
|
record.response_contains_error_hint = /失败|错误|异常|error|alert\\(/i.test(text) && !record.response_contains_success_hint;
|
|
return originalSuccess ? originalSuccess.apply(this, arguments) : undefined;
|
|
};
|
|
config.error = function errorWrapper(jqXHR, textStatus, errorThrown) {
|
|
record.ok = false;
|
|
record.text_status = textStatus || '';
|
|
record.error_text_redacted = errorThrown ? String(errorThrown).slice(0, 80) : '';
|
|
return originalError ? originalError.apply(this, arguments) : undefined;
|
|
};
|
|
config.complete = function completeWrapper() {
|
|
record.completed = true;
|
|
return originalComplete ? originalComplete.apply(this, arguments) : undefined;
|
|
};
|
|
return originalAjax.call(this, config, ...rest);
|
|
};
|
|
|
|
let thrown = '';
|
|
try {
|
|
orderWin.SubmitInfoForm();
|
|
} catch (err) {
|
|
thrown = err.message || String(err);
|
|
}
|
|
const started = Date.now();
|
|
while (Date.now() - started < 30000) {
|
|
if (ajaxRecords.some((record) => record.completed)) break;
|
|
await sleep(250);
|
|
}
|
|
|
|
$.ajax = originalAjax;
|
|
orderWin.alert = originalAlert;
|
|
if ($.dialog && typeof originalDialogAlert === 'function') $.dialog.alert = originalDialogAlert;
|
|
|
|
const submitBlockers = [];
|
|
if (thrown) submitBlockers.push('SubmitInfoForm threw before ajax');
|
|
if (!ajaxRecords.length) submitBlockers.push('SubmitInfoForm did not attempt DoInfoJH ajax submit');
|
|
if (ajaxRecords.length !== 1) submitBlockers.push('Expected exactly one DoInfoJH ajax submit attempt');
|
|
if (ajaxRecords[0]?.prevented_from_network) submitBlockers.push('DoInfoJH ajax was blocked before network because payload hash mismatched');
|
|
if (ajaxRecords[0] && !ajaxRecords[0].completed) submitBlockers.push('DoInfoJH ajax did not complete before timeout');
|
|
if (ajaxRecords[0]?.response_contains_login_timeout) submitBlockers.push('DoInfoJH response looks like login timeout');
|
|
if (ajaxRecords[0]?.response_contains_permission_text) submitBlockers.push('DoInfoJH response contains permission text');
|
|
|
|
return {
|
|
ok: true,
|
|
status: submitBlockers.length ? 'live_submit_uncertain_or_failed' : 'live_submit_completed',
|
|
page: { url: doc.location.href, title: doc.title, form_element_count: form.elements.length },
|
|
current_payload: {
|
|
payload_sha256: currentHash,
|
|
field_count: Array.from(params.keys()).length,
|
|
value_redacted: true
|
|
},
|
|
ajax_records: ajaxRecords,
|
|
alerts,
|
|
blockers: submitBlockers,
|
|
submit_safety: {
|
|
live_submit_attempted: ajaxRecords.some((record) => record.live_submit_attempted),
|
|
approved_payload_sha256_matched: currentHash === expectedPayloadSha256
|
|
}
|
|
};
|
|
})()`;
|
|
}
|
|
|
|
async function main() {
|
|
const args = parseArgs(process.argv.slice(2));
|
|
if (args.help) {
|
|
console.log(usage());
|
|
return;
|
|
}
|
|
|
|
const localBlockers = [];
|
|
if (!args['preflight-report']) localBlockers.push('--preflight-report is required');
|
|
if (!args['submit-intercept-report']) localBlockers.push('--submit-intercept-report is required');
|
|
if (!args['execute-live-submit']) localBlockers.push('--execute-live-submit is required');
|
|
if (args['approval-token'] !== APPROVAL_TOKEN) localBlockers.push(`--approval-token must exactly equal ${APPROVAL_TOKEN}`);
|
|
|
|
let preflightReport = null;
|
|
let interceptReport = null;
|
|
if (args['preflight-report']) preflightReport = readJson(args['preflight-report']);
|
|
if (args['submit-intercept-report']) interceptReport = readJson(args['submit-intercept-report']);
|
|
if (preflightReport && interceptReport) {
|
|
localBlockers.push(...validateReports(preflightReport, interceptReport).blockers);
|
|
}
|
|
|
|
if (localBlockers.length) {
|
|
const output = {
|
|
generated_at: new Date().toISOString(),
|
|
status: 'live_submit_refused_before_browser',
|
|
blockers: localBlockers,
|
|
submit_safety: {
|
|
live_submit_attempted: false
|
|
}
|
|
};
|
|
if (args.out) writeJson(args.out, output);
|
|
console.log(JSON.stringify({
|
|
status: output.status,
|
|
blockers: localBlockers.length,
|
|
out: args.out || ''
|
|
}, null, 2));
|
|
process.exit(2);
|
|
}
|
|
|
|
const validated = validateReports(preflightReport, interceptReport);
|
|
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, submitExpression({
|
|
expectedPayloadSha256: validated.expectedPayloadSha256,
|
|
expectedFieldCount: validated.expectedFieldCount
|
|
}), 60000);
|
|
output.generated_at = new Date().toISOString();
|
|
output.source_preflight_report = args['preflight-report'];
|
|
output.source_submit_intercept_report = args['submit-intercept-report'];
|
|
output.approval_gate = {
|
|
execute_live_submit_flag: true,
|
|
approval_token_matched: true,
|
|
preflight_and_intercept_hash_matched: true
|
|
};
|
|
if (args.out) writeJson(args.out, output);
|
|
console.log(JSON.stringify({
|
|
status: output.status,
|
|
blockers: output.blockers?.length || 0,
|
|
live_submit_attempted: output.submit_safety?.live_submit_attempted || false,
|
|
ajax_records: output.ajax_records?.length || 0,
|
|
out: args.out || ''
|
|
}, null, 2));
|
|
if (output.status !== 'live_submit_completed') process.exit(2);
|
|
} finally {
|
|
cdp.close();
|
|
}
|
|
}
|
|
|
|
main().catch((error) => {
|
|
console.error(error.stack || error.message);
|
|
process.exit(1);
|
|
});
|