mirror of
https://github.com/yaklang/yaklang-chrome-extension.git
synced 2026-09-24 12:11:53 +08:00
405 lines
13 KiB
TypeScript
405 lines
13 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import type {
|
|
BrowserAuthorizationBaseline,
|
|
BrowserTransformExecution,
|
|
BrowserTransformProfile,
|
|
} from '@/types/models';
|
|
import type { BrowserTransformReplayDraft } from '@/features/browser-transform/replay-draft';
|
|
import {
|
|
assertAuthorizationLogicalProtocol,
|
|
assertAuthorizationLogicalPacketStructure,
|
|
authorizationTransformOutputDestinations,
|
|
buildAuthorizationLogicalRequestBinding,
|
|
replaceAuthorizationLogicalResource,
|
|
} from './logical-binding';
|
|
|
|
const executeBrowserTransform = vi.fn();
|
|
|
|
vi.mock('wxt/browser', () => {
|
|
const event = { addListener: vi.fn() };
|
|
return {
|
|
browser: {
|
|
tabs: { onRemoved: event, onCreated: event },
|
|
webNavigation: {
|
|
onBeforeNavigate: event,
|
|
onCommitted: event,
|
|
onDOMContentLoaded: event,
|
|
onCompleted: event,
|
|
onHistoryStateUpdated: event,
|
|
onReferenceFragmentUpdated: event,
|
|
onErrorOccurred: event,
|
|
},
|
|
},
|
|
};
|
|
});
|
|
|
|
vi.mock('@/features/browser-transform/service', () => ({
|
|
executeBrowserTransform: (...args: unknown[]) => executeBrowserTransform(...args),
|
|
getBrowserTransformProfile: vi.fn(),
|
|
}));
|
|
|
|
function base64(value: string): string {
|
|
const bytes = new TextEncoder().encode(value);
|
|
return btoa(String.fromCharCode(...bytes));
|
|
}
|
|
|
|
function comparisonKey(): string {
|
|
return btoa(String.fromCharCode(...new Uint8Array(32).fill(23)))
|
|
.replace(/\+/g, '-')
|
|
.replace(/\//g, '_')
|
|
.replace(/=+$/, '');
|
|
}
|
|
|
|
function profile(outputs = ['body.encryptedData', 'header.Content-Type']): BrowserTransformProfile {
|
|
return {
|
|
id: 'profile-left',
|
|
name: '登录请求加密',
|
|
enabled: true,
|
|
target: { tabId: 11, frameId: 0, documentId: 'document-left' },
|
|
isolationContextId: 'browser-profile:store-left',
|
|
cookieStoreId: 'store-left',
|
|
origin: 'https://example.test',
|
|
match: { methods: ['POST'], urlPattern: '*/api/login' },
|
|
request: {
|
|
enabled: true,
|
|
nodes: outputs.map((destination, index) => ({
|
|
id: `output-${index}`,
|
|
name: destination,
|
|
kind: 'output.write' as const,
|
|
destination,
|
|
source: { nodeId: 'callable' },
|
|
encoding: 'text' as const,
|
|
})),
|
|
},
|
|
response: { enabled: false, nodes: [] },
|
|
failMode: 'closed',
|
|
maxConcurrency: 1,
|
|
createdAt: 1,
|
|
updatedAt: 2,
|
|
};
|
|
}
|
|
|
|
function baseline(): BrowserAuthorizationBaseline {
|
|
return {
|
|
version: 1,
|
|
id: 'baseline-left',
|
|
deviceId: 'device-left',
|
|
installationId: 'installation-left',
|
|
isolationContextId: 'browser-profile:store-left',
|
|
cookieStoreId: 'store-left',
|
|
origin: 'https://example.test',
|
|
grantId: 'grant-left',
|
|
target: { tabId: 11, frameId: 0, documentId: 'document-left' },
|
|
authContextReference: { kind: 'handle', id: 'auth-left' },
|
|
networkRequestId: 'request-left',
|
|
request: {
|
|
method: 'POST',
|
|
url: 'https://example.test/api/login',
|
|
path: '/api/login',
|
|
contentType: 'application/x-www-form-urlencoded',
|
|
actionFingerprint: `sha256:${'a'.repeat(64)}`,
|
|
headerNames: ['Host', 'Content-Type', 'Cookie'],
|
|
fields: [{
|
|
location: 'body',
|
|
path: 'body.encryptedData',
|
|
valueType: 'string',
|
|
byteLength: 32,
|
|
valueFingerprint: `workspace-hmac-sha256:${'b'.repeat(64)}`,
|
|
category: 'unknown',
|
|
}],
|
|
},
|
|
createdAt: 1,
|
|
expiresAt: Date.now() + 60_000,
|
|
};
|
|
}
|
|
|
|
function draft(): BrowserTransformReplayDraft {
|
|
return {
|
|
version: 1,
|
|
profileId: 'profile-left',
|
|
direction: 'request',
|
|
origin: 'https://example.test',
|
|
method: 'POST',
|
|
url: 'https://example.test/api/login',
|
|
headers: '{"Content-Type":"application/json"}',
|
|
body: '{"username":"alice","orderId":"order-a"}',
|
|
updatedAt: 3,
|
|
};
|
|
}
|
|
|
|
describe('authorization logical plaintext binding', () => {
|
|
beforeEach(() => {
|
|
executeBrowserTransform.mockReset();
|
|
});
|
|
|
|
it('rejects a logical replay that changes the observed GraphQL operation', () => {
|
|
const observed = baseline().request;
|
|
observed.protocol = 'graphql';
|
|
observed.operationFingerprint = `sha256:${'1'.repeat(64)}`;
|
|
observed.operationNames = ['Order'];
|
|
const logical = {
|
|
...observed,
|
|
operationFingerprint: `sha256:${'2'.repeat(64)}`,
|
|
operationNames: ['CancelOrder'],
|
|
};
|
|
|
|
expect(() => assertAuthorizationLogicalProtocol(observed, logical)).toThrow(
|
|
'GraphQL operation 与线上基线不一致',
|
|
);
|
|
});
|
|
|
|
it('allows a logical GraphQL envelope when the encrypted wire baseline has no protocol metadata', () => {
|
|
const observed = baseline().request;
|
|
const logical = {
|
|
...observed,
|
|
protocol: 'graphql' as const,
|
|
operationFingerprint: `sha256:${'1'.repeat(64)}`,
|
|
operationNames: ['Order'],
|
|
};
|
|
|
|
expect(() => assertAuthorizationLogicalProtocol(observed, logical)).not.toThrow();
|
|
});
|
|
|
|
it('binds private plaintext field metadata only after the generated wire shape matches', async () => {
|
|
executeBrowserTransform.mockResolvedValue({
|
|
profileId: 'profile-left',
|
|
direction: 'request',
|
|
url: 'https://example.test/api/login',
|
|
bodyBase64: base64('encryptedData=ciphertext'),
|
|
setHeaders: [{ name: 'Content-Type', value: 'application/x-www-form-urlencoded' }],
|
|
removeHeaders: [],
|
|
logicalInput: {},
|
|
logicalOutput: {},
|
|
nodeDurations: [],
|
|
nodeTrace: [],
|
|
fieldChanges: [],
|
|
durationMs: 1,
|
|
} satisfies BrowserTransformExecution);
|
|
const raw = base64([
|
|
'POST /api/login HTTP/1.1',
|
|
'Host: example.test',
|
|
'Content-Type: application/x-www-form-urlencoded',
|
|
'Cookie: session=identity-a',
|
|
'',
|
|
'encryptedData=observed-ciphertext',
|
|
].join('\r\n'));
|
|
|
|
const binding = await buildAuthorizationLogicalRequestBinding({
|
|
baseline: baseline(),
|
|
rawRequestBase64: raw,
|
|
profile: profile(),
|
|
draft: draft(),
|
|
comparisonKey: comparisonKey(),
|
|
});
|
|
|
|
expect(binding.request.fields).toEqual(expect.arrayContaining([
|
|
expect.objectContaining({
|
|
location: 'body',
|
|
path: 'body.orderId',
|
|
valueType: 'string',
|
|
category: 'resource',
|
|
}),
|
|
]));
|
|
expect(binding.outputDestinations).toEqual(['body.encryptedData', 'header.content-type']);
|
|
expect(binding.bindingFingerprint).toMatch(/^sha256:[a-f0-9]{64}$/);
|
|
expect(JSON.stringify(binding)).not.toContain('order-a');
|
|
expect(JSON.stringify(binding)).not.toContain('alice');
|
|
});
|
|
|
|
it('keeps a multi-output AES plus RSA envelope tied to one logical business object', async () => {
|
|
executeBrowserTransform.mockResolvedValue({
|
|
profileId: 'profile-left',
|
|
direction: 'request',
|
|
url: 'https://example.test/api/login',
|
|
bodyBase64: base64([
|
|
'encryptedData=aes-ciphertext',
|
|
'encryptedKey=rsa-wrapped-key',
|
|
'encryptedIv=rsa-wrapped-iv',
|
|
].join('&')),
|
|
setHeaders: [{ name: 'Content-Type', value: 'application/x-www-form-urlencoded' }],
|
|
removeHeaders: [],
|
|
logicalInput: {},
|
|
logicalOutput: {},
|
|
nodeDurations: [],
|
|
nodeTrace: [],
|
|
fieldChanges: [],
|
|
durationMs: 1,
|
|
} satisfies BrowserTransformExecution);
|
|
const raw = base64([
|
|
'POST /api/login HTTP/1.1',
|
|
'Host: example.test',
|
|
'Content-Type: application/x-www-form-urlencoded',
|
|
'Cookie: session=identity-a',
|
|
'',
|
|
[
|
|
'encryptedData=observed-aes-ciphertext',
|
|
'encryptedKey=observed-rsa-key',
|
|
'encryptedIv=observed-rsa-iv',
|
|
].join('&'),
|
|
].join('\r\n'));
|
|
|
|
const binding = await buildAuthorizationLogicalRequestBinding({
|
|
baseline: baseline(),
|
|
rawRequestBase64: raw,
|
|
profile: profile([
|
|
'body.encryptedData',
|
|
'body.encryptedKey',
|
|
'body.encryptedIv',
|
|
'header.Content-Type',
|
|
]),
|
|
draft: draft(),
|
|
comparisonKey: comparisonKey(),
|
|
});
|
|
|
|
expect(binding.outputDestinations).toEqual([
|
|
'body.encryptedData',
|
|
'body.encryptedIv',
|
|
'body.encryptedKey',
|
|
'header.content-type',
|
|
]);
|
|
expect(binding.request.fields).toEqual(expect.arrayContaining([
|
|
expect.objectContaining({ path: 'body.orderId', category: 'resource' }),
|
|
expect.objectContaining({ path: 'body.username' }),
|
|
]));
|
|
expect(binding.validation.proofLevel).toBe('structure');
|
|
});
|
|
|
|
it('rejects a gateway whose generated serialization does not match the captured request', async () => {
|
|
executeBrowserTransform.mockResolvedValue({
|
|
profileId: 'profile-left',
|
|
direction: 'request',
|
|
url: 'https://example.test/api/login',
|
|
bodyBase64: base64('{"encryptedData":"ciphertext"}'),
|
|
setHeaders: [{ name: 'Content-Type', value: 'application/json' }],
|
|
removeHeaders: [],
|
|
logicalInput: {},
|
|
logicalOutput: {},
|
|
nodeDurations: [],
|
|
nodeTrace: [],
|
|
fieldChanges: [],
|
|
durationMs: 1,
|
|
} satisfies BrowserTransformExecution);
|
|
const raw = base64([
|
|
'POST /api/login HTTP/1.1',
|
|
'Host: example.test',
|
|
'Content-Type: application/x-www-form-urlencoded',
|
|
'',
|
|
'encryptedData=observed-ciphertext',
|
|
].join('\r\n'));
|
|
|
|
await expect(buildAuthorizationLogicalRequestBinding({
|
|
baseline: baseline(),
|
|
rawRequestBase64: raw,
|
|
profile: profile(),
|
|
draft: draft(),
|
|
comparisonKey: comparisonKey(),
|
|
})).rejects.toThrow('结构不一致');
|
|
});
|
|
|
|
it('rejects compressed request bodies because their logical structure cannot be proven', async () => {
|
|
executeBrowserTransform.mockResolvedValue({
|
|
profileId: 'profile-left',
|
|
direction: 'request',
|
|
url: 'https://example.test/api/login',
|
|
bodyBase64: base64('encryptedData=ciphertext'),
|
|
setHeaders: [{ name: 'Content-Type', value: 'application/x-www-form-urlencoded' }],
|
|
removeHeaders: [],
|
|
logicalInput: {},
|
|
logicalOutput: {},
|
|
nodeDurations: [],
|
|
nodeTrace: [],
|
|
fieldChanges: [],
|
|
durationMs: 1,
|
|
} satisfies BrowserTransformExecution);
|
|
const raw = base64([
|
|
'POST /api/login HTTP/1.1',
|
|
'Host: example.test',
|
|
'Content-Type: application/x-www-form-urlencoded',
|
|
'Content-Encoding: gzip',
|
|
'',
|
|
'encryptedData=observed-ciphertext',
|
|
].join('\r\n'));
|
|
|
|
await expect(buildAuthorizationLogicalRequestBinding({
|
|
baseline: baseline(),
|
|
rawRequestBase64: raw,
|
|
profile: profile(),
|
|
draft: draft(),
|
|
comparisonKey: comparisonKey(),
|
|
})).rejects.toThrow('压缩或编码后的请求 Body');
|
|
});
|
|
|
|
it('rejects a conditionally changed output envelope during later matrix compilation', () => {
|
|
const observed = {
|
|
method: 'POST',
|
|
url: 'https://example.test/api/login',
|
|
headers: [{ name: 'Content-Type', value: 'application/x-www-form-urlencoded' }],
|
|
bodyBase64: base64('encryptedData=observed-ciphertext'),
|
|
};
|
|
const generated = {
|
|
...observed,
|
|
bodyBase64: base64('encryptedData=generated-ciphertext&unexpected=side-channel'),
|
|
};
|
|
|
|
expect(() => assertAuthorizationLogicalPacketStructure(
|
|
generated,
|
|
observed,
|
|
)).toThrow('Body 字段与类型结构');
|
|
});
|
|
|
|
it('replaces one explicit JSON plaintext field without touching its siblings', () => {
|
|
const packet = {
|
|
method: 'POST',
|
|
url: 'https://example.test/api/orders',
|
|
headers: [{ name: 'Content-Type', value: 'application/json' }],
|
|
bodyBase64: base64('{"orderId":"order-a","note":"keep"}'),
|
|
};
|
|
const replaced = replaceAuthorizationLogicalResource({
|
|
packet,
|
|
selector: { source: 'logical', location: 'body', path: 'body.orderId' },
|
|
replacement: 'order-b',
|
|
});
|
|
|
|
expect(JSON.parse(new TextDecoder().decode(
|
|
Uint8Array.from(atob(replaced.bodyBase64), (character) => character.charCodeAt(0)),
|
|
))).toEqual({ orderId: 'order-b', note: 'keep' });
|
|
});
|
|
|
|
it('preserves the primitive type of a numeric logical resource', () => {
|
|
const packet = {
|
|
method: 'POST',
|
|
url: 'https://example.test/graphql',
|
|
headers: [{ name: 'Content-Type', value: 'application/json' }],
|
|
bodyBase64: base64('{"variables":{"orderId":42},"query":"query Order { order { id } }"}'),
|
|
};
|
|
const replaced = replaceAuthorizationLogicalResource({
|
|
packet,
|
|
selector: {
|
|
source: 'logical',
|
|
location: 'body',
|
|
path: 'body.variables.orderId',
|
|
},
|
|
replacement: 84,
|
|
});
|
|
|
|
expect(JSON.parse(new TextDecoder().decode(
|
|
Uint8Array.from(atob(replaced.bodyBase64), (character) => character.charCodeAt(0)),
|
|
)).variables.orderId).toBe(84);
|
|
expect(() => replaceAuthorizationLogicalResource({
|
|
packet,
|
|
selector: {
|
|
source: 'logical',
|
|
location: 'body',
|
|
path: 'body.variables.orderId',
|
|
},
|
|
replacement: '84',
|
|
})).toThrow('不能改变字段类型');
|
|
});
|
|
|
|
it('refuses profiles that attempt to synthesize authentication headers', () => {
|
|
expect(() => authorizationTransformOutputDestinations(
|
|
profile(['header.Authorization']),
|
|
)).toThrow('认证 Header');
|
|
});
|
|
});
|