mirror of
https://github.com/yaklang/yaklang-chrome-extension.git
synced 2026-09-25 12:41: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:
@@ -44,4 +44,21 @@ describe('Bridge v3 protocol', () => {
|
||||
expect(() => parseCapabilityParams('browser.eval', { code: 'document.title' })).toThrow('mode');
|
||||
expect(() => parseBridgeEnvelope('x'.repeat(BRIDGE_MAX_MESSAGE_BYTES + 1))).toThrow('16 MiB');
|
||||
});
|
||||
|
||||
it('accepts exact Worker boundary handles for remote deep capture', () => {
|
||||
expect(parseCapabilityParams('browser.deep_capture.start', {
|
||||
matcher: {
|
||||
kind: 'boundary', eventKind: 'worker', operation: 'worker.postMessage', wrapperHandleId: 'boundary-wrapper-1',
|
||||
},
|
||||
})).toMatchObject({ matcher: { kind: 'boundary', eventKind: 'worker' } });
|
||||
});
|
||||
|
||||
it('accepts automatic selected-frame capture and rejects the legacy expression contract', () => {
|
||||
expect(parseCapabilityParams('browser.callable.create', {
|
||||
source: 'deep-capture', strategy: 'selected-frame', callFrameId: 'frame-1', name: 'Envelope',
|
||||
})).toMatchObject({ strategy: 'selected-frame', callFrameId: 'frame-1' });
|
||||
expect(() => parseCapabilityParams('browser.callable.create', {
|
||||
source: 'deep-capture', callFrameId: 'frame-1', name: 'Envelope', functionExpression: 'buildEnvelope',
|
||||
})).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
+67
-7
@@ -1,6 +1,7 @@
|
||||
import * as v from 'valibot';
|
||||
import type { BridgeEnvelope } from '@/types/messages';
|
||||
import type { BridgePublicKey } from '@/types/models';
|
||||
import { browserTransformExecuteSchema, browserTransformProfileInputSchema } from './transform';
|
||||
|
||||
export const BRIDGE_PROTOCOL_VERSION = 3;
|
||||
export const BRIDGE_MAX_MESSAGE_BYTES = 16 * 1024 * 1024;
|
||||
@@ -32,6 +33,36 @@ const optionalTabId = v.optional(tabId);
|
||||
const optionalFrameId = v.optional(v.pipe(v.number(), v.safeInteger(), v.minValue(0)));
|
||||
const optionalDocumentId = v.optional(v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(160)));
|
||||
const targetFields = { tabId: optionalTabId, frameId: optionalFrameId, documentId: optionalDocumentId };
|
||||
const cryptoAdapterId = v.pipe(v.string(), v.trim(), v.regex(/^[a-z0-9][a-z0-9.-]{0,63}$/));
|
||||
const businessFrameHints = v.optional(v.pipe(v.array(v.strictObject({
|
||||
functionName: v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(240)),
|
||||
url: v.optional(v.pipe(v.string(), v.trim(), v.maxLength(4_096))),
|
||||
support: v.pipe(v.number(), v.safeInteger(), v.minValue(1), v.maxValue(8)),
|
||||
averageDepth: v.pipe(v.number(), v.finite(), v.minValue(0), v.maxValue(16)),
|
||||
})), v.maxLength(8)));
|
||||
const deepCaptureMatcher = v.variant('kind', [
|
||||
v.strictObject({
|
||||
kind: v.literal('crypto'),
|
||||
adapterId: cryptoAdapterId,
|
||||
operation: v.pipe(v.string(), v.trim(), v.regex(/^[A-Za-z0-9_$][A-Za-z0-9_$.-]{0,159}$/)),
|
||||
wrapperHandleId: id,
|
||||
scriptUrl: v.optional(v.pipe(v.string(), v.trim(), v.maxLength(4_096))),
|
||||
frameHints: businessFrameHints,
|
||||
}),
|
||||
v.strictObject({
|
||||
kind: v.literal('boundary'),
|
||||
eventKind: v.picklist(['beacon', 'worker', 'message']),
|
||||
operation: v.pipe(v.string(), v.trim(), v.regex(/^[A-Za-z0-9_$][A-Za-z0-9_$.-]{0,159}$/)),
|
||||
wrapperHandleId: id,
|
||||
scriptUrl: v.optional(v.pipe(v.string(), v.trim(), v.maxLength(4_096))),
|
||||
frameHints: businessFrameHints,
|
||||
}),
|
||||
v.strictObject({
|
||||
kind: v.literal('request'),
|
||||
urlPattern: v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(2_048)),
|
||||
frameHints: businessFrameHints,
|
||||
}),
|
||||
]);
|
||||
const captureId = v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(160));
|
||||
const nodeId = v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(80));
|
||||
|
||||
@@ -78,19 +109,48 @@ const capabilityParams = {
|
||||
'browser.network.export': v.strictObject({ ...targetFields, id }),
|
||||
'browser.network.poc': v.strictObject({ ...targetFields, id }),
|
||||
'browser.network.analysis': v.strictObject({ ...targetFields, id }),
|
||||
'browser.observe.start': v.optional(v.strictObject({
|
||||
'browser.recording.start': v.optional(v.strictObject({
|
||||
...targetFields,
|
||||
captureValues: v.optional(v.boolean()),
|
||||
maxEntries: v.optional(v.pipe(v.number(), v.safeInteger(), v.minValue(10), v.maxValue(200))),
|
||||
maxEntries: v.optional(v.pipe(v.number(), v.safeInteger(), v.minValue(20), v.maxValue(500))),
|
||||
maxValueBytes: v.optional(v.pipe(v.number(), v.safeInteger(), v.minValue(256), v.maxValue(8_192))),
|
||||
})),
|
||||
'browser.observe.status': v.optional(v.strictObject(targetFields)),
|
||||
'browser.observe.list': v.optional(v.strictObject({
|
||||
'browser.recording.status': v.optional(v.strictObject(targetFields)),
|
||||
'browser.recording.get': v.optional(v.strictObject({
|
||||
...targetFields,
|
||||
limit: v.optional(v.pipe(v.number(), v.safeInteger(), v.minValue(1), v.maxValue(200))),
|
||||
limit: v.optional(v.pipe(v.number(), v.safeInteger(), v.minValue(1), v.maxValue(500))),
|
||||
})),
|
||||
'browser.observe.clear': v.optional(v.strictObject(targetFields)),
|
||||
'browser.observe.stop': v.optional(v.strictObject(targetFields)),
|
||||
'browser.recording.clear': v.optional(v.strictObject(targetFields)),
|
||||
'browser.recording.stop': v.optional(v.strictObject(targetFields)),
|
||||
'browser.callable.create': v.union([
|
||||
v.strictObject({
|
||||
...targetFields, source: v.literal('recording'), callHandleId: id,
|
||||
name: v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(120)),
|
||||
}),
|
||||
v.strictObject({
|
||||
...targetFields, source: v.literal('deep-capture'), callFrameId: id,
|
||||
strategy: v.literal('selected-frame'),
|
||||
name: v.optional(v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(120))),
|
||||
}),
|
||||
v.strictObject({
|
||||
...targetFields, source: v.literal('deep-capture'), callFrameId: id,
|
||||
strategy: v.literal('expression'),
|
||||
name: v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(120)),
|
||||
functionExpression: v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(4_096)),
|
||||
}),
|
||||
]),
|
||||
'browser.callable.list': v.optional(v.strictObject(targetFields)),
|
||||
'browser.callable.execute': v.strictObject({ ...targetFields, callableId: id, args: v.pipe(v.array(v.unknown()), v.maxLength(64)) }),
|
||||
'browser.callable.delete': v.strictObject({ ...targetFields, callableId: id }),
|
||||
'browser.deep_capture.start': v.strictObject({ ...targetFields, matcher: deepCaptureMatcher }),
|
||||
'browser.deep_capture.status': v.optional(v.strictObject(targetFields)),
|
||||
'browser.deep_capture.keepalive': v.optional(v.strictObject(targetFields)),
|
||||
'browser.deep_capture.resume': v.optional(v.strictObject(targetFields)),
|
||||
'browser.deep_capture.detach': v.optional(v.strictObject(targetFields)),
|
||||
'browser.transform.profile.list': v.optional(v.strictObject(targetFields)),
|
||||
'browser.transform.profile.save': browserTransformProfileInputSchema,
|
||||
'browser.transform.profile.delete': v.strictObject({ id }),
|
||||
'browser.transform.execute': browserTransformExecuteSchema,
|
||||
'browser.invoke': v.strictObject({
|
||||
...targetFields,
|
||||
path: v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(2_048)),
|
||||
|
||||
@@ -19,11 +19,26 @@ export const BRIDGE_CAPABILITIES = [
|
||||
'browser.network.export',
|
||||
'browser.network.poc',
|
||||
'browser.network.analysis',
|
||||
'browser.observe.start',
|
||||
'browser.observe.status',
|
||||
'browser.observe.list',
|
||||
'browser.observe.clear',
|
||||
'browser.observe.stop',
|
||||
'browser.recording.start',
|
||||
'browser.recording.status',
|
||||
'browser.recording.get',
|
||||
'browser.recording.clear',
|
||||
'browser.recording.stop',
|
||||
'browser.callable.create',
|
||||
'browser.callable.list',
|
||||
'browser.callable.execute',
|
||||
'browser.callable.delete',
|
||||
...(!import.meta.env.FIREFOX ? [
|
||||
'browser.deep_capture.start',
|
||||
'browser.deep_capture.status',
|
||||
'browser.deep_capture.keepalive',
|
||||
'browser.deep_capture.resume',
|
||||
'browser.deep_capture.detach',
|
||||
'browser.transform.profile.list',
|
||||
'browser.transform.profile.save',
|
||||
'browser.transform.profile.delete',
|
||||
'browser.transform.execute',
|
||||
] : []),
|
||||
...(!(import.meta.env.FIREFOX && import.meta.env.MODE === 'store') ? ['browser.invoke', 'browser.eval'] : []),
|
||||
'proxy.list',
|
||||
'proxy.switch',
|
||||
@@ -35,7 +50,8 @@ export const READ_CAPABILITY_SCOPES: CapabilityScope[] = [
|
||||
'browser.storage.read',
|
||||
'browser.cookies.read',
|
||||
'browser.network.read',
|
||||
'browser.observation.read',
|
||||
'browser.recording.read',
|
||||
...(!import.meta.env.FIREFOX ? ['browser.transform.read' as const] : []),
|
||||
];
|
||||
|
||||
export const CONTROL_CAPABILITY_SCOPES: CapabilityScope[] = [
|
||||
@@ -48,8 +64,15 @@ export const CONTROL_CAPABILITY_SCOPES: CapabilityScope[] = [
|
||||
'browser.human.takeover',
|
||||
'browser.network.capture',
|
||||
'browser.network.sensitive.read',
|
||||
'browser.observation.control',
|
||||
'browser.observation.sensitive.read',
|
||||
'browser.recording.control',
|
||||
'browser.recording.sensitive.read',
|
||||
'browser.callable.execute',
|
||||
...(!import.meta.env.FIREFOX ? [
|
||||
'browser.debugger.read' as const,
|
||||
'browser.debugger.control' as const,
|
||||
'browser.transform.manage' as const,
|
||||
'browser.transform.execute' as const,
|
||||
] : []),
|
||||
'browser.proxy.read',
|
||||
'browser.proxy.write',
|
||||
];
|
||||
@@ -68,9 +91,15 @@ export const CAPABILITY_LABELS: Record<CapabilityScope, string> = {
|
||||
'browser.network.read': '读取网络摘要',
|
||||
'browser.network.capture': '控制网络捕获',
|
||||
'browser.network.sensitive.read': '读取请求头与请求体',
|
||||
'browser.observation.read': '读取页面行为观测',
|
||||
'browser.observation.control': '控制页面行为观测',
|
||||
'browser.observation.sensitive.read': '读取观测值预览',
|
||||
'browser.recording.read': '读取浏览器录制',
|
||||
'browser.recording.control': '控制浏览器录制',
|
||||
'browser.recording.sensitive.read': '读取录制值预览',
|
||||
'browser.callable.execute': '创建并执行页面函数',
|
||||
'browser.debugger.read': '读取暂停现场与作用域',
|
||||
'browser.debugger.control': '控制页面深度捕获',
|
||||
'browser.transform.read': '读取浏览器转换配置',
|
||||
'browser.transform.manage': '管理浏览器转换配置',
|
||||
'browser.transform.execute': '执行浏览器请求与响应转换',
|
||||
'browser.proxy.read': '读取代理',
|
||||
'browser.proxy.write': '切换代理',
|
||||
};
|
||||
|
||||
@@ -15,4 +15,228 @@ describe('extension request schemas', () => {
|
||||
action: 'context.eval', payload: { mode: 'program', code: '1 + 1', timeoutMs: 500 },
|
||||
}).action).toBe('context.eval');
|
||||
});
|
||||
|
||||
it('accepts all browser transform grant scopes', () => {
|
||||
expect(parseExtensionRequest({
|
||||
action: 'grant.create',
|
||||
payload: {
|
||||
targets: [{ tabId: 12, frameId: 0 }],
|
||||
scopes: ['browser.transform.read', 'browser.transform.manage', 'browser.transform.execute'],
|
||||
durationMinutes: 5,
|
||||
},
|
||||
}).action).toBe('grant.create');
|
||||
expect(() => parseExtensionRequest({
|
||||
action: 'grant.create',
|
||||
payload: { targets: [{ tabId: 12, frameId: 0 }], scopes: ['browser.transform.unknown'], durationMinutes: 5 },
|
||||
})).toThrow('scopes');
|
||||
});
|
||||
|
||||
it('validates the atomic current-site route reset action', () => {
|
||||
expect(parseExtensionRequest({
|
||||
action: 'proxy.site.route.clear', payload: { url: 'https://api.example.test/path' },
|
||||
}).action).toBe('proxy.site.route.clear');
|
||||
expect(() => parseExtensionRequest({
|
||||
action: 'proxy.site.route.clear', payload: { url: 'chrome://extensions' },
|
||||
})).toThrow('HTTP(S)');
|
||||
});
|
||||
|
||||
it('validates recording bounds and recorded page callables', () => {
|
||||
expect(parseExtensionRequest({
|
||||
action: 'recording.start',
|
||||
payload: { tabId: 12, frameId: 0, captureValues: false, maxEntries: 500, maxValueBytes: 8_192 },
|
||||
}).action).toBe('recording.start');
|
||||
expect(() => parseExtensionRequest({
|
||||
action: 'recording.start', payload: { tabId: 12, maxEntries: 501 },
|
||||
})).toThrow();
|
||||
expect(parseExtensionRequest({
|
||||
action: 'callable.create',
|
||||
payload: {
|
||||
tabId: 12,
|
||||
frameId: 0,
|
||||
source: 'recording',
|
||||
callHandleId: 'handle-1',
|
||||
name: 'Login encrypt',
|
||||
},
|
||||
}).action).toBe('callable.create');
|
||||
expect(() => parseExtensionRequest({
|
||||
action: 'callable.create',
|
||||
payload: {
|
||||
tabId: 12,
|
||||
source: 'deep-capture',
|
||||
strategy: 'request-transaction',
|
||||
callFrameId: 'frame-1',
|
||||
transaction: {
|
||||
request: { method: 'POST', url: '/login', expectedDestinations: [] },
|
||||
inputMode: 'auto',
|
||||
boundaries: ['fetch'],
|
||||
},
|
||||
},
|
||||
})).toThrow('expectedDestinations');
|
||||
expect(parseExtensionRequest({
|
||||
action: 'callable.create',
|
||||
payload: {
|
||||
tabId: 12,
|
||||
source: 'deep-capture',
|
||||
strategy: 'request-transaction',
|
||||
callFrameId: 'frame-1',
|
||||
name: 'Login request transaction',
|
||||
transaction: {
|
||||
request: {
|
||||
method: 'POST',
|
||||
url: 'encrypt/aesrsa.php',
|
||||
expectedDestinations: ['body.encryptedData', 'body.encryptedKey', 'body.encryptedIv'],
|
||||
},
|
||||
inputMode: 'auto',
|
||||
boundaries: ['fetch', 'xhr', 'beacon', 'form'],
|
||||
},
|
||||
},
|
||||
}).action).toBe('callable.create');
|
||||
expect(() => parseExtensionRequest({
|
||||
action: 'callable.create',
|
||||
payload: {
|
||||
tabId: 12,
|
||||
source: 'recording',
|
||||
callHandleId: 'handle-1',
|
||||
name: '',
|
||||
},
|
||||
})).toThrow();
|
||||
expect(parseExtensionRequest({
|
||||
action: 'callable.execute', payload: { tabId: 12, callableId: 'callable-1', args: ['new plaintext'] },
|
||||
}).action).toBe('callable.execute');
|
||||
});
|
||||
|
||||
it('validates deep capture matchers and bounded business callables', () => {
|
||||
expect(parseExtensionRequest({
|
||||
action: 'deep.capture.start',
|
||||
payload: {
|
||||
tabId: 12,
|
||||
frameId: 0,
|
||||
matcher: {
|
||||
kind: 'crypto',
|
||||
adapterId: 'webcrypto',
|
||||
operation: 'encrypt',
|
||||
wrapperHandleId: 'wrapper-1',
|
||||
frameHints: [{
|
||||
functionName: 'buildEnvelope', url: 'https://example.test/app.js', support: 3, averageDepth: 1,
|
||||
}],
|
||||
},
|
||||
},
|
||||
}).action).toBe('deep.capture.start');
|
||||
expect(() => parseExtensionRequest({
|
||||
action: 'deep.capture.start',
|
||||
payload: { tabId: 12, matcher: { kind: 'crypto', operation: 'crypto.subtle.encrypt' } },
|
||||
})).toThrow();
|
||||
expect(parseExtensionRequest({
|
||||
action: 'deep.capture.start',
|
||||
payload: { tabId: 12, matcher: { kind: 'request', urlPattern: '/api/login' } },
|
||||
}).action).toBe('deep.capture.start');
|
||||
expect(parseExtensionRequest({
|
||||
action: 'deep.capture.start',
|
||||
payload: {
|
||||
tabId: 12,
|
||||
matcher: {
|
||||
kind: 'boundary', eventKind: 'worker', operation: 'worker.postMessage', wrapperHandleId: 'boundary-wrapper-1',
|
||||
},
|
||||
},
|
||||
}).action).toBe('deep.capture.start');
|
||||
expect(() => parseExtensionRequest({
|
||||
action: 'deep.capture.start',
|
||||
payload: {
|
||||
tabId: 12,
|
||||
matcher: { kind: 'boundary', eventKind: 'fetch', operation: 'request', wrapperHandleId: 'boundary-wrapper-1' },
|
||||
},
|
||||
})).toThrow();
|
||||
expect(() => parseExtensionRequest({
|
||||
action: 'deep.capture.start', payload: { tabId: 12, matcher: { kind: 'request', urlPattern: '' } },
|
||||
})).toThrow();
|
||||
expect(parseExtensionRequest({
|
||||
action: 'callable.create',
|
||||
payload: {
|
||||
tabId: 12,
|
||||
source: 'deep-capture',
|
||||
strategy: 'selected-frame',
|
||||
callFrameId: 'frame-1',
|
||||
name: 'Login envelope',
|
||||
},
|
||||
}).action).toBe('callable.create');
|
||||
expect(parseExtensionRequest({
|
||||
action: 'callable.create',
|
||||
payload: {
|
||||
tabId: 12,
|
||||
source: 'deep-capture',
|
||||
strategy: 'expression',
|
||||
callFrameId: 'frame-1',
|
||||
name: 'Anonymous envelope',
|
||||
functionExpression: 'scopeFunction',
|
||||
},
|
||||
}).action).toBe('callable.create');
|
||||
expect(() => parseExtensionRequest({
|
||||
action: 'callable.create',
|
||||
payload: {
|
||||
tabId: 12,
|
||||
source: 'deep-capture',
|
||||
callFrameId: 'frame-1',
|
||||
name: 'Legacy expression',
|
||||
functionExpression: 'buildLoginEnvelope',
|
||||
sourceUrl: 'https://example.test/login.js',
|
||||
lineNumber: 42,
|
||||
},
|
||||
})).toThrow();
|
||||
expect(() => parseExtensionRequest({
|
||||
action: 'callable.execute', payload: { tabId: 12, callableId: 'callable-1', args: Array.from({ length: 65 }) },
|
||||
})).toThrow();
|
||||
});
|
||||
|
||||
it('validates browser transform profiles and packet execution', () => {
|
||||
const profile = {
|
||||
name: 'Login gateway', enabled: true,
|
||||
target: { tabId: 12, frameId: 0, documentId: 'document-1' },
|
||||
origin: 'https://example.test',
|
||||
match: { methods: ['POST'], urlPattern: '*/api/login' },
|
||||
request: {
|
||||
enabled: true,
|
||||
nodes: [
|
||||
{ id: 'input-1', name: 'Password', kind: 'context.read', path: 'body.password' },
|
||||
{ id: 'call-1', name: 'Encrypt', kind: 'page.call', callableId: 'callable-1', arguments: [{ nodeId: 'input-1' }] },
|
||||
{ id: 'output-1', name: 'Write', kind: 'output.write', source: { nodeId: 'call-1' }, destination: 'body.password', encoding: 'auto' },
|
||||
],
|
||||
},
|
||||
response: { enabled: false, nodes: [] },
|
||||
failMode: 'closed' as const,
|
||||
maxConcurrency: 1,
|
||||
};
|
||||
expect(parseExtensionRequest({ action: 'transform.profile.save', payload: profile }).action).toBe('transform.profile.save');
|
||||
expect(parseExtensionRequest({
|
||||
action: 'transform.execute',
|
||||
payload: {
|
||||
profileId: 'profile-1', direction: 'request',
|
||||
packet: { method: 'POST', url: 'https://example.test/api/login', headers: [], bodyBase64: 'e30=' },
|
||||
},
|
||||
}).action).toBe('transform.execute');
|
||||
expect(() => parseExtensionRequest({
|
||||
action: 'transform.profile.save',
|
||||
payload: { ...profile, origin: 'https://example.test/login' },
|
||||
})).toThrow('来源');
|
||||
expect(() => parseExtensionRequest({
|
||||
action: 'transform.profile.save',
|
||||
payload: { ...profile, request: { ...profile.request, nodes: profile.request.nodes.filter((node) => node.kind !== 'output.write') } },
|
||||
})).toThrow('输出节点');
|
||||
expect(() => parseExtensionRequest({
|
||||
action: 'transform.profile.save',
|
||||
payload: {
|
||||
...profile,
|
||||
request: { ...profile.request, nodes: profile.request.nodes.map((node) => node.kind === 'output.write' ? { ...node, destination: 'cookie.password' } : node) },
|
||||
},
|
||||
})).toThrow('输出目标');
|
||||
expect(() => parseExtensionRequest({
|
||||
action: 'transform.profile.save',
|
||||
payload: {
|
||||
...profile,
|
||||
request: {
|
||||
...profile.request,
|
||||
nodes: [...profile.request.nodes, { ...profile.request.nodes[0] }],
|
||||
},
|
||||
},
|
||||
})).toThrow('重复');
|
||||
});
|
||||
});
|
||||
|
||||
+171
-23
@@ -1,6 +1,7 @@
|
||||
import * as v from 'valibot';
|
||||
import type { ExtensionAction, ExtensionRequest } from '@/types/messages';
|
||||
import type { CapabilityScope } from '@/types/models';
|
||||
import { browserTransformExecuteSchema, browserTransformProfileInputSchema } from './transform';
|
||||
|
||||
const id = v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(160));
|
||||
const shortText = v.pipe(v.string(), v.trim(), v.maxLength(240));
|
||||
@@ -11,6 +12,44 @@ const documentId = v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(160)
|
||||
const captureId = v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(160));
|
||||
const nodeId = v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(80));
|
||||
const targetFields = { tabId: v.optional(tabId), frameId: v.optional(frameId), documentId: v.optional(documentId) };
|
||||
const businessFrameHints = v.optional(v.pipe(v.array(v.strictObject({
|
||||
functionName: v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(240)),
|
||||
url: v.optional(v.pipe(v.string(), v.trim(), v.maxLength(4_096))),
|
||||
support: v.pipe(v.number(), v.safeInteger(), v.minValue(1), v.maxValue(8)),
|
||||
averageDepth: v.pipe(v.number(), v.finite(), v.minValue(0), v.maxValue(16)),
|
||||
})), v.maxLength(8)));
|
||||
const deepCaptureMatcher = v.variant('kind', [
|
||||
v.strictObject({
|
||||
kind: v.literal('crypto'),
|
||||
adapterId: v.pipe(v.string(), v.trim(), v.regex(/^[a-z0-9][a-z0-9.-]{0,63}$/)),
|
||||
operation: v.pipe(v.string(), v.trim(), v.regex(/^[A-Za-z0-9_$][A-Za-z0-9_$.-]{0,159}$/)),
|
||||
wrapperHandleId: id,
|
||||
scriptUrl: v.optional(v.pipe(v.string(), v.trim(), v.maxLength(4_096))),
|
||||
frameHints: businessFrameHints,
|
||||
}),
|
||||
v.strictObject({
|
||||
kind: v.literal('boundary'),
|
||||
eventKind: v.picklist(['beacon', 'worker', 'message']),
|
||||
operation: v.pipe(v.string(), v.trim(), v.regex(/^[A-Za-z0-9_$][A-Za-z0-9_$.-]{0,159}$/)),
|
||||
wrapperHandleId: id,
|
||||
scriptUrl: v.optional(v.pipe(v.string(), v.trim(), v.maxLength(4_096))),
|
||||
frameHints: businessFrameHints,
|
||||
}),
|
||||
v.strictObject({
|
||||
kind: v.literal('request'),
|
||||
urlPattern: v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(2_048)),
|
||||
frameHints: businessFrameHints,
|
||||
}),
|
||||
]);
|
||||
const pageRequestTransaction = v.strictObject({
|
||||
request: v.strictObject({
|
||||
method: v.pipe(v.string(), v.trim(), v.toUpperCase(), v.regex(/^[A-Z]{1,16}$/)),
|
||||
url: v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(4_096)),
|
||||
expectedDestinations: v.pipe(v.array(v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(512))), v.minLength(1), v.maxLength(64)),
|
||||
}),
|
||||
inputMode: v.literal('auto'),
|
||||
boundaries: v.pipe(v.array(v.picklist(['fetch', 'xhr', 'beacon', 'form'])), v.minLength(1), v.maxLength(4)),
|
||||
});
|
||||
const port = v.pipe(v.number(), v.safeInteger(), v.minValue(1), v.maxValue(65_535));
|
||||
const proxyHost = v.pipe(
|
||||
v.string(),
|
||||
@@ -48,13 +87,61 @@ const proxyProfile = v.pipe(v.strictObject({
|
||||
return true;
|
||||
}, '代理配置缺少当前类型所需的主机、端口或 PAC 内容'));
|
||||
|
||||
const proxyCondition = v.strictObject({
|
||||
type: v.picklist(['host_exact', 'host_suffix', 'host_wildcard', 'host_regex', 'url_prefix', 'url_wildcard', 'url_regex', 'keyword']),
|
||||
value: v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(8_192)),
|
||||
});
|
||||
|
||||
const timestamp = v.pipe(v.number(), v.safeInteger(), v.minValue(1));
|
||||
const nonNegativeInteger = v.pipe(v.number(), v.safeInteger(), v.minValue(0));
|
||||
const sourceFormat = v.picklist(['auto', 'autoproxy', 'switchyomega', 'hosts']);
|
||||
const sourceStatus = v.picklist(['idle', 'updating', 'ready', 'error']);
|
||||
|
||||
const proxyRule = v.strictObject({
|
||||
id,
|
||||
name: v.pipe(shortText, v.minLength(1)),
|
||||
enabled: v.boolean(),
|
||||
patterns: stringList(500, 2_048),
|
||||
condition: proxyCondition,
|
||||
proxyProfileId: id,
|
||||
priority: v.pipe(v.number(), v.safeInteger(), v.minValue(1), v.maxValue(1_000_000)),
|
||||
order: nonNegativeInteger,
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
});
|
||||
|
||||
const proxyRuleSourceInput = v.strictObject({
|
||||
id: v.optional(id),
|
||||
name: v.pipe(shortText, v.minLength(1)),
|
||||
url: httpUrl,
|
||||
format: sourceFormat,
|
||||
enabled: v.boolean(),
|
||||
matchProfileId: id,
|
||||
bypassProfileId: id,
|
||||
order: v.optional(nonNegativeInteger),
|
||||
updateIntervalMinutes: v.pipe(v.number(), v.safeInteger(), v.minValue(15), v.maxValue(43_200)),
|
||||
});
|
||||
|
||||
const proxyRuleSource = v.strictObject({
|
||||
id,
|
||||
name: v.pipe(shortText, v.minLength(1)),
|
||||
url: httpUrl,
|
||||
format: sourceFormat,
|
||||
enabled: v.boolean(),
|
||||
matchProfileId: id,
|
||||
bypassProfileId: id,
|
||||
order: nonNegativeInteger,
|
||||
updateIntervalMinutes: v.pipe(v.number(), v.safeInteger(), v.minValue(15), v.maxValue(43_200)),
|
||||
revision: v.optional(id),
|
||||
contentHash: v.optional(id),
|
||||
etag: v.optional(v.pipe(v.string(), v.maxLength(1_024))),
|
||||
lastModified: v.optional(v.pipe(v.string(), v.maxLength(1_024))),
|
||||
lastCheckedAt: v.optional(timestamp),
|
||||
lastUpdatedAt: v.optional(timestamp),
|
||||
status: sourceStatus,
|
||||
totalRuleCount: nonNegativeInteger,
|
||||
supportedRuleCount: nonNegativeInteger,
|
||||
ignoredRuleCount: nonNegativeInteger,
|
||||
invalidRuleCount: nonNegativeInteger,
|
||||
error: v.optional(v.pipe(v.string(), v.maxLength(16_384))),
|
||||
});
|
||||
|
||||
const proxyRouting = v.strictObject({
|
||||
@@ -63,18 +150,25 @@ const proxyRouting = v.strictObject({
|
||||
});
|
||||
|
||||
const proxyConfiguration = v.strictObject({
|
||||
version: v.literal(1),
|
||||
version: v.literal(2),
|
||||
profiles: v.pipe(v.array(proxyProfile), v.minLength(1), v.maxLength(500)),
|
||||
rules: v.pipe(v.array(proxyRule), v.maxLength(5_000)),
|
||||
sources: v.pipe(v.array(v.strictObject({
|
||||
source: proxyRuleSource,
|
||||
content: v.optional(v.pipe(v.string(), v.maxLength(10 * 1024 * 1024))),
|
||||
})), v.maxLength(200)),
|
||||
routing: proxyRouting,
|
||||
});
|
||||
|
||||
const userAgentRule = v.strictObject({
|
||||
id,
|
||||
const userAgentValue = v.pipe(
|
||||
v.string(), v.trim(), v.minLength(1), v.maxLength(1_024),
|
||||
v.check((value) => !/[\r\n]/.test(value), 'User-Agent 不能包含换行符'),
|
||||
);
|
||||
|
||||
const userAgentProfileInput = v.strictObject({
|
||||
id: v.optional(id),
|
||||
name: v.pipe(shortText, v.minLength(1)),
|
||||
enabled: v.boolean(),
|
||||
userAgent: v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(4_096)),
|
||||
domains: stringList(500, 253),
|
||||
userAgent: userAgentValue,
|
||||
});
|
||||
|
||||
const bridgeConfig = v.strictObject({
|
||||
@@ -147,9 +241,15 @@ const capabilityScopes: readonly CapabilityScope[] = [
|
||||
'browser.network.read',
|
||||
'browser.network.capture',
|
||||
'browser.network.sensitive.read',
|
||||
'browser.observation.read',
|
||||
'browser.observation.control',
|
||||
'browser.observation.sensitive.read',
|
||||
'browser.recording.read',
|
||||
'browser.recording.control',
|
||||
'browser.recording.sensitive.read',
|
||||
'browser.callable.execute',
|
||||
'browser.debugger.read',
|
||||
'browser.debugger.control',
|
||||
'browser.transform.read',
|
||||
'browser.transform.manage',
|
||||
'browser.transform.execute',
|
||||
'browser.proxy.read',
|
||||
'browser.proxy.write',
|
||||
];
|
||||
@@ -165,13 +265,23 @@ const payloadSchemas = {
|
||||
'proxy.switch': v.strictObject({ id }),
|
||||
'proxy.rule.save': proxyRule,
|
||||
'proxy.rule.delete': v.strictObject({ id }),
|
||||
'proxy.rules.apply': noPayload,
|
||||
'proxy.auto.apply': noPayload,
|
||||
'proxy.rules.preview': v.strictObject({ url: httpUrl }),
|
||||
'proxy.rules.compile': noPayload,
|
||||
'proxy.rules.reorder': v.strictObject({ ids: v.pipe(v.array(id), v.maxLength(5_000)) }),
|
||||
'proxy.rules.settings': proxyRouting,
|
||||
'proxy.rules.stats': noPayload,
|
||||
'proxy.rules.stats.clear': noPayload,
|
||||
'proxy.source.save': proxyRuleSourceInput,
|
||||
'proxy.source.refresh': v.strictObject({ id }),
|
||||
'proxy.source.delete': v.strictObject({ id }),
|
||||
'proxy.sources.reorder': v.strictObject({ ids: v.pipe(v.array(id), v.maxLength(200)) }),
|
||||
'proxy.source.rules': v.strictObject({
|
||||
id,
|
||||
offset: nonNegativeInteger,
|
||||
limit: v.pipe(v.number(), v.safeInteger(), v.minValue(1), v.maxValue(500)),
|
||||
query: v.optional(v.pipe(v.string(), v.trim(), v.maxLength(2_048))),
|
||||
}),
|
||||
'proxy.site.route': v.strictObject({ url: httpUrl, profileId: id }),
|
||||
'proxy.site.route.clear': v.strictObject({ url: httpUrl }),
|
||||
'proxy.auth.set': v.strictObject({ profileId: id, password: v.pipe(v.string(), v.maxLength(4_096)) }),
|
||||
'proxy.auth.status': v.strictObject({ profileId: id }),
|
||||
'proxy.config.export': noPayload,
|
||||
@@ -182,9 +292,12 @@ const payloadSchemas = {
|
||||
'cookie.removeMany': v.strictObject({ cookies: v.pipe(v.array(cookieRemoveInput), v.minLength(1), v.maxLength(1_000)) }),
|
||||
'cookie.import': v.strictObject({ url, format: v.picklist(['json', 'netscape', 'set-cookie']), text: v.pipe(v.string(), v.maxLength(2 * 1024 * 1024)) }),
|
||||
'cookie.export': v.strictObject({ url, format: v.picklist(['json', 'netscape', 'set-cookie']), includeValues: v.boolean() }),
|
||||
'ua.save': userAgentRule,
|
||||
'ua.delete': v.strictObject({ id }),
|
||||
'ua.apply': noPayload,
|
||||
'ua.catalog': noPayload,
|
||||
'ua.resolve': v.strictObject({ url: httpUrl }),
|
||||
'ua.profile.save': userAgentProfileInput,
|
||||
'ua.profile.delete': v.strictObject({ id }),
|
||||
'ua.site.apply': v.strictObject({ url: httpUrl, profileId: id }),
|
||||
'ua.site.reset': v.strictObject({ url: httpUrl }),
|
||||
'context.capture': v.strictObject(contextOptions),
|
||||
'context.node.inspect': v.strictObject({ ...targetFields, captureId, nodeId }),
|
||||
'context.node.action': v.pipe(v.strictObject({
|
||||
@@ -243,16 +356,51 @@ const payloadSchemas = {
|
||||
'network.capture.send': v.strictObject({ ...targetFields, id }),
|
||||
'network.capture.poc': v.strictObject({ ...targetFields, id }),
|
||||
'network.capture.analysis': v.strictObject({ ...targetFields, id }),
|
||||
'observation.start': v.strictObject({
|
||||
'recording.start': v.strictObject({
|
||||
...targetFields,
|
||||
captureValues: v.optional(v.boolean()),
|
||||
maxEntries: v.optional(v.pipe(v.number(), v.safeInteger(), v.minValue(10), v.maxValue(200))),
|
||||
maxEntries: v.optional(v.pipe(v.number(), v.safeInteger(), v.minValue(20), v.maxValue(500))),
|
||||
maxValueBytes: v.optional(v.pipe(v.number(), v.safeInteger(), v.minValue(256), v.maxValue(8_192))),
|
||||
}),
|
||||
'observation.status': v.strictObject(targetFields),
|
||||
'observation.list': v.strictObject({ ...targetFields, limit: v.optional(v.pipe(v.number(), v.safeInteger(), v.minValue(1), v.maxValue(200))) }),
|
||||
'observation.clear': v.strictObject(targetFields),
|
||||
'observation.stop': v.strictObject(targetFields),
|
||||
'recording.status': v.strictObject(targetFields),
|
||||
'recording.get': v.strictObject({ ...targetFields, limit: v.optional(v.pipe(v.number(), v.safeInteger(), v.minValue(1), v.maxValue(500))) }),
|
||||
'recording.clear': v.strictObject(targetFields),
|
||||
'recording.stop': v.strictObject(targetFields),
|
||||
'callable.create': v.union([
|
||||
v.strictObject({
|
||||
...targetFields, source: v.literal('recording'), callHandleId: id,
|
||||
name: v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(120)),
|
||||
}),
|
||||
v.strictObject({
|
||||
...targetFields, source: v.literal('deep-capture'), callFrameId: id,
|
||||
strategy: v.literal('selected-frame'),
|
||||
name: v.optional(v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(120))),
|
||||
}),
|
||||
v.strictObject({
|
||||
...targetFields, source: v.literal('deep-capture'), callFrameId: id,
|
||||
strategy: v.literal('request-transaction'),
|
||||
name: v.optional(v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(120))),
|
||||
transaction: pageRequestTransaction,
|
||||
}),
|
||||
v.strictObject({
|
||||
...targetFields, source: v.literal('deep-capture'), callFrameId: id,
|
||||
strategy: v.literal('expression'),
|
||||
name: v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(120)),
|
||||
functionExpression: v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(4_096)),
|
||||
}),
|
||||
]),
|
||||
'callable.list': v.strictObject(targetFields),
|
||||
'callable.execute': v.strictObject({ ...targetFields, callableId: id, args: v.pipe(v.array(v.unknown()), v.maxLength(64)) }),
|
||||
'callable.delete': v.strictObject({ ...targetFields, callableId: id }),
|
||||
'deep.capture.start': v.strictObject({ ...targetFields, matcher: deepCaptureMatcher }),
|
||||
'deep.capture.status': v.strictObject(targetFields),
|
||||
'deep.capture.keepalive': v.strictObject(targetFields),
|
||||
'deep.capture.resume': v.strictObject(targetFields),
|
||||
'deep.capture.detach': v.strictObject(targetFields),
|
||||
'transform.profile.list': v.strictObject(targetFields),
|
||||
'transform.profile.save': browserTransformProfileInputSchema,
|
||||
'transform.profile.delete': v.strictObject({ id }),
|
||||
'transform.execute': browserTransformExecuteSchema,
|
||||
'audit.list': v.strictObject({ limit: v.optional(v.pipe(v.number(), v.safeInteger(), v.minValue(1), v.maxValue(500))) }),
|
||||
'audit.clear': noPayload,
|
||||
'agent.runtime.get': noPayload,
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import * as v from 'valibot';
|
||||
|
||||
const id = v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(160));
|
||||
const tabId = v.pipe(v.number(), v.safeInteger(), v.minValue(1));
|
||||
const frameId = v.pipe(v.number(), v.safeInteger(), v.minValue(0));
|
||||
const documentId = v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(160));
|
||||
const blockedPathSegments = new Set(['__proto__', 'prototype', 'constructor']);
|
||||
|
||||
function isSafeValuePath(value: string): boolean {
|
||||
if (value === '$') return true;
|
||||
const normalized = value.startsWith('$.') ? value.slice(2) : value;
|
||||
if (!normalized || normalized.startsWith('.') || normalized.endsWith('.') || normalized.includes('..')) return false;
|
||||
const segments = normalized.split('.');
|
||||
return segments.length <= 64 && segments.every((segment) => segment.length > 0 && !blockedPathSegments.has(segment));
|
||||
}
|
||||
|
||||
function isOutputDestination(value: string): boolean {
|
||||
if (value === 'body') return true;
|
||||
if (value.startsWith('body.')) return isSafeValuePath(value.slice(5));
|
||||
if (value.toLowerCase().startsWith('header.')) {
|
||||
const name = value.slice(7);
|
||||
return Boolean(name) && !/[\r\n:]/.test(name);
|
||||
}
|
||||
if (value.startsWith('query.')) {
|
||||
const name = value.slice(6);
|
||||
return Boolean(name) && !/[\r\n&#=]/.test(name);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const valuePath = v.pipe(
|
||||
v.string(), v.trim(), v.minLength(1), v.maxLength(512),
|
||||
v.check(isSafeValuePath, '转换值路径无效或包含保留字段'),
|
||||
);
|
||||
const httpOrigin = v.pipe(
|
||||
v.string(),
|
||||
v.trim(),
|
||||
v.url(),
|
||||
v.maxLength(2_048),
|
||||
v.check((value) => {
|
||||
const parsed = new URL(value);
|
||||
return ['http:', 'https:'].includes(parsed.protocol) && parsed.origin === value;
|
||||
}, '必须是 HTTP(S) 页面来源,不得包含路径'),
|
||||
);
|
||||
|
||||
const target = v.strictObject({ tabId, frameId, documentId: v.optional(documentId) });
|
||||
const nodeName = v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(120));
|
||||
const reference = v.strictObject({ nodeId: id, path: v.optional(valuePath) });
|
||||
const contextReadNode = v.strictObject({ id, name: nodeName, kind: v.literal('context.read'), path: valuePath });
|
||||
const builtinNode = v.strictObject({
|
||||
id,
|
||||
name: nodeName,
|
||||
kind: v.literal('builtin'),
|
||||
operation: v.picklist([
|
||||
'value.literal',
|
||||
'json.stringify', 'json.parse', 'text.toString', 'url.encode', 'url.decode',
|
||||
'base64.encode', 'base64.decode', 'hex.encode', 'hex.decode',
|
||||
'object.pick', 'object.compose', 'form.compose',
|
||||
]),
|
||||
inputs: v.pipe(v.array(reference), v.maxLength(64)),
|
||||
options: v.optional(v.record(v.string(), v.unknown())),
|
||||
});
|
||||
const pageCallNode = v.strictObject({
|
||||
id,
|
||||
name: nodeName,
|
||||
kind: v.literal('page.call'),
|
||||
callableId: id,
|
||||
arguments: v.pipe(v.array(reference), v.maxLength(64)),
|
||||
});
|
||||
const outputWriteNode = v.strictObject({
|
||||
id,
|
||||
name: nodeName,
|
||||
kind: v.literal('output.write'),
|
||||
destination: v.pipe(
|
||||
v.string(), v.trim(), v.minLength(1), v.maxLength(512),
|
||||
v.check(isOutputDestination, '输出目标必须是 body、body.<path>、header.<name> 或 query.<name>'),
|
||||
),
|
||||
source: reference,
|
||||
encoding: v.picklist(['auto', 'text', 'json', 'base64']),
|
||||
});
|
||||
const pipelineNode = v.union([contextReadNode, builtinNode, pageCallNode, outputWriteNode]);
|
||||
const direction = v.pipe(
|
||||
v.strictObject({
|
||||
enabled: v.boolean(),
|
||||
nodes: v.pipe(v.array(pipelineNode), v.maxLength(64)),
|
||||
}),
|
||||
v.check((value) => !value.enabled || value.nodes.length > 0, '启用的转换方向必须包含 Pipeline 节点'),
|
||||
v.check((value) => new Set(value.nodes.map((item) => item.id)).size === value.nodes.length, '同一转换方向不能包含重复节点 ID'),
|
||||
v.check((value) => !value.enabled || value.nodes.some((item) => item.kind === 'output.write'), '启用的转换方向必须包含输出节点'),
|
||||
);
|
||||
|
||||
const profileFields = {
|
||||
name: v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(120)),
|
||||
enabled: v.boolean(),
|
||||
target,
|
||||
origin: httpOrigin,
|
||||
match: v.strictObject({
|
||||
methods: v.pipe(v.array(v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(32))), v.maxLength(16)),
|
||||
urlPattern: v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(2_048)),
|
||||
}),
|
||||
request: direction,
|
||||
response: direction,
|
||||
failMode: v.literal('closed'),
|
||||
maxConcurrency: v.pipe(v.number(), v.safeInteger(), v.minValue(1), v.maxValue(8)),
|
||||
};
|
||||
|
||||
export const browserTransformProfileInputSchema = v.strictObject({
|
||||
id: v.optional(id),
|
||||
...profileFields,
|
||||
});
|
||||
|
||||
export const browserTransformProfileSchema = v.strictObject({
|
||||
id,
|
||||
...profileFields,
|
||||
createdAt: v.pipe(v.number(), v.safeInteger(), v.minValue(0)),
|
||||
updatedAt: v.pipe(v.number(), v.safeInteger(), v.minValue(0)),
|
||||
});
|
||||
|
||||
const header = v.strictObject({
|
||||
name: v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(512), v.regex(/^[^\r\n:]+$/, 'Header 名称无效')),
|
||||
value: v.pipe(v.string(), v.maxLength(1_000_000)),
|
||||
});
|
||||
|
||||
export const browserTransformExecuteSchema = v.strictObject({
|
||||
profileId: id,
|
||||
direction: v.picklist(['request', 'response']),
|
||||
packet: v.strictObject({
|
||||
method: v.optional(v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(32))),
|
||||
url: v.pipe(v.string(), v.trim(), v.url(), v.maxLength(8_192)),
|
||||
statusCode: v.optional(v.pipe(v.number(), v.safeInteger(), v.minValue(100), v.maxValue(999))),
|
||||
headers: v.pipe(v.array(header), v.maxLength(512)),
|
||||
bodyBase64: v.pipe(v.string(), v.maxLength(11_184_820)),
|
||||
}),
|
||||
});
|
||||
Reference in New Issue
Block a user