mirror of
https://github.com/yaklang/yaklang-chrome-extension.git
synced 2026-09-26 05:01:53 +08:00
Enhance architecture documentation and update project dependencies. Introduce new features for browser recording, page callables, and transform capabilities. Improve build scripts and permissions for better functionality.
This commit is contained in:
@@ -0,0 +1 @@
|
||||
export const PAGE_CALLABLE_REGISTRY_KEY = '__YAKIT_PAGE_CALLABLES_V2__' as const;
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { callableExecutionPolicy, settleCallableResult } from './execution';
|
||||
|
||||
describe('page callable execution contract', () => {
|
||||
it('settles a declared Promise result', async () => {
|
||||
await expect(settleCallableResult(
|
||||
Promise.resolve({ signature: 'signed' }),
|
||||
callableExecutionPolicy('promise'),
|
||||
)).resolves.toEqual({ signature: 'signed' });
|
||||
});
|
||||
|
||||
it('supports auto mode for captured business functions', async () => {
|
||||
await expect(settleCallableResult('ciphertext', callableExecutionPolicy('auto'))).resolves.toBe('ciphertext');
|
||||
await expect(settleCallableResult(Promise.resolve('ciphertext'), callableExecutionPolicy('auto'))).resolves.toBe('ciphertext');
|
||||
});
|
||||
|
||||
it('fails closed when an asynchronous result exceeds its deadline', async () => {
|
||||
const never = new Promise(() => undefined);
|
||||
await expect(settleCallableResult(never, callableExecutionPolicy('promise', 250)))
|
||||
.rejects.toThrow('页面函数异步执行超过 250 ms');
|
||||
});
|
||||
|
||||
it('rejects a result that violates its declared mode', async () => {
|
||||
await expect(settleCallableResult(Promise.resolve('late'), callableExecutionPolicy('sync')))
|
||||
.rejects.toThrow('声明为同步执行');
|
||||
await expect(settleCallableResult('early', callableExecutionPolicy('promise')))
|
||||
.rejects.toThrow('声明为异步执行');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import type {
|
||||
BrowserPageCallableExecutionPolicy,
|
||||
BrowserPageCallableResultMode,
|
||||
} from '@/types/models';
|
||||
|
||||
export const DEFAULT_CALLABLE_TIMEOUT_MS = 8_000;
|
||||
export const MIN_CALLABLE_TIMEOUT_MS = 250;
|
||||
export const MAX_CALLABLE_TIMEOUT_MS = 30_000;
|
||||
|
||||
export function callableExecutionPolicy(
|
||||
resultMode: BrowserPageCallableResultMode,
|
||||
timeoutMs = DEFAULT_CALLABLE_TIMEOUT_MS,
|
||||
): BrowserPageCallableExecutionPolicy {
|
||||
return {
|
||||
resultMode,
|
||||
timeoutMs: Math.max(MIN_CALLABLE_TIMEOUT_MS, Math.min(MAX_CALLABLE_TIMEOUT_MS, Math.floor(timeoutMs))),
|
||||
};
|
||||
}
|
||||
|
||||
function isThenable(value: unknown): value is PromiseLike<unknown> {
|
||||
return Boolean(value && (typeof value === 'object' || typeof value === 'function')
|
||||
&& typeof (value as { then?: unknown }).then === 'function');
|
||||
}
|
||||
|
||||
export async function settleCallableResult(
|
||||
value: unknown,
|
||||
execution: BrowserPageCallableExecutionPolicy,
|
||||
): Promise<unknown> {
|
||||
const thenable = isThenable(value);
|
||||
if (execution.resultMode === 'sync') {
|
||||
if (thenable) throw new Error('页面函数声明为同步执行,但返回了 Promise');
|
||||
return value;
|
||||
}
|
||||
if (execution.resultMode === 'promise' && !thenable) {
|
||||
throw new Error('页面函数声明为异步执行,但没有返回 Promise');
|
||||
}
|
||||
if (!thenable) return value;
|
||||
|
||||
return await new Promise<unknown>((resolve, reject) => {
|
||||
let settled = false;
|
||||
const timer = globalThis.setTimeout(() => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
reject(new Error(`页面函数异步执行超过 ${execution.timeoutMs} ms`));
|
||||
}, execution.timeoutMs);
|
||||
Promise.resolve(value).then(
|
||||
(result) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
globalThis.clearTimeout(timer);
|
||||
resolve(result);
|
||||
},
|
||||
(reason) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
globalThis.clearTimeout(timer);
|
||||
reject(reason);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { BrowserPageCallableTransaction } from '@/types/models'
|
||||
import { requestMatchesTransaction, validateRequestTransactionOutput } from './request-transaction'
|
||||
|
||||
const transaction: BrowserPageCallableTransaction = {
|
||||
request: {
|
||||
method: 'POST',
|
||||
url: 'encrypt/aesrsa.php?mode=login',
|
||||
expectedDestinations: ['body.encryptedData', 'body.encryptedKey', 'body.encryptedIv'],
|
||||
},
|
||||
inputMode: 'auto',
|
||||
boundaries: ['fetch', 'xhr', 'beacon', 'form'],
|
||||
}
|
||||
|
||||
describe('request transaction contract', () => {
|
||||
it('matches a relative recorded URL against the exact page request', () => {
|
||||
expect(requestMatchesTransaction(
|
||||
transaction,
|
||||
'post',
|
||||
'http://127.0.0.1:82/login/encrypt/aesrsa.php?mode=login',
|
||||
'http://127.0.0.1:82/login/index.html',
|
||||
)).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects another method, origin, path, or query', () => {
|
||||
const base = 'http://127.0.0.1:82/'
|
||||
expect(requestMatchesTransaction(transaction, 'GET', transaction.request.url, base)).toBe(false)
|
||||
expect(requestMatchesTransaction(transaction, 'POST', 'https://example.test/encrypt/aesrsa.php?mode=login', base)).toBe(false)
|
||||
expect(requestMatchesTransaction(transaction, 'POST', '/encrypt/rsa.php?mode=login', base)).toBe(false)
|
||||
expect(requestMatchesTransaction(transaction, 'POST', '/encrypt/aesrsa.php?mode=other', base)).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts a complete multi-field envelope and fails closed on a missing field', () => {
|
||||
const envelope = {
|
||||
encryptedData: 'ciphertext',
|
||||
encryptedKey: 'wrapped-key',
|
||||
encryptedIv: 'wrapped-iv',
|
||||
}
|
||||
expect(() => validateRequestTransactionOutput(envelope, transaction.request.expectedDestinations)).not.toThrow()
|
||||
expect(() => validateRequestTransactionOutput(
|
||||
{...envelope, encryptedIv: undefined},
|
||||
transaction.request.expectedDestinations,
|
||||
)).toThrow('截获的请求缺少目标字段:body.encryptedIv')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,474 @@
|
||||
import type { BrowserPageCallableExecutionPolicy, BrowserPageCallableTransaction } from '@/types/models'
|
||||
import { callableExecutionPolicy, settleCallableResult } from './execution'
|
||||
|
||||
const MAX_BODY_BYTES = 8 * 1024 * 1024
|
||||
const MAX_CONTROLS = 2_000
|
||||
const MAX_FIELDS = 64
|
||||
const MAX_MUTATIONS = 2_000
|
||||
const DEFAULT_TIMEOUT_MS = 4_000
|
||||
|
||||
interface CapturedRequest {
|
||||
boundary: 'fetch' | 'xhr' | 'beacon' | 'form'
|
||||
method: string
|
||||
url: string
|
||||
headers: Record<string, string>
|
||||
bodyText: string
|
||||
}
|
||||
|
||||
interface TransactionContext {
|
||||
domInputCount: number
|
||||
}
|
||||
|
||||
export interface RequestTransactionInvocation {
|
||||
transaction: BrowserPageCallableTransaction
|
||||
logicalInput: unknown
|
||||
invoke(context: TransactionContext): unknown
|
||||
timeoutMs?: number
|
||||
}
|
||||
|
||||
interface RollbackController {
|
||||
finish(): number
|
||||
}
|
||||
|
||||
interface MutableControl extends Element {
|
||||
value?: string
|
||||
checked?: boolean
|
||||
selectedIndex?: number
|
||||
name?: string
|
||||
id: string
|
||||
type?: string
|
||||
}
|
||||
|
||||
function error(message: string): Error {
|
||||
return new Error(`请求事务失败:${message}`)
|
||||
}
|
||||
|
||||
function delay(milliseconds: number): Promise<void> {
|
||||
return new Promise((resolve) => window.setTimeout(resolve, milliseconds))
|
||||
}
|
||||
|
||||
function absoluteUrl(value: string): string {
|
||||
try { return new URL(value, location.href).toString() } catch { return value }
|
||||
}
|
||||
|
||||
function runtimeBaseUrl(): string {
|
||||
return typeof location === 'undefined' ? 'http://localhost/' : location.href
|
||||
}
|
||||
|
||||
function comparableUrl(value: string, baseUrl: string): string {
|
||||
try {
|
||||
const url = new URL(value, baseUrl)
|
||||
return `${url.origin}${url.pathname}${url.search}`
|
||||
} catch {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
export function requestMatchesTransaction(
|
||||
transaction: BrowserPageCallableTransaction,
|
||||
method: string,
|
||||
url: string,
|
||||
baseUrl = runtimeBaseUrl(),
|
||||
): boolean {
|
||||
return transaction.request.method.toUpperCase() === method.toUpperCase()
|
||||
&& comparableUrl(transaction.request.url, baseUrl) === comparableUrl(url, baseUrl)
|
||||
}
|
||||
|
||||
function byteLength(value: string): number {
|
||||
return new TextEncoder().encode(value).byteLength
|
||||
}
|
||||
|
||||
async function bodyText(value: unknown): Promise<string> {
|
||||
if (value === undefined || value === null) return ''
|
||||
if (typeof value === 'string') return value
|
||||
if (typeof URLSearchParams !== 'undefined' && value instanceof URLSearchParams) return value.toString()
|
||||
if (typeof Blob !== 'undefined' && value instanceof Blob) return value.text()
|
||||
if (typeof FormData !== 'undefined' && value instanceof FormData) {
|
||||
const form = new URLSearchParams()
|
||||
for (const [key, item] of value.entries()) {
|
||||
if (typeof item !== 'string') throw error(`表单字段 ${key} 包含文件,暂不允许自动回放`)
|
||||
form.append(key, item)
|
||||
}
|
||||
return form.toString()
|
||||
}
|
||||
if (value instanceof ArrayBuffer) return new TextDecoder().decode(new Uint8Array(value))
|
||||
if (ArrayBuffer.isView(value)) return new TextDecoder().decode(new Uint8Array(value.buffer, value.byteOffset, value.byteLength))
|
||||
throw error(`不支持的请求 Body 类型 ${Object.prototype.toString.call(value)}`)
|
||||
}
|
||||
|
||||
function headerRecord(headers: Headers): Record<string, string> {
|
||||
const output: Record<string, string> = Object.create(null) as Record<string, string>
|
||||
headers.forEach((value, key) => { output[key.toLowerCase()] = value })
|
||||
return output
|
||||
}
|
||||
|
||||
function parseForm(value: string): Record<string, string | string[]> {
|
||||
const output: Record<string, string | string[]> = Object.create(null) as Record<string, string | string[]>
|
||||
for (const [key, item] of new URLSearchParams(value)) {
|
||||
const previous = output[key]
|
||||
output[key] = previous === undefined ? item : Array.isArray(previous) ? [...previous, item] : [previous, item]
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
function capturedBody(request: CapturedRequest): unknown {
|
||||
const contentType = request.headers['content-type']?.toLowerCase() || ''
|
||||
if (contentType.includes('application/json') || /^[\s\n\r]*[\[{]/.test(request.bodyText)) {
|
||||
try { return JSON.parse(request.bodyText) as unknown } catch { throw error('页面生成的请求 Body 不是有效 JSON') }
|
||||
}
|
||||
if (contentType.includes('application/x-www-form-urlencoded')) return parseForm(request.bodyText)
|
||||
return request.bodyText
|
||||
}
|
||||
|
||||
function readOwnPath(input: unknown, path: string): unknown {
|
||||
let current = input
|
||||
for (const segment of path.split('.').filter(Boolean)) {
|
||||
if (!current || typeof current !== 'object' || !Object.prototype.hasOwnProperty.call(current, segment)) return undefined
|
||||
current = (current as Record<string, unknown>)[segment]
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
||||
export function validateRequestTransactionOutput(value: unknown, destinations: string[]): void {
|
||||
const missing = destinations.filter((destination) => {
|
||||
const path = destination === 'body' ? '' : destination.startsWith('body.') ? destination.slice(5) : destination
|
||||
return path ? readOwnPath(value, path) === undefined : value === undefined
|
||||
})
|
||||
if (missing.length) throw error(`截获的请求缺少目标字段:${missing.join('、')}`)
|
||||
}
|
||||
|
||||
function logicalObject(value: unknown): Record<string, unknown> | undefined {
|
||||
let current = value
|
||||
if (typeof current === 'string') {
|
||||
try { current = JSON.parse(current) as unknown } catch { return undefined }
|
||||
}
|
||||
return current && typeof current === 'object' && !Array.isArray(current)
|
||||
? current as Record<string, unknown>
|
||||
: undefined
|
||||
}
|
||||
|
||||
interface LogicalField {
|
||||
path: string
|
||||
key: string
|
||||
value: unknown
|
||||
}
|
||||
|
||||
function logicalFields(value: unknown): LogicalField[] {
|
||||
const root = logicalObject(value)
|
||||
if (!root) return []
|
||||
const output: LogicalField[] = []
|
||||
const visit = (current: Record<string, unknown>, prefix: string, depth: number) => {
|
||||
if (depth > 4 || output.length >= MAX_FIELDS) return
|
||||
for (const [key, item] of Object.entries(current)) {
|
||||
if (output.length >= MAX_FIELDS) break
|
||||
const path = prefix ? `${prefix}.${key}` : key
|
||||
if (item && typeof item === 'object' && !Array.isArray(item)) visit(item as Record<string, unknown>, path, depth + 1)
|
||||
else output.push({ path, key, value: item })
|
||||
}
|
||||
}
|
||||
visit(root, '', 0)
|
||||
return output
|
||||
}
|
||||
|
||||
function controlNames(control: MutableControl): string[] {
|
||||
const name = typeof control.name === 'string' ? control.name : ''
|
||||
return [name, control.id, name.replace(/\[([^\]]+)\]/g, '.$1')].filter(Boolean)
|
||||
}
|
||||
|
||||
function setControlValue(control: MutableControl, value: unknown): void {
|
||||
const type = String(control.type || '').toLowerCase()
|
||||
if ((type === 'checkbox' || type === 'radio') && typeof control.checked === 'boolean') {
|
||||
if (type === 'radio') control.checked = String(control.value ?? '') === String(value)
|
||||
else control.checked = typeof value === 'boolean' ? value : Array.isArray(value)
|
||||
? value.map(String).includes(String(control.value ?? ''))
|
||||
: Boolean(value)
|
||||
return
|
||||
}
|
||||
if ('value' in control) {
|
||||
control.value = value === undefined || value === null ? ''
|
||||
: typeof value === 'object' ? JSON.stringify(value) : String(value)
|
||||
}
|
||||
}
|
||||
|
||||
function bindLogicalInput(value: unknown): number {
|
||||
const fields = logicalFields(value)
|
||||
if (!fields.length) return 0
|
||||
const controls = [...document.querySelectorAll('input, textarea, select')].slice(0, MAX_CONTROLS) as MutableControl[]
|
||||
const missing: string[] = []
|
||||
let matched = 0
|
||||
for (const field of fields) {
|
||||
const candidates = controls.filter((control) => controlNames(control).some((name) => (
|
||||
name === field.path || name === field.key || name.endsWith(`.${field.path}`) || name.endsWith(`.${field.key}`)
|
||||
)))
|
||||
if (!candidates.length) {
|
||||
missing.push(field.path)
|
||||
continue
|
||||
}
|
||||
candidates.forEach((control) => setControlValue(control, field.value))
|
||||
matched += 1
|
||||
}
|
||||
if (matched && missing.length) throw error(`无法把明文字段映射到页面输入:${missing.join('、')}`)
|
||||
return matched
|
||||
}
|
||||
|
||||
function beginDomRollback(): RollbackController {
|
||||
const controls = [...document.querySelectorAll('input, textarea, select')].slice(0, MAX_CONTROLS) as MutableControl[]
|
||||
const controlSnapshots = controls.map((control) => ({
|
||||
control,
|
||||
value: control.value,
|
||||
checked: control.checked,
|
||||
selectedIndex: control.selectedIndex,
|
||||
}))
|
||||
const mutations: MutationRecord[] = []
|
||||
const root = document.documentElement
|
||||
const observer = root && typeof MutationObserver !== 'undefined'
|
||||
? new MutationObserver((records) => {
|
||||
if (mutations.length < MAX_MUTATIONS) mutations.push(...records.slice(0, MAX_MUTATIONS - mutations.length))
|
||||
})
|
||||
: undefined
|
||||
observer?.observe(root, {
|
||||
subtree: true,
|
||||
childList: true,
|
||||
attributes: true,
|
||||
attributeOldValue: true,
|
||||
characterData: true,
|
||||
characterDataOldValue: true,
|
||||
})
|
||||
let finished = false
|
||||
return {
|
||||
finish() {
|
||||
if (finished) return mutations.length
|
||||
finished = true
|
||||
if (observer) mutations.push(...observer.takeRecords().slice(0, Math.max(0, MAX_MUTATIONS - mutations.length)))
|
||||
observer?.disconnect()
|
||||
for (const snapshot of controlSnapshots) {
|
||||
if (snapshot.value !== undefined) snapshot.control.value = snapshot.value
|
||||
if (snapshot.checked !== undefined) snapshot.control.checked = snapshot.checked
|
||||
if (snapshot.selectedIndex !== undefined) snapshot.control.selectedIndex = snapshot.selectedIndex
|
||||
}
|
||||
for (const mutation of [...mutations].reverse()) {
|
||||
try {
|
||||
if (mutation.type === 'attributes') {
|
||||
if (!mutation.attributeName) continue
|
||||
if (mutation.oldValue === null) (mutation.target as Element).removeAttributeNS(mutation.attributeNamespace, mutation.attributeName)
|
||||
else (mutation.target as Element).setAttributeNS(mutation.attributeNamespace, mutation.attributeName, mutation.oldValue)
|
||||
} else if (mutation.type === 'characterData') {
|
||||
mutation.target.nodeValue = mutation.oldValue
|
||||
} else {
|
||||
mutation.addedNodes.forEach((node) => { if (node.parentNode === mutation.target) mutation.target.removeChild(node) })
|
||||
const before = mutation.nextSibling?.parentNode === mutation.target ? mutation.nextSibling : null
|
||||
mutation.removedNodes.forEach((node) => mutation.target.insertBefore(node, before))
|
||||
}
|
||||
} catch {
|
||||
// Best-effort rollback is followed by fail-closed validation at the request boundary.
|
||||
}
|
||||
}
|
||||
return mutations.length
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function setMethod<T extends object, K extends keyof T>(target: T, key: K, value: T[K], restorers: Array<() => void>): void {
|
||||
const previous = target[key]
|
||||
try {
|
||||
target[key] = value
|
||||
restorers.push(() => { target[key] = previous })
|
||||
} catch {
|
||||
// A non-writable optional boundary remains protected by the other installed boundaries.
|
||||
}
|
||||
}
|
||||
|
||||
function formRequest(form: HTMLFormElement, submitter?: HTMLElement | null): CapturedRequest {
|
||||
const method = (form.method || 'GET').toUpperCase()
|
||||
const url = absoluteUrl(form.action || location.href)
|
||||
const formData = new FormData(form, submitter instanceof HTMLButtonElement || submitter instanceof HTMLInputElement ? submitter : undefined)
|
||||
const encoded = new URLSearchParams()
|
||||
for (const [key, value] of formData.entries()) {
|
||||
if (typeof value !== 'string') throw error(`表单字段 ${key} 包含文件,暂不允许自动回放`)
|
||||
encoded.append(key, value)
|
||||
}
|
||||
return {
|
||||
boundary: 'form', method, url,
|
||||
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
||||
bodyText: encoded.toString(),
|
||||
}
|
||||
}
|
||||
|
||||
export async function executeRequestTransaction(input: RequestTransactionInvocation): Promise<unknown> {
|
||||
const timeoutMs = callableExecutionPolicy('auto', input.timeoutMs ?? DEFAULT_TIMEOUT_MS).timeoutMs
|
||||
const rollback = beginDomRollback()
|
||||
const restorers: Array<() => void> = []
|
||||
let captured: CapturedRequest | undefined
|
||||
let captureFailure: Error | undefined
|
||||
let resolveCapture!: () => void
|
||||
const captureSignal = new Promise<void>((resolve) => { resolveCapture = resolve })
|
||||
|
||||
const capture = async (request: CapturedRequest): Promise<void> => {
|
||||
if (captured || captureFailure) {
|
||||
captureFailure = error('页面流程产生了多个网络请求,无法唯一确定转换边界')
|
||||
resolveCapture()
|
||||
throw captureFailure
|
||||
}
|
||||
if (!requestMatchesTransaction(input.transaction, request.method, request.url)) {
|
||||
captureFailure = error(`页面尝试访问未授权请求 ${request.method} ${request.url}`)
|
||||
resolveCapture()
|
||||
throw captureFailure
|
||||
}
|
||||
if (byteLength(request.bodyText) > MAX_BODY_BYTES) {
|
||||
captureFailure = error('页面生成的请求 Body 超过 8 MiB')
|
||||
resolveCapture()
|
||||
throw captureFailure
|
||||
}
|
||||
captured = request
|
||||
resolveCapture()
|
||||
}
|
||||
|
||||
const previousFetch = window.fetch
|
||||
setMethod(window, 'fetch', (async function transactionFetch(requestInput: RequestInfo | URL, init?: RequestInit) {
|
||||
const request = new Request(requestInput, init)
|
||||
await capture({
|
||||
boundary: 'fetch',
|
||||
method: request.method.toUpperCase(),
|
||||
url: request.url,
|
||||
headers: headerRecord(request.headers),
|
||||
bodyText: await request.clone().text(),
|
||||
})
|
||||
return new Response(JSON.stringify({ success: false, error: 'request captured by Yakit Browser Agent' }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}) as typeof previousFetch, restorers)
|
||||
|
||||
const xhrMetadata = new WeakMap<XMLHttpRequest, { method: string; url: string; headers: Record<string, string> }>()
|
||||
const xhrPrototype = XMLHttpRequest.prototype
|
||||
const previousOpen = xhrPrototype.open
|
||||
const previousSetHeader = xhrPrototype.setRequestHeader
|
||||
const previousSend = xhrPrototype.send
|
||||
setMethod(xhrPrototype, 'open', (function transactionOpen(this: XMLHttpRequest, method: string, url: string | URL, ...rest: unknown[]) {
|
||||
xhrMetadata.set(this, { method: method.toUpperCase(), url: absoluteUrl(String(url)), headers: Object.create(null) as Record<string, string> })
|
||||
return Reflect.apply(previousOpen, this, [method, url, ...rest] as never)
|
||||
}) as typeof previousOpen, restorers)
|
||||
setMethod(xhrPrototype, 'setRequestHeader', (function transactionSetHeader(this: XMLHttpRequest, name: string, value: string) {
|
||||
const metadata = xhrMetadata.get(this)
|
||||
if (metadata) metadata.headers[name.toLowerCase()] = value
|
||||
return Reflect.apply(previousSetHeader, this, [name, value])
|
||||
}) as typeof previousSetHeader, restorers)
|
||||
setMethod(xhrPrototype, 'send', (function transactionSend(this: XMLHttpRequest, body?: Document | XMLHttpRequestBodyInit | null) {
|
||||
const metadata = xhrMetadata.get(this)
|
||||
if (!metadata) throw error('XHR 没有可验证的 open 边界')
|
||||
void bodyText(body).then((text) => capture({ boundary: 'xhr', ...metadata, bodyText: text })).catch((reason) => {
|
||||
captureFailure = reason instanceof Error ? reason : error(String(reason))
|
||||
resolveCapture()
|
||||
})
|
||||
}) as typeof previousSend, restorers)
|
||||
|
||||
if (typeof navigator.sendBeacon === 'function') {
|
||||
setMethod(navigator, 'sendBeacon', (function transactionBeacon(url: string | URL, data?: BodyInit | null) {
|
||||
void bodyText(data).then((text) => capture({
|
||||
boundary: 'beacon', method: 'POST', url: absoluteUrl(String(url)), headers: {}, bodyText: text,
|
||||
})).catch((reason) => {
|
||||
captureFailure = reason instanceof Error ? reason : error(String(reason))
|
||||
resolveCapture()
|
||||
})
|
||||
return true
|
||||
}) as typeof navigator.sendBeacon, restorers)
|
||||
}
|
||||
|
||||
const formPrototype = HTMLFormElement.prototype
|
||||
const previousSubmit = formPrototype.submit
|
||||
const previousRequestSubmit = formPrototype.requestSubmit
|
||||
setMethod(formPrototype, 'submit', (function transactionSubmit(this: HTMLFormElement) {
|
||||
void capture(formRequest(this)).catch(() => undefined)
|
||||
}) as typeof previousSubmit, restorers)
|
||||
setMethod(formPrototype, 'requestSubmit', (function transactionRequestSubmit(this: HTMLFormElement, submitter?: HTMLElement | null) {
|
||||
void capture(formRequest(this, submitter)).catch(() => undefined)
|
||||
}) as typeof previousRequestSubmit, restorers)
|
||||
const submitListener = (event: SubmitEvent) => {
|
||||
event.preventDefault()
|
||||
event.stopImmediatePropagation()
|
||||
if (event.target instanceof HTMLFormElement) void capture(formRequest(event.target, event.submitter)).catch(() => undefined)
|
||||
}
|
||||
document.addEventListener('submit', submitListener, true)
|
||||
restorers.push(() => document.removeEventListener('submit', submitListener, true))
|
||||
|
||||
setMethod(window, 'alert', (() => undefined) as typeof window.alert, restorers)
|
||||
setMethod(window, 'confirm', (() => false) as typeof window.confirm, restorers)
|
||||
setMethod(window, 'prompt', (() => null) as typeof window.prompt, restorers)
|
||||
setMethod(window, 'open', (() => null) as typeof window.open, restorers)
|
||||
|
||||
let invocationFailure: unknown
|
||||
let returned: unknown
|
||||
try {
|
||||
const domInputCount = bindLogicalInput(input.logicalInput)
|
||||
try { returned = input.invoke({ domInputCount }) } catch (reason) {
|
||||
invocationFailure = reason
|
||||
resolveCapture()
|
||||
}
|
||||
void Promise.resolve(returned).catch((reason) => {
|
||||
invocationFailure = reason
|
||||
if (!captured) resolveCapture()
|
||||
})
|
||||
await Promise.race([
|
||||
captureSignal,
|
||||
delay(timeoutMs).then(() => {
|
||||
if (!captured && !captureFailure) captureFailure = error('等待页面生成目标请求超时')
|
||||
}),
|
||||
])
|
||||
if (captureFailure) throw captureFailure
|
||||
if (!captured) {
|
||||
if (invocationFailure instanceof Error) throw error(invocationFailure.message)
|
||||
throw error('页面函数没有产生目标请求')
|
||||
}
|
||||
await delay(0)
|
||||
if (captureFailure) throw captureFailure
|
||||
if (invocationFailure instanceof Error) throw error(invocationFailure.message)
|
||||
const value = capturedBody(captured)
|
||||
validateRequestTransactionOutput(value, input.transaction.request.expectedDestinations)
|
||||
return value
|
||||
} finally {
|
||||
for (const restore of restorers.reverse()) {
|
||||
try { restore() } catch { /* The document may have been replaced while fail-closing. */ }
|
||||
}
|
||||
rollback.finish()
|
||||
}
|
||||
}
|
||||
|
||||
export async function executeSideEffectFreeCallable(
|
||||
invoke: () => unknown,
|
||||
execution: BrowserPageCallableExecutionPolicy,
|
||||
): Promise<unknown> {
|
||||
const rollback = beginDomRollback()
|
||||
const restorers: Array<() => void> = []
|
||||
let attemptedBoundary = ''
|
||||
const block = (boundary: string): never => {
|
||||
attemptedBoundary = boundary
|
||||
throw error(`普通页面函数尝试触发 ${boundary},必须改用请求事务`)
|
||||
}
|
||||
setMethod(window, 'fetch', (() => block('Fetch')) as typeof window.fetch, restorers)
|
||||
setMethod(XMLHttpRequest.prototype, 'send', (function blockedXhrSend() { return block('XHR') }) as typeof XMLHttpRequest.prototype.send, restorers)
|
||||
if (typeof navigator.sendBeacon === 'function') {
|
||||
setMethod(navigator, 'sendBeacon', (() => block('Beacon')) as typeof navigator.sendBeacon, restorers)
|
||||
}
|
||||
setMethod(HTMLFormElement.prototype, 'submit', (function blockedSubmit() { return block('Form Submit') }) as typeof HTMLFormElement.prototype.submit, restorers)
|
||||
setMethod(HTMLFormElement.prototype, 'requestSubmit', (function blockedRequestSubmit() { return block('Form Submit') }) as typeof HTMLFormElement.prototype.requestSubmit, restorers)
|
||||
const submitListener = (event: SubmitEvent) => {
|
||||
event.preventDefault()
|
||||
event.stopImmediatePropagation()
|
||||
attemptedBoundary = 'Form Submit'
|
||||
}
|
||||
document.addEventListener('submit', submitListener, true)
|
||||
restorers.push(() => document.removeEventListener('submit', submitListener, true))
|
||||
try {
|
||||
const value = await settleCallableResult(invoke(), execution)
|
||||
await Promise.resolve()
|
||||
if (attemptedBoundary) throw error(`普通页面函数尝试触发 ${attemptedBoundary},必须改用请求事务`)
|
||||
const mutationCount = rollback.finish()
|
||||
if (mutationCount) throw error('普通页面函数修改了页面 DOM,必须改用请求事务')
|
||||
return value
|
||||
} finally {
|
||||
for (const restore of restorers.reverse()) {
|
||||
try { restore() } catch { /* The document may have been replaced while fail-closing. */ }
|
||||
}
|
||||
rollback.finish()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { BrowserPageCallable } from '@/types/models';
|
||||
import { normalizeCallable } from './service';
|
||||
|
||||
const target = { tabId: 7, frameId: 0, documentId: 'document-1' };
|
||||
const callable: Omit<BrowserPageCallable, 'target'> = {
|
||||
id: 'transaction-1',
|
||||
name: '登录请求业务封装',
|
||||
kind: 'request-transaction',
|
||||
operation: 'buildLoginEnvelope',
|
||||
origin: 'https://example.test',
|
||||
lifecycle: 'document',
|
||||
execution: { resultMode: 'auto', timeoutMs: 10_000 },
|
||||
inputSlots: [{ id: 'body', name: 'body', index: 0, role: 'data', dataType: 'object', required: true, retained: false }],
|
||||
output: {
|
||||
dataType: 'object', encoding: 'json', shape: 'envelope',
|
||||
paths: ['body.encryptedData', 'body.encryptedKey'],
|
||||
},
|
||||
transaction: {
|
||||
request: {
|
||||
method: 'POST', url: 'https://example.test/login',
|
||||
expectedDestinations: ['body.encryptedData', 'body.encryptedKey'],
|
||||
},
|
||||
inputMode: 'auto',
|
||||
boundaries: ['fetch', 'xhr'],
|
||||
},
|
||||
provenance: { eventId: 'request-1' },
|
||||
createdAt: 1,
|
||||
};
|
||||
|
||||
describe('page callable metadata contract', () => {
|
||||
it('accepts an explicit asynchronous multi-output envelope', () => {
|
||||
expect(normalizeCallable(callable, target)).toMatchObject({
|
||||
target,
|
||||
execution: { resultMode: 'auto', timeoutMs: 10_000 },
|
||||
output: { shape: 'envelope', paths: ['body.encryptedData', 'body.encryptedKey'] },
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects a transaction whose declared envelope differs from its request boundary', () => {
|
||||
expect(normalizeCallable({
|
||||
...callable,
|
||||
output: { ...callable.output, paths: ['body.encryptedData'] },
|
||||
}, target)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects missing execution policy instead of silently selecting legacy behavior', () => {
|
||||
const { execution: _execution, ...legacy } = callable;
|
||||
expect(normalizeCallable(legacy, target)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,228 @@
|
||||
import { browser } from 'wxt/browser';
|
||||
import type {
|
||||
BrowserPageCallable,
|
||||
BrowserPageCallableExecution,
|
||||
BrowserTarget,
|
||||
BrowserTransformDirection,
|
||||
BrowserTransformDirectionName,
|
||||
BrowserTransformExecution,
|
||||
BrowserTransformPacket,
|
||||
} from '@/types/models';
|
||||
import { ExtensionError } from '@/shared/errors';
|
||||
import { resolveDocumentTarget, scriptingTarget } from '@/platform/browser/targets';
|
||||
import { PAGE_RECORDER_PROTOCOL_VERSION, PAGE_RECORDER_REGISTRY_KEY } from '@/features/browser-recording/constants';
|
||||
import { normalizeBrowserRecordingCrypto } from '@/features/browser-crypto/model';
|
||||
import {
|
||||
MAX_CALLABLE_TIMEOUT_MS,
|
||||
MIN_CALLABLE_TIMEOUT_MS,
|
||||
callableExecutionPolicy,
|
||||
} from './execution';
|
||||
|
||||
const MAX_CALLABLES = 128;
|
||||
|
||||
type RawCallable = Omit<BrowserPageCallable, 'target'> & { target?: BrowserTarget };
|
||||
|
||||
function normalizeTransaction(value: unknown): BrowserPageCallable['transaction'] {
|
||||
if (!value || typeof value !== 'object') return undefined;
|
||||
const input = value as Partial<NonNullable<BrowserPageCallable['transaction']>>;
|
||||
const request = input.request as Partial<NonNullable<BrowserPageCallable['transaction']>['request']> | undefined;
|
||||
if (!request || typeof request.method !== 'string' || typeof request.url !== 'string'
|
||||
|| !Array.isArray(request.expectedDestinations) || request.expectedDestinations.length === 0
|
||||
|| request.expectedDestinations.some((item) => typeof item !== 'string' || !item.trim())) return undefined;
|
||||
const boundaries = Array.isArray(input.boundaries)
|
||||
? input.boundaries.filter((item): item is NonNullable<BrowserPageCallable['transaction']>['boundaries'][number] => (
|
||||
['fetch', 'xhr', 'beacon', 'form'].includes(String(item))
|
||||
)).slice(0, 4)
|
||||
: ['fetch', 'xhr', 'beacon', 'form'] as NonNullable<BrowserPageCallable['transaction']>['boundaries'];
|
||||
if (!boundaries.length) return undefined;
|
||||
return {
|
||||
request: {
|
||||
method: request.method.toUpperCase().slice(0, 16),
|
||||
url: request.url.slice(0, 4_096),
|
||||
expectedDestinations: request.expectedDestinations.slice(0, 64).map((item) => item.slice(0, 512)),
|
||||
},
|
||||
inputMode: 'auto',
|
||||
boundaries,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeExecution(value: unknown): BrowserPageCallable['execution'] | undefined {
|
||||
if (!value || typeof value !== 'object') return undefined;
|
||||
const input = value as Partial<BrowserPageCallable['execution']>;
|
||||
if (!['sync', 'promise', 'auto'].includes(String(input.resultMode))
|
||||
|| !Number.isSafeInteger(input.timeoutMs)
|
||||
|| Number(input.timeoutMs) < MIN_CALLABLE_TIMEOUT_MS
|
||||
|| Number(input.timeoutMs) > MAX_CALLABLE_TIMEOUT_MS) return undefined;
|
||||
return callableExecutionPolicy(input.resultMode as BrowserPageCallable['execution']['resultMode'], Number(input.timeoutMs));
|
||||
}
|
||||
|
||||
function normalizeCallable(value: unknown, target: BrowserTarget): BrowserPageCallable | undefined {
|
||||
if (!value || typeof value !== 'object') return undefined;
|
||||
const input = value as Partial<RawCallable>;
|
||||
if (typeof input.id !== 'string' || typeof input.name !== 'string'
|
||||
|| !['recorded-call', 'business-closure', 'request-transaction', 'global-function'].includes(String(input.kind))
|
||||
|| typeof input.operation !== 'string' || typeof input.origin !== 'string'
|
||||
|| input.lifecycle !== 'document' || !Array.isArray(input.inputSlots)
|
||||
|| !input.output || typeof input.output !== 'object' || !input.provenance || typeof input.provenance !== 'object') return undefined;
|
||||
const transaction = normalizeTransaction(input.transaction);
|
||||
const execution = normalizeExecution(input.execution);
|
||||
if (!execution) return undefined;
|
||||
if (input.kind === 'request-transaction' && !transaction) return undefined;
|
||||
const outputShape = input.output.shape;
|
||||
const outputPaths = input.output.paths;
|
||||
if (!['value', 'envelope'].includes(String(outputShape)) || !Array.isArray(outputPaths)
|
||||
|| outputPaths.some((item) => typeof item !== 'string')
|
||||
|| (outputShape === 'envelope' && outputPaths.length === 0)) return undefined;
|
||||
if (input.kind === 'request-transaction') {
|
||||
const expected = [...new Set(transaction!.request.expectedDestinations)].sort();
|
||||
const declared = [...new Set(outputPaths)].sort();
|
||||
if (outputShape !== 'envelope' || expected.length !== declared.length
|
||||
|| expected.some((path, index) => path !== declared[index])) return undefined;
|
||||
}
|
||||
return {
|
||||
id: input.id.slice(0, 160),
|
||||
name: input.name.slice(0, 120),
|
||||
kind: input.kind as BrowserPageCallable['kind'],
|
||||
operation: input.operation.slice(0, 240),
|
||||
algorithm: typeof input.algorithm === 'string' ? input.algorithm.slice(0, 240) : undefined,
|
||||
crypto: normalizeBrowserRecordingCrypto(input.crypto),
|
||||
origin: input.origin.slice(0, 2_048),
|
||||
target: { ...target },
|
||||
lifecycle: 'document',
|
||||
execution,
|
||||
inputSlots: input.inputSlots.slice(0, 64).map((slot, index) => {
|
||||
const item = slot as Partial<BrowserPageCallable['inputSlots'][number]>;
|
||||
return {
|
||||
id: typeof item.id === 'string' ? item.id.slice(0, 120) : `arg-${index}`,
|
||||
name: typeof item.name === 'string' ? item.name.slice(0, 120) : `arg${index}`,
|
||||
index: Number.isSafeInteger(item.index) ? Number(item.index) : index,
|
||||
role: ['data', 'key', 'iv', 'algorithm', 'options', 'signature', 'salt', 'nonce', 'aad', 'unknown'].includes(String(item.role))
|
||||
? item.role as BrowserPageCallable['inputSlots'][number]['role'] : 'unknown',
|
||||
dataType: typeof item.dataType === 'string' ? item.dataType.slice(0, 120) : 'unknown',
|
||||
required: item.required !== false,
|
||||
retained: item.retained === true,
|
||||
};
|
||||
}),
|
||||
output: {
|
||||
dataType: typeof input.output.dataType === 'string' ? input.output.dataType.slice(0, 120) : 'unknown',
|
||||
encoding: ['auto', 'utf8', 'hex', 'base64', 'json'].includes(String(input.output.encoding))
|
||||
? input.output.encoding as BrowserPageCallable['output']['encoding'] : 'auto',
|
||||
shape: outputShape as BrowserPageCallable['output']['shape'],
|
||||
paths: outputPaths.slice(0, 64).map((item) => item.slice(0, 512)),
|
||||
},
|
||||
transaction,
|
||||
provenance: {
|
||||
recordingId: typeof input.provenance.recordingId === 'string' ? input.provenance.recordingId.slice(0, 160) : undefined,
|
||||
traceId: typeof input.provenance.traceId === 'string' ? input.provenance.traceId.slice(0, 160) : undefined,
|
||||
eventId: typeof input.provenance.eventId === 'string' ? input.provenance.eventId.slice(0, 160) : undefined,
|
||||
sourceUrl: typeof input.provenance.sourceUrl === 'string' ? input.provenance.sourceUrl.slice(0, 4_096) : undefined,
|
||||
lineNumber: Number.isSafeInteger(input.provenance.lineNumber) ? Math.max(1, Number(input.provenance.lineNumber)) : undefined,
|
||||
functionName: typeof input.provenance.functionName === 'string' ? input.provenance.functionName.slice(0, 240) : undefined,
|
||||
},
|
||||
createdAt: Number.isFinite(input.createdAt) ? Math.max(0, Number(input.createdAt)) : Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
type PageControllerCommand = 'callable.list' | 'callable.execute' | 'callable.delete' | 'transform.execute';
|
||||
|
||||
function injectionErrorMessage(value: unknown): string {
|
||||
if (value instanceof Error) return value.message;
|
||||
if (typeof value === 'string') return value;
|
||||
if (value && typeof value === 'object' && typeof (value as { message?: unknown }).message === 'string') {
|
||||
return (value as { message: string }).message;
|
||||
}
|
||||
return String(value || '页面脚本执行失败');
|
||||
}
|
||||
|
||||
async function pageCallableCommand(
|
||||
registryKey: string,
|
||||
protocolVersion: number,
|
||||
command: PageControllerCommand,
|
||||
input: Record<string, unknown>,
|
||||
): Promise<unknown> {
|
||||
const controller = (window as unknown as Record<string, unknown>)[registryKey] as {
|
||||
version?: unknown;
|
||||
command?: (name: PageControllerCommand, params: Record<string, unknown>) => unknown;
|
||||
} | undefined;
|
||||
if (controller?.version !== protocolVersion || typeof controller.command !== 'function') {
|
||||
if (command === 'callable.list') return [];
|
||||
throw new Error('页面函数控制器不存在,页面可能已经刷新');
|
||||
}
|
||||
return await Promise.resolve(controller.command(command, input));
|
||||
}
|
||||
|
||||
async function callPageController(
|
||||
target: BrowserTarget,
|
||||
command: PageControllerCommand,
|
||||
input: Record<string, unknown> = {},
|
||||
): Promise<unknown> {
|
||||
const [result] = await browser.scripting.executeScript({
|
||||
target: scriptingTarget(target),
|
||||
world: 'MAIN',
|
||||
func: pageCallableCommand,
|
||||
args: [PAGE_RECORDER_REGISTRY_KEY, PAGE_RECORDER_PROTOCOL_VERSION, command, input],
|
||||
});
|
||||
const injectionError = (result as (typeof result & { error?: unknown }) | undefined)?.error;
|
||||
if (injectionError !== undefined) {
|
||||
throw new ExtensionError('page_callable_execution_failed', injectionErrorMessage(injectionError));
|
||||
}
|
||||
return result?.result;
|
||||
}
|
||||
|
||||
export async function listPageCallables(target: BrowserTarget): Promise<BrowserPageCallable[]> {
|
||||
const resolved = await resolveDocumentTarget(target);
|
||||
const result = await callPageController(resolved, 'callable.list');
|
||||
if (!Array.isArray(result)) return [];
|
||||
return result.map((item) => normalizeCallable(item, resolved))
|
||||
.filter((item): item is BrowserPageCallable => Boolean(item)).slice(-MAX_CALLABLES);
|
||||
}
|
||||
|
||||
export async function executePageCallable(
|
||||
target: BrowserTarget,
|
||||
callableId: string,
|
||||
args: unknown[],
|
||||
): Promise<BrowserPageCallableExecution> {
|
||||
const resolved = await resolveDocumentTarget(target);
|
||||
const value = await callPageController(resolved, 'callable.execute', { callableId, args }) as BrowserPageCallableExecution | undefined;
|
||||
if (!value || value.callableId !== callableId || typeof value.durationMs !== 'number') {
|
||||
throw new ExtensionError('callable_invalid_result', '页面函数没有返回有效结果');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export async function deletePageCallable(target: BrowserTarget, callableId: string): Promise<BrowserPageCallable[]> {
|
||||
const resolved = await resolveDocumentTarget(target);
|
||||
const result = await callPageController(resolved, 'callable.delete', { callableId });
|
||||
if (!Array.isArray(result)) return [];
|
||||
return result.map((item) => normalizeCallable(item, resolved))
|
||||
.filter((item): item is BrowserPageCallable => Boolean(item));
|
||||
}
|
||||
|
||||
export async function executePageTransformDirection(
|
||||
target: BrowserTarget,
|
||||
profileId: string,
|
||||
directionName: BrowserTransformDirectionName,
|
||||
direction: BrowserTransformDirection,
|
||||
packet: BrowserTransformPacket,
|
||||
): Promise<BrowserTransformExecution> {
|
||||
const resolved = await resolveDocumentTarget(target);
|
||||
const result = await callPageController(resolved, 'transform.execute', {
|
||||
profileId, directionName, direction, packet,
|
||||
}) as {
|
||||
ok?: unknown;
|
||||
value?: BrowserTransformExecution;
|
||||
error?: { code?: unknown; message?: unknown };
|
||||
} | undefined;
|
||||
if (!result?.ok) {
|
||||
throw new ExtensionError(
|
||||
typeof result?.error?.code === 'string' ? result.error.code : 'transform_page_execution_failed',
|
||||
typeof result?.error?.message === 'string' ? result.error.message : '页面没有返回有效的 Pipeline 结果',
|
||||
);
|
||||
}
|
||||
if (!result.value || result.value.profileId !== profileId || result.value.direction !== directionName) {
|
||||
throw new ExtensionError('transform_invalid_result', '页面没有返回有效的 Pipeline 结果');
|
||||
}
|
||||
return result.value;
|
||||
}
|
||||
|
||||
export { normalizeCallable };
|
||||
Reference in New Issue
Block a user