mirror of
https://github.com/yaklang/yaklang-chrome-extension.git
synced 2026-09-25 20:51:52 +08:00
352 lines
18 KiB
TypeScript
352 lines
18 KiB
TypeScript
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 { executeFirefoxPageRecorderCommand } from '@/features/browser-recording/bridge-client';
|
|
import { normalizeBrowserRecordingCrypto } from '@/features/browser-crypto/model';
|
|
import { serializeTabExecution, withPageNetworkGuard } from './network-guard';
|
|
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 (input.version !== 2 || !Array.isArray(input.prerequisites) || input.prerequisites.length > 4
|
|
|| !request || !['fetch', 'xhr', 'beacon', 'form'].includes(String(request.boundary))
|
|
|| 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())
|
|
|| !['json', 'form', 'raw'].includes(String(request.bodyFormat))) return undefined;
|
|
const prerequisites = input.prerequisites.flatMap((value) => {
|
|
if (!value || typeof value !== 'object') return [];
|
|
const item = value as Partial<NonNullable<BrowserPageCallable['transaction']>['prerequisites'][number]>;
|
|
const response = item.response as Partial<NonNullable<BrowserPageCallable['transaction']>['prerequisites'][number]['response']> | undefined;
|
|
if (item.boundary !== 'fetch' || typeof item.method !== 'string' || typeof item.url !== 'string'
|
|
|| !['none', 'json', 'form', 'raw'].includes(String(item.requestBodyFormat))
|
|
|| !Number.isSafeInteger(item.maxRequestBodyBytes) || Number(item.maxRequestBodyBytes) < 0
|
|
|| Number(item.maxRequestBodyBytes) > 8 * 1_024 * 1_024
|
|
|| !response || !Number.isSafeInteger(response.statusCode) || Number(response.statusCode) < 100
|
|
|| Number(response.statusCode) > 599 || typeof response.url !== 'string'
|
|
|| !['json', 'form', 'raw'].includes(String(response.bodyFormat))
|
|
|| !Number.isSafeInteger(response.maxBodyBytes) || Number(response.maxBodyBytes) < 1
|
|
|| Number(response.maxBodyBytes) > 8 * 1_024 * 1_024
|
|
|| !Array.isArray(response.requiredPaths) || response.requiredPaths.length === 0
|
|
|| response.requiredPaths.some((path) => typeof path !== 'string' || !path.trim())) return [];
|
|
return [{
|
|
boundary: 'fetch' as const,
|
|
method: item.method.toUpperCase().slice(0, 16),
|
|
url: item.url.slice(0, 4_096),
|
|
requestBodyFormat: item.requestBodyFormat as NonNullable<BrowserPageCallable['transaction']>['prerequisites'][number]['requestBodyFormat'],
|
|
maxRequestBodyBytes: Number(item.maxRequestBodyBytes),
|
|
response: {
|
|
statusCode: Number(response.statusCode),
|
|
url: response.url.slice(0, 4_096),
|
|
bodyFormat: response.bodyFormat as NonNullable<BrowserPageCallable['transaction']>['prerequisites'][number]['response']['bodyFormat'],
|
|
maxBodyBytes: Number(response.maxBodyBytes),
|
|
requiredPaths: [...new Set(response.requiredPaths.map((path) => path.trim().slice(0, 512)))].slice(0, 64),
|
|
},
|
|
}];
|
|
});
|
|
if (prerequisites.length !== input.prerequisites.length) return undefined;
|
|
return {
|
|
version: 2,
|
|
prerequisites,
|
|
request: {
|
|
boundary: request.boundary as NonNullable<BrowserPageCallable['transaction']>['request']['boundary'],
|
|
method: request.method.toUpperCase().slice(0, 16),
|
|
url: request.url.slice(0, 4_096),
|
|
expectedDestinations: [...new Set(request.expectedDestinations.map((item) => item.trim().slice(0, 512)))].slice(0, 64),
|
|
bodyFormat: request.bodyFormat as NonNullable<BrowserPageCallable['transaction']>['request']['bodyFormat'],
|
|
},
|
|
inputMode: 'auto',
|
|
};
|
|
}
|
|
|
|
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 normalizeCallableAnalysis(
|
|
value: unknown,
|
|
): BrowserPageCallable['provenance']['analysis'] | undefined {
|
|
if (!value || typeof value !== 'object') return undefined;
|
|
const input = value as Partial<NonNullable<BrowserPageCallable['provenance']['analysis']>>;
|
|
if (input.version !== 1 || typeof input.traceId !== 'string'
|
|
|| !input.confidence || typeof input.confidence !== 'object'
|
|
|| !Number.isFinite(input.confidence.score)
|
|
|| !['high', 'medium', 'low'].includes(String(input.confidence.level))
|
|
|| !Array.isArray(input.flow) || !Array.isArray(input.operations) || !Array.isArray(input.evidence)) return undefined;
|
|
const evidenceKinds = new Set([
|
|
'request-boundary', 'response-boundary', 'exact-value', 'message-boundary',
|
|
'state-sequence', 'transform-lineage', 'callable', 'trace-order', 'heuristic',
|
|
]);
|
|
return {
|
|
version: 1,
|
|
traceId: input.traceId.slice(0, 160),
|
|
confidence: {
|
|
score: Math.max(0, Math.min(100, Number(input.confidence.score))),
|
|
level: input.confidence.level as 'high' | 'medium' | 'low',
|
|
},
|
|
flow: input.flow.slice(0, 32).flatMap((item) => typeof item === 'string' ? [item.slice(0, 240)] : []),
|
|
operations: input.operations.slice(0, 16).flatMap((operation) => (
|
|
operation && typeof operation.operation === 'string'
|
|
? [{
|
|
operation: operation.operation.slice(0, 240),
|
|
destination: typeof operation.destination === 'string' ? operation.destination.slice(0, 512) : undefined,
|
|
crypto: normalizeBrowserRecordingCrypto(operation.crypto),
|
|
}]
|
|
: []
|
|
)),
|
|
evidence: input.evidence.slice(0, 24).flatMap((item) => (
|
|
item && evidenceKinds.has(String(item.kind))
|
|
&& ['proven', 'supported'].includes(String(item.strength))
|
|
&& typeof item.label === 'string'
|
|
? [{
|
|
kind: item.kind,
|
|
strength: item.strength,
|
|
label: item.label.slice(0, 500),
|
|
}]
|
|
: []
|
|
)),
|
|
};
|
|
}
|
|
|
|
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,
|
|
businessFrameHints: Array.isArray(input.provenance.businessFrameHints)
|
|
? input.provenance.businessFrameHints.slice(0, 16).flatMap((hint) => (
|
|
hint
|
|
&& typeof hint.functionName === 'string'
|
|
&& Number.isFinite(hint.support)
|
|
&& Number.isFinite(hint.averageDepth)
|
|
? [{
|
|
functionName: hint.functionName.slice(0, 240),
|
|
url: typeof hint.url === 'string' ? hint.url.slice(0, 4_096) : undefined,
|
|
support: Math.max(0, Number(hint.support)),
|
|
averageDepth: Math.max(0, Number(hint.averageDepth)),
|
|
}]
|
|
: []
|
|
))
|
|
: undefined,
|
|
analysis: normalizeCallableAnalysis(input.provenance.analysis),
|
|
},
|
|
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('页面函数控制器不存在,页面可能已经刷新');
|
|
}
|
|
try {
|
|
return await Promise.resolve(controller.command(command, input));
|
|
} catch (error) {
|
|
return { __yakitCallError: {
|
|
message: error instanceof Error ? error.message : String(error),
|
|
} };
|
|
}
|
|
}
|
|
|
|
async function callPageController(
|
|
target: BrowserTarget,
|
|
command: PageControllerCommand,
|
|
input: Record<string, unknown> = {},
|
|
): Promise<unknown> {
|
|
if (command === 'callable.execute' || command === 'transform.execute') {
|
|
return serializeTabExecution(target.tabId, async () => {
|
|
const current = await resolveDocumentTarget(target);
|
|
const callables = await callPageController(current, 'callable.list') as RawCallable[];
|
|
const ids = command === 'callable.execute' ? [input.callableId]
|
|
: (input.direction as BrowserTransformDirection).nodes.flatMap((node) => node.kind === 'page.call' ? [node.callableId] : []);
|
|
const used = callables.filter((callable) => ids.includes(callable.id));
|
|
if (used.length !== new Set(ids).size) throw new ExtensionError('callable_unavailable', '页面函数已经失效');
|
|
const prerequisites = used.flatMap((callable) => callable.transaction?.prerequisites || []);
|
|
return withPageNetworkGuard(current, prerequisites, () => invokePageController(current, command, input));
|
|
});
|
|
}
|
|
return invokePageController(target, command, input);
|
|
}
|
|
|
|
async function invokePageController(
|
|
target: BrowserTarget,
|
|
command: PageControllerCommand,
|
|
input: Record<string, unknown>,
|
|
): Promise<unknown> {
|
|
if (import.meta.env.FIREFOX) return executeFirefoxPageRecorderCommand(target, command, input);
|
|
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));
|
|
}
|
|
const failure = (result?.result as { __yakitCallError?: { message: string } } | undefined)?.__yakitCallError;
|
|
if (failure) throw new ExtensionError('page_callable_execution_failed', failure.message);
|
|
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 };
|