Files
yaklang-chrome-extension/src/features/authorization-testing/baseline-execution.ts
T

572 lines
22 KiB
TypeScript

import type {
BrowserAuthorizationCompiledRequest,
BrowserAuthorizationResourceSelector,
BrowserAuthorizationResourceValue,
BrowserTransformExecution,
BrowserTransformPacket,
} from '@/types/models';
import { ExtensionError } from '@/shared/errors';
import { fingerprintAuthorizationComparisonValue } from './baseline-metadata';
import {
replaceStructuredAuthorizationBodyValue,
} from './structured-body';
const MAX_RESOURCE_VALUE_BYTES = 8 * 1_024;
interface ParsedAuthorizationRequest {
method: string;
requestTarget: string;
protocol: string;
headers: Array<{ name: string; value: string }>;
bytes: Uint8Array;
bodyOffset: number;
}
function base64ToBytes(value: string): Uint8Array {
let binary: string;
try {
binary = atob(value);
} catch {
throw new ExtensionError('authorization_value_invalid', '授权资源值不是有效的 Base64');
}
return Uint8Array.from(binary, (character) => character.charCodeAt(0));
}
function bytesToBase64(bytes: Uint8Array): string {
let binary = '';
const chunkSize = 0x8000;
for (let offset = 0; offset < bytes.length; offset += chunkSize) {
binary += String.fromCharCode(...bytes.subarray(offset, offset + chunkSize));
}
return btoa(binary);
}
function packetBodyOffset(bytes: Uint8Array): number {
for (let index = 0; index <= bytes.length - 4; index += 1) {
if (bytes[index] === 13 && bytes[index + 1] === 10
&& bytes[index + 2] === 13 && bytes[index + 3] === 10) {
return index + 4;
}
}
throw new ExtensionError('authorization_baseline_invalid', '授权基线缺少 HTTP Header 分隔符');
}
export function parseAuthorizationRequestPacket(
rawRequestBase64: string,
): ParsedAuthorizationRequest {
const bytes = base64ToBytes(rawRequestBase64);
const offset = packetBodyOffset(bytes);
let head: string;
try {
head = new TextDecoder('utf-8', { fatal: true }).decode(bytes.subarray(0, offset - 4));
} catch {
throw new ExtensionError('authorization_baseline_invalid', '授权基线请求头不是有效的 UTF-8');
}
const lines = head.split('\r\n');
const requestLine = lines.shift()?.split(/\s+/) || [];
if (requestLine.length !== 3 || !/^[A-Z]{1,16}$/.test(requestLine[0])) {
throw new ExtensionError('authorization_baseline_invalid', '授权基线请求行无效');
}
const headers = lines.slice(0, 256).flatMap((line) => {
const separator = line.indexOf(':');
if (separator <= 0) return [];
const name = line.slice(0, separator).trim().slice(0, 256);
const value = line.slice(separator + 1).trim().slice(0, 16_384);
return name ? [{ name, value }] : [];
});
return {
method: requestLine[0],
requestTarget: requestLine[1],
protocol: requestLine[2],
headers,
bytes,
bodyOffset: offset,
};
}
function parameterSelector(
location: 'header' | 'query',
path: string,
): { name: string; index?: number } {
const prefix = `${location}.`;
if (!path.startsWith(prefix)) {
throw new ExtensionError('authorization_selector_invalid', '授权资源字段路径与位置不匹配');
}
const raw = path.slice(prefix.length);
const indexed = raw.match(/^(.*)\[(\d+)]$/);
const name = indexed ? indexed[1] : raw;
const index = indexed ? Number(indexed[2]) : undefined;
if (!name || (index !== undefined && (!Number.isSafeInteger(index) || index < 0))) {
throw new ExtensionError('authorization_selector_invalid', '授权资源字段路径无效');
}
return { name, index };
}
function pathSegmentSelector(path: string): number {
const matched = path.match(/^path\.segment\[(\d+)]$/);
const index = matched ? Number(matched[1]) : -1;
if (!Number.isSafeInteger(index) || index < 0) {
throw new ExtensionError('authorization_selector_invalid', '授权路径资源字段无效');
}
return index;
}
function valuesForQuery(url: URL, name: string): string[] {
return [...url.searchParams].filter(([key]) => key === name).map(([, value]) => value);
}
export function extractAuthorizationResourceValue(
requestUrl: string,
rawRequestBase64: string,
baselineId: string,
selector: { location: 'header' | 'path' | 'query'; path: string },
valueFingerprint: string,
): BrowserAuthorizationResourceValue {
const url = new URL(requestUrl);
let value: string;
if (selector.location === 'header') {
const selected = parameterSelector('header', selector.path);
const values = parseAuthorizationRequestPacket(rawRequestBase64).headers
.filter((header) => header.name.toLowerCase() === selected.name.toLowerCase())
.map((header) => header.value);
if (selected.index === undefined && values.length !== 1) {
throw new ExtensionError('authorization_selector_ambiguous', '授权 Header 字段存在多个同名值,必须选择带序号的字段');
}
const index = selected.index ?? 0;
if (index >= values.length) {
throw new ExtensionError('authorization_selector_invalid', '授权 Header 资源字段不存在');
}
value = values[index];
} else if (selector.location === 'path') {
const index = pathSegmentSelector(selector.path);
const segments = url.pathname.split('/').filter(Boolean);
if (index >= segments.length) {
throw new ExtensionError('authorization_selector_invalid', '授权路径资源字段不存在');
}
try {
value = decodeURIComponent(segments[index]);
} catch {
value = segments[index];
}
} else {
const selected = parameterSelector('query', selector.path);
const values = valuesForQuery(url, selected.name);
if (selected.index === undefined && values.length !== 1) {
throw new ExtensionError('authorization_selector_ambiguous', '授权查询字段存在多个同名值,必须选择带序号的字段');
}
const index = selected.index ?? 0;
if (index >= values.length) {
throw new ExtensionError('authorization_selector_invalid', '授权查询资源字段不存在');
}
value = values[index];
}
const bytes = new TextEncoder().encode(value);
if (bytes.byteLength > MAX_RESOURCE_VALUE_BYTES) {
throw new ExtensionError('authorization_value_too_large', '授权资源值超过 8 KiB 上限');
}
return {
version: 1,
baselineId,
source: 'wire',
location: selector.location,
path: selector.path,
valueType: 'string',
byteLength: bytes.byteLength,
valueBase64: bytesToBase64(bytes),
valueFingerprint,
};
}
export function replaceAuthorizationResourceValue(
requestUrl: string,
selector: { location: 'path' | 'query'; path: string },
replacement: string,
): string {
const url = new URL(requestUrl);
if (selector.location === 'path') {
const selectedIndex = pathSegmentSelector(selector.path);
let currentIndex = -1;
const segments = url.pathname.split('/');
const next = segments.map((segment) => {
if (!segment) return segment;
currentIndex += 1;
return currentIndex === selectedIndex ? encodeURIComponent(replacement) : segment;
});
if (currentIndex < selectedIndex) {
throw new ExtensionError('authorization_selector_invalid', '授权路径资源字段不存在');
}
url.pathname = next.join('/');
return url.toString();
}
const selected = parameterSelector('query', selector.path);
const entries = [...url.searchParams];
const matchingIndexes = entries.flatMap(([name], index) => name === selected.name ? [index] : []);
if (selected.index === undefined && matchingIndexes.length !== 1) {
throw new ExtensionError('authorization_selector_ambiguous', '授权查询字段存在多个同名值,必须选择带序号的字段');
}
const occurrence = selected.index ?? 0;
if (occurrence >= matchingIndexes.length) {
throw new ExtensionError('authorization_selector_invalid', '授权查询资源字段不存在');
}
entries[matchingIndexes[occurrence]][1] = replacement;
url.search = '';
for (const [name, value] of entries) url.searchParams.append(name, value);
return url.toString();
}
export async function compileAuthorizationBaselineRequest(input: {
baselineId: string;
rawRequestBase64: string;
requestUrl: string;
publicUrl: string;
selector: BrowserAuthorizationResourceSelector & { source: 'wire' };
replacement: BrowserAuthorizationResourceValue;
comparisonKey: string;
isHttps: boolean;
}): Promise<BrowserAuthorizationCompiledRequest> {
const packet = parseAuthorizationRequestPacket(input.rawRequestBase64);
const method = packet.method.toUpperCase();
if (input.replacement.source !== 'wire'
|| input.replacement.location !== input.selector.location
|| input.replacement.path !== input.selector.path
|| !['string', 'number', 'boolean'].includes(input.replacement.valueType)) {
throw new ExtensionError('authorization_value_invalid', '授权资源值与矩阵选择器不匹配');
}
const replacementBytes = base64ToBytes(input.replacement.valueBase64);
if (replacementBytes.byteLength !== input.replacement.byteLength
|| replacementBytes.byteLength > MAX_RESOURCE_VALUE_BYTES) {
throw new ExtensionError('authorization_value_invalid', '授权资源值长度无效');
}
let replacementText: string;
try {
replacementText = new TextDecoder('utf-8', { fatal: true }).decode(replacementBytes);
} catch {
throw new ExtensionError('authorization_value_invalid', '授权资源值不是有效的 UTF-8 字符串');
}
let replacement: string | number | boolean;
if (input.replacement.valueType === 'string') {
replacement = replacementText;
} else if (input.replacement.valueType === 'number') {
try {
const parsed: unknown = JSON.parse(replacementText);
if (
typeof parsed !== 'number'
|| !Number.isFinite(parsed)
|| JSON.stringify(parsed) !== replacementText
) {
throw new Error('not canonical');
}
replacement = parsed;
} catch {
throw new ExtensionError('authorization_value_invalid', '授权数字资源值不是规范 JSON 数字');
}
} else if (replacementText === 'true' || replacementText === 'false') {
replacement = replacementText === 'true';
} else {
throw new ExtensionError('authorization_value_invalid', '授权布尔资源值必须是 true 或 false');
}
const fingerprint = await fingerprintAuthorizationComparisonValue(
input.comparisonKey,
replacementText,
);
if (fingerprint !== input.replacement.valueFingerprint) {
throw new ExtensionError('authorization_value_invalid', '授权资源值指纹校验失败');
}
const selector = input.selector;
const selectorLocation = selector.location;
if (selectorLocation === 'body') {
const origin = new URL(input.requestUrl).origin;
const transformed = replaceStructuredAuthorizationBodyValue({
packet: authorizationRequestToTransformPacket(input.rawRequestBase64, origin),
path: selector.path,
replacement,
});
const rawBytes = base64ToBytes(input.rawRequestBase64);
const compiled: BrowserAuthorizationCompiledRequest = {
version: 1,
baselineId: input.baselineId,
selector,
method: method as BrowserAuthorizationCompiledRequest['method'],
url: input.publicUrl,
isHttps: input.isHttps,
rawRequestBase64: input.rawRequestBase64,
resourceValueFingerprint: input.replacement.valueFingerprint,
packetFingerprint: `sha256:${[...new Uint8Array(await crypto.subtle.digest(
'SHA-256',
Uint8Array.from(rawBytes).buffer,
))].map((byte) => byte.toString(16).padStart(2, '0')).join('')}`,
};
return applyAuthorizationTransformExecution({
compiled,
execution: {
profileId: 'authorization-structured-body',
direction: 'request',
url: transformed.url,
bodyBase64: transformed.bodyBase64,
setHeaders: [],
removeHeaders: [],
logicalInput: undefined,
logicalOutput: undefined,
nodeDurations: [],
nodeTrace: [],
fieldChanges: [],
durationMs: 0,
},
origin,
allowedDestinations: [selector.path],
allowBody: true,
});
}
if (typeof replacement !== 'string') {
throw new ExtensionError(
'authorization_value_invalid',
'Header、Path 与 Query 资源替换只接受字符串',
);
}
if (selectorLocation === 'header' && /[\u0000\r\n]/.test(replacement as string)) {
throw new ExtensionError('authorization_value_invalid', '授权 Header 资源值包含非法控制字符');
}
const requestUrl = selectorLocation === 'header'
? input.requestUrl
: replaceAuthorizationResourceValue(
input.requestUrl,
{ location: selectorLocation, path: selector.path },
replacement as string,
);
const originalOrigin = new URL(input.requestUrl).origin;
if (new URL(requestUrl).origin !== originalOrigin) {
throw new ExtensionError('authorization_origin_changed', '资源替换不能改变请求来源');
}
const url = new URL(requestUrl);
const target = selectorLocation === 'header'
? packet.requestTarget
: `${url.pathname || '/'}${url.search}`;
const requestLine = new TextEncoder().encode(`${method} ${target} ${packet.protocol}\r\n`);
const firstLineEnd = packet.bytes.findIndex(
(byte, index) => byte === 13 && packet.bytes[index + 1] === 10,
);
if (firstLineEnd < 0 || firstLineEnd >= packet.bodyOffset - 4) {
throw new ExtensionError('authorization_baseline_invalid', '授权基线请求行边界无效');
}
let remainder = packet.bytes.subarray(firstLineEnd + 2);
if (selectorLocation === 'header') {
const selected = parameterSelector('header', selector.path);
const headerBytes = packet.bytes.subarray(firstLineEnd + 2, packet.bodyOffset - 4);
const headerLines = new TextDecoder('utf-8', { fatal: true }).decode(headerBytes).split('\r\n');
const matching = headerLines.flatMap((line, index) => {
const separator = line.indexOf(':');
return separator > 0 && line.slice(0, separator).trim().toLowerCase() === selected.name.toLowerCase()
? [index]
: [];
});
if (selected.index === undefined && matching.length !== 1) {
throw new ExtensionError('authorization_selector_ambiguous', '授权 Header 字段存在多个同名值,必须选择带序号的字段');
}
const occurrence = selected.index ?? 0;
if (occurrence >= matching.length) {
throw new ExtensionError('authorization_selector_invalid', '授权 Header 资源字段不存在');
}
const lineIndex = matching[occurrence];
const separator = headerLines[lineIndex].indexOf(':');
headerLines[lineIndex] = `${headerLines[lineIndex].slice(0, separator)}: ${replacement as string}`;
const rewrittenHeaders = new TextEncoder().encode(`${headerLines.join('\r\n')}\r\n\r\n`);
const body = packet.bytes.subarray(packet.bodyOffset);
remainder = new Uint8Array(rewrittenHeaders.byteLength + body.byteLength);
remainder.set(rewrittenHeaders);
remainder.set(body, rewrittenHeaders.byteLength);
}
const compiled = new Uint8Array(requestLine.byteLength + remainder.byteLength);
compiled.set(requestLine);
compiled.set(remainder, requestLine.byteLength);
return {
version: 1,
baselineId: input.baselineId,
selector,
method: method as BrowserAuthorizationCompiledRequest['method'],
url: input.publicUrl,
isHttps: input.isHttps,
rawRequestBase64: bytesToBase64(compiled),
resourceValueFingerprint: input.replacement.valueFingerprint,
packetFingerprint: `sha256:${[...new Uint8Array(await crypto.subtle.digest(
'SHA-256',
Uint8Array.from(compiled).buffer,
))].map((byte) => byte.toString(16).padStart(2, '0')).join('')}`,
};
}
function normalizedTransformDestination(destination: string): string {
const trimmed = destination.trim();
if (trimmed.toLowerCase().startsWith('header.')) {
return `header.${trimmed.slice(7).trim().toLowerCase()}`;
}
return trimmed;
}
function queryValueMap(url: URL): Map<string, string[]> {
const output = new Map<string, string[]>();
for (const [name, value] of url.searchParams) {
output.set(name, [...(output.get(name) || []), value]);
}
return output;
}
function sameStringValues(left: string[] | undefined, right: string[] | undefined): boolean {
return JSON.stringify(left || []) === JSON.stringify(right || []);
}
export function authorizationRequestToTransformPacket(
rawRequestBase64: string,
origin: string,
): BrowserTransformPacket {
const parsed = parseAuthorizationRequestPacket(rawRequestBase64);
let url: URL;
try {
url = new URL(parsed.requestTarget, origin);
} catch {
throw new ExtensionError('authorization_baseline_invalid', '授权基线请求目标无法转换为页面报文');
}
if (url.origin !== origin || url.hash) {
throw new ExtensionError('authorization_origin_changed', '授权基线请求目标超出了认证来源');
}
return {
method: parsed.method,
url: url.toString(),
headers: parsed.headers,
bodyBase64: bytesToBase64(parsed.bytes.subarray(parsed.bodyOffset)),
};
}
export async function applyAuthorizationTransformExecution(input: {
compiled: BrowserAuthorizationCompiledRequest;
execution: BrowserTransformExecution;
origin: string;
allowedDestinations: string[];
allowBody?: boolean;
}): Promise<BrowserAuthorizationCompiledRequest> {
const packet = parseAuthorizationRequestPacket(input.compiled.rawRequestBase64);
const baselinePacket = authorizationRequestToTransformPacket(
input.compiled.rawRequestBase64,
input.origin,
);
const allowed = new Set(input.allowedDestinations.map(normalizedTransformDestination));
const bodyChanged = input.execution.bodyBase64 !== baselinePacket.bodyBase64;
const bodyAllowed = input.allowBody && [...allowed].some(
(destination) => destination === 'body'
|| destination.startsWith('body.')
|| destination.startsWith('body['),
);
if (bodyChanged && !bodyAllowed) {
throw new ExtensionError(
'authorization_transform_unsupported',
'授权动态重算只有在逻辑明文绑定后才能改写 Body',
);
}
let transformedURL: URL;
const originalURL = new URL(baselinePacket.url);
try {
transformedURL = new URL(input.execution.url);
} catch {
throw new ExtensionError('authorization_transform_invalid', 'Transform Profile 返回了无效 URL');
}
if (
transformedURL.origin !== input.origin
|| transformedURL.pathname !== originalURL.pathname
|| transformedURL.hash
) {
throw new ExtensionError(
'authorization_transform_invalid',
'动态重算不能改变请求来源、路径或 fragment',
);
}
const originalQuery = queryValueMap(originalURL);
const transformedQuery = queryValueMap(transformedURL);
const queryNames = new Set([...originalQuery.keys(), ...transformedQuery.keys()]);
for (const name of queryNames) {
if (
!sameStringValues(originalQuery.get(name), transformedQuery.get(name))
&& !allowed.has(`query.${name}`)
) {
throw new ExtensionError(
'authorization_transform_invalid',
`Transform Profile 改写了未声明的查询字段: ${name}`,
);
}
}
const forbiddenHeaders = new Set(['authorization', 'cookie', 'proxy-authorization', 'host']);
const removed = new Set<string>();
for (const name of input.execution.removeHeaders) {
const normalized = name.trim().toLowerCase();
if (
forbiddenHeaders.has(normalized)
|| !allowed.has(`header.${normalized}`)
) {
throw new ExtensionError(
'authorization_transform_invalid',
`Transform Profile 尝试删除认证材料或未声明 Header: ${name}`,
);
}
removed.add(normalized);
}
const replacements = new Map<string, { name: string; value: string }>();
for (const header of input.execution.setHeaders) {
const normalized = header.name.trim().toLowerCase();
if (
!normalized
|| /[\r\n:]/.test(header.name)
|| /[\r\n]/.test(header.value)
|| forbiddenHeaders.has(normalized)
|| !allowed.has(`header.${normalized}`)
) {
throw new ExtensionError(
'authorization_transform_invalid',
`Transform Profile 尝试改写认证材料或未声明 Header: ${header.name}`,
);
}
replacements.set(normalized, { name: header.name.trim(), value: header.value });
removed.delete(normalized);
}
let headers = packet.headers.filter(
(header) => !removed.has(header.name.toLowerCase())
&& !replacements.has(header.name.toLowerCase()),
);
headers.push(...replacements.values());
const host = headers.find((header) => header.name.toLowerCase() === 'host')?.value;
if (!host || host !== transformedURL.host) {
throw new ExtensionError('authorization_transform_invalid', '动态重算后的 Host 与认证来源不一致');
}
const body = bodyChanged
? base64ToBytes(input.execution.bodyBase64)
: packet.bytes.subarray(packet.bodyOffset);
if (body.byteLength > 2 * 1_024 * 1_024) {
throw new ExtensionError('authorization_transform_invalid', '动态重算后的请求 Body 超过 2 MiB 上限');
}
if (bodyChanged) {
headers = headers.filter((header) => {
const name = header.name.toLowerCase();
return name !== 'content-length' && name !== 'transfer-encoding';
});
headers.push({ name: 'Content-Length', value: String(body.byteLength) });
}
const head = [
`${packet.method} ${transformedURL.pathname || '/'}${transformedURL.search} ${packet.protocol}`,
...headers.map((header) => `${header.name}: ${header.value}`),
'',
'',
].join('\r\n');
const headBytes = new TextEncoder().encode(head);
const raw = new Uint8Array(headBytes.byteLength + body.byteLength);
raw.set(headBytes);
raw.set(body, headBytes.byteLength);
return {
...input.compiled,
rawRequestBase64: bytesToBase64(raw),
packetFingerprint: `sha256:${[...new Uint8Array(await crypto.subtle.digest(
'SHA-256',
Uint8Array.from(raw).buffer,
))].map((byte) => byte.toString(16).padStart(2, '0')).join('')}`,
};
}