mirror of
https://github.com/yaklang/yaklang-chrome-extension.git
synced 2026-09-22 03:10:43 +08:00
fix(capture): automate safe bidirectional gateways for users and agents
This commit is contained in:
@@ -0,0 +1,113 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { spawn } from 'node:child_process'
|
||||
import { resolve } from 'node:path'
|
||||
import { createDecipheriv } from 'node:crypto'
|
||||
import { extensionRequest, launchBrowserAgentContractHarness } from './browser-agent-contract-harness.mjs'
|
||||
|
||||
function body(packet) { return JSON.parse(packet.raw.slice(packet.raw.indexOf('\r\n\r\n') + 4)) }
|
||||
function decrypt(envelope) {
|
||||
const decipher = createDecipheriv('aes-128-cbc', Buffer.from(envelope.key, 'hex'), Buffer.from(envelope.iv, 'hex'))
|
||||
return JSON.parse(Buffer.concat([decipher.update(Buffer.from(envelope.message, 'base64')), decipher.final()]).toString())
|
||||
}
|
||||
|
||||
const targetURL = process.env.LOGIN_TARGET || 'http://localhost:8080/crypto/sqli/aes-ecb/encrypt/login'
|
||||
const engine = spawn('go', ['test', './common/yakgrpc', '-run', '^TestBrowserAgentLiveGateway$', '-count=1', '-v'], {
|
||||
cwd: process.env.YAKLANG_ROOT || resolve(import.meta.dirname, '../../../go/yaklang'),
|
||||
env: { ...process.env, YAK_BROWSER_AGENT_E2E: '1' }, stdio: ['ignore', 'pipe', 'pipe'],
|
||||
})
|
||||
let output = ''
|
||||
let harness, connection
|
||||
const exited = new Promise(resolve => engine.once('exit', code => resolve(code)))
|
||||
const ready = new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error(`Go Agent bridge startup timed out: ${output.slice(-4000)}`)), 120_000)
|
||||
engine.stdout.on('data', chunk => {
|
||||
output += chunk
|
||||
const match = output.match(/YAK_AGENT_E2E=(\{[^\n]+\})/)
|
||||
if (match) { clearTimeout(timer); resolve(JSON.parse(match[1])) }
|
||||
})
|
||||
engine.stderr.on('data', chunk => { output += chunk })
|
||||
engine.once('error', error => { clearTimeout(timer); reject(error) })
|
||||
engine.once('exit', code => { clearTimeout(timer); reject(new Error(`Go bridge exited ${code}: ${output}`)) })
|
||||
})
|
||||
try {
|
||||
connection = await ready
|
||||
const tool = async (name, params = {}) => {
|
||||
const response = await fetch(connection.endpoint, { method: 'POST', headers: { 'X-Test-Token': connection.token }, body: JSON.stringify({ tool: name, params }) })
|
||||
if (!response.ok) throw new Error(`${name}: ${await response.text()}`)
|
||||
return response.json()
|
||||
}
|
||||
harness = await launchBrowserAgentContractHarness({ profilePrefix: 'yakit-agent-gateway-', targetURL })
|
||||
const { controlPage, targetPage, tabId } = harness
|
||||
let completed = 0, blocked = 0
|
||||
targetPage.on('response', response => { if (response.url() === targetURL && response.request().method() === 'POST') completed++ })
|
||||
targetPage.on('requestfailed', request => { if (request.url() === targetURL && request.failure()?.errorText.includes('BLOCKED_BY_CLIENT')) blocked++ })
|
||||
const state = await controlPage.evaluate(async () => (await chrome.runtime.sendMessage({ action: 'state.get' })).data)
|
||||
await extensionRequest(controlPage, 'bridge.config.save', {
|
||||
transport: 'websocket', nativeHost: 'com.yaklang.browser_agent', endpoint: connection.bridge,
|
||||
autoConnect: false, installationId: state.bridge.installationId,
|
||||
})
|
||||
await controlPage.evaluate(async () => {
|
||||
const result = await chrome.runtime.sendMessage({ action: 'bridge.pair' })
|
||||
if (!result.ok) throw new Error(JSON.stringify(result.error))
|
||||
})
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
const status = await controlPage.evaluate(async () => (await chrome.runtime.sendMessage({ action: 'bridge.status' })).data)
|
||||
if (status.state === 'connected') break
|
||||
if (attempt === 100) throw new Error(`Bridge did not connect: ${JSON.stringify(status)}`)
|
||||
await controlPage.waitForTimeout(100)
|
||||
}
|
||||
// Close the extension UI. All workflow calls below go through Go Agent tools.
|
||||
// A previously released human debugging session must not lock out the Agent.
|
||||
await extensionRequest(controlPage, 'deep.capture.start', { tabId, frameId: 0, matcher: { kind: 'request', urlPattern: '/not-triggered' } })
|
||||
await extensionRequest(controlPage, 'deep.capture.detach', { tabId, frameId: 0 })
|
||||
await controlPage.close()
|
||||
const call = (method, params = {}) => tool('browser.capability.call', { method, params: { tabId, frameId: 0, ...params } })
|
||||
const catalog = await tool('browser.capability.catalog', { domain: 'debugger' })
|
||||
assert(catalog.capabilities.some(item => item.method === 'browser.deep_capture.start'))
|
||||
const context = await call('browser.context', { includeDom: true })
|
||||
const nodes = context.document.interactive
|
||||
const username = nodes.find(node => node.name === 'username' || node.id === 'username')
|
||||
const password = nodes.find(node => node.name === 'password' || node.id === 'password')
|
||||
const submit = nodes.find(node => node.tag === 'button' && node.type === 'submit')
|
||||
assert(username && password && submit, JSON.stringify(nodes))
|
||||
for (const [node, value] of [[username, 'agent-original'], [password, 'agent-original-wrong']]) {
|
||||
await call('browser.node.action', { captureId: context.captureId, nodeId: node.nodeId, action: 'setValue', value })
|
||||
}
|
||||
const inspection = await tool('browser.crypto.inspect', { tabId, frameId: 0, captureId: context.captureId, nodeId: submit.nodeId })
|
||||
assert.equal(inspection.gatewayPreparation.state, 'capture-required')
|
||||
const plaintext = { username: 'agent-new-user', password: 'agent-new-wrong' }
|
||||
const url = new URL(targetURL)
|
||||
const request = `POST ${url.pathname} HTTP/1.1\r\nHost: ${url.host}\r\nContent-Type: application/json\r\n\r\n${JSON.stringify(plaintext)}`
|
||||
const prepareInput = { ...inspection.target, candidate_id: inspection.gatewayPreparation.candidateId, request, is_https: false }
|
||||
await assert.rejects(tool('browser.transform.prepare', { ...prepareInput, captureId: 'stale-context', nodeId: submit.nodeId }), /快照已经失效/)
|
||||
assert.equal((await call('browser.deep_capture.status')).state, 'detached', 'failed preparation must release its debugger')
|
||||
assert.equal(completed, 1)
|
||||
const prepared = await tool('browser.transform.prepare', prepareInput)
|
||||
assert(prepared.valid, JSON.stringify(prepared))
|
||||
assert.deepEqual(prepared.validationDraft.directions, { request: true, response: true })
|
||||
assert.equal(completed, 1, 'automatic preparation must not submit another real browser login')
|
||||
assert.equal(blocked, 1)
|
||||
const preparedAgain = await tool('browser.transform.prepare', prepareInput)
|
||||
assert(preparedAgain.valid)
|
||||
assert.equal(blocked, 1, 'repeated preparation must reuse existing captured functions')
|
||||
const tested = await tool('browser.http.test', { validation_id: preparedAgain.validationDraft.id, request, is_https: false })
|
||||
assert(tested.responseTransformEnabled, JSON.stringify(tested))
|
||||
assert(tested.requestTransformed, JSON.stringify(tested))
|
||||
assert(tested.responseTransformed)
|
||||
assert.deepEqual(decrypt(body(tested.wireRequest)), plaintext)
|
||||
assert.deepEqual(body(tested.plaintextResponse), decrypt(body(tested.wireResponse)))
|
||||
const draft = await call('browser.profile.validation.latest')
|
||||
const saved = await tool('browser.capability.call', { method: 'browser.transform.profile.save', params: draft.profile })
|
||||
assert(saved.request.enabled && saved.response.enabled)
|
||||
await tool('browser.capability.call', { method: 'browser.transform.profile.delete', params: { id: saved.id } })
|
||||
assert.equal(completed, 1)
|
||||
assert.equal(await targetPage.locator('#username').inputValue(), 'agent-original')
|
||||
assert.equal(await targetPage.locator('#password').inputValue(), 'agent-original-wrong')
|
||||
console.log('Agent gateway passed: signed Go bridge, advanced capability access, no plugin UI, failed-capture cleanup, automatic business capture, bidirectional validation, independently checked request/response crypto, default response decryption and Profile save/delete.')
|
||||
} finally {
|
||||
await harness?.close()
|
||||
if (connection) await fetch(`${connection.endpoint}/finish`, { method: 'POST', headers: { 'X-Test-Token': connection.token } }).catch(() => undefined)
|
||||
else engine.kill('SIGTERM')
|
||||
const code = await exited
|
||||
if (code !== 0 && connection) throw new Error(`Go integration exited ${code}: ${output.slice(-4000)}`)
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { createServer } from 'node:http'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { extensionRequest, launchBrowserAgentContractHarness, waitFor } from './browser-agent-contract-harness.mjs'
|
||||
|
||||
const registryKey = (await readFile(new URL('../src/features/page-callable/constants.ts', import.meta.url), 'utf8')).match(/= '([^']+)'/)[1]
|
||||
const received = []
|
||||
const server = createServer(async (request, response) => {
|
||||
let body = ''
|
||||
for await (const chunk of request) body += chunk
|
||||
received.push({ url: request.url, method: request.method, body })
|
||||
if (request.url === '/key' || request.url === '/bad-key') {
|
||||
response.setHeader('Content-Type', 'application/json')
|
||||
response.end(JSON.stringify(request.url === '/key' ? { key: 'fresh-server-key' } : {}))
|
||||
} else if (request.method === 'POST') {
|
||||
response.setHeader('Content-Type', 'application/json')
|
||||
response.end('{}')
|
||||
} else {
|
||||
response.setHeader('Content-Type', 'text/html')
|
||||
response.end('<form id="form"><input name="password" value="old"><button>Submit</button></form><script>globalThis.cachedFetch = fetch.bind(window); globalThis.cachedSubmit = HTMLFormElement.prototype.submit;</script>')
|
||||
}
|
||||
})
|
||||
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve))
|
||||
const targetURL = `http://127.0.0.1:${server.address().port}/`
|
||||
let harness
|
||||
try {
|
||||
harness = await launchBrowserAgentContractHarness({ profilePrefix: 'yakit-capture-regressions-', targetURL })
|
||||
const { controlPage, targetPage, tabId } = harness
|
||||
const target = { tabId, frameId: 0 }
|
||||
const request = (action, payload = {}) => extensionRequest(controlPage, action, { ...target, ...payload })
|
||||
await request('recording.start', { captureValues: true, maxEntries: 200, maxValueBytes: 8192 })
|
||||
|
||||
await targetPage.evaluate(async () => {
|
||||
await fetch(new Request(location.origin + '/request-object', { method: 'POST', body: 'encrypted-request-body' }))
|
||||
})
|
||||
const recorded = await waitFor(controlPage, 'recording.get', target, snapshot => snapshot.events.some(event =>
|
||||
event.url?.endsWith('/request-object') && event.inputs.some(value => value.path === '$body' && value.preview === 'encrypted-request-body')))
|
||||
assert(recorded.events.some(event => event.url?.endsWith('/request-object')))
|
||||
|
||||
async function register(mode, transaction = false) {
|
||||
return targetPage.evaluate(({ key, mode, transaction }) => {
|
||||
const id = crypto.randomUUID()
|
||||
const endpoint = location.origin + '/' + mode
|
||||
const savedState = { password: 'old' }
|
||||
const prerequisites = mode.startsWith('prerequisite') ? [{
|
||||
boundary: 'fetch', method: 'GET', url: location.origin + (mode === 'prerequisite-invalid' ? '/bad-key' : '/key'),
|
||||
requestBodyFormat: 'none', maxRequestBodyBytes: 0,
|
||||
response: { statusCode: 200, url: location.origin + (mode === 'prerequisite-invalid' ? '/bad-key' : '/key'), bodyFormat: 'json', maxBodyBytes: 4096, requiredPaths: ['body.key'] },
|
||||
}] : []
|
||||
if (mode === 'controlled') document.querySelector('input').addEventListener('input', event => { savedState.password = event.target.value })
|
||||
const metadata = {
|
||||
id, name: mode, kind: transaction ? 'request-transaction' : 'business-closure', operation: mode,
|
||||
origin: location.origin, lifecycle: 'document', execution: { resultMode: 'auto', timeoutMs: 1500 },
|
||||
inputSlots: [{ id: 'body', name: 'body', index: 0, role: 'data', dataType: 'object', required: true, retained: false }],
|
||||
output: { dataType: transaction ? 'object' : 'string', encoding: transaction ? 'json' : 'utf8', shape: transaction ? 'envelope' : 'value', paths: transaction ? ['body.password'] : [] },
|
||||
provenance: {}, createdAt: Date.now(),
|
||||
...(transaction ? { transaction: { version: 2, prerequisites, inputMode: 'auto', request: {
|
||||
boundary: mode === 'multipart-xhr' ? 'xhr' : mode === 'multipart-beacon' ? 'beacon' : 'fetch',
|
||||
method: 'POST', url: endpoint, bodyFormat: mode.startsWith('multipart') ? 'form' : 'json', expectedDestinations: ['body.password'],
|
||||
} } } : {}),
|
||||
}
|
||||
const registry = globalThis[key] ||= new Map()
|
||||
registry.set(id, { metadata, invoke(args) {
|
||||
if (mode === 'native-form') {
|
||||
const form = document.querySelector('form'); form.method = 'POST'; form.action = endpoint
|
||||
Reflect.apply(globalThis.cachedSubmit, form, []); return 'unexpected'
|
||||
}
|
||||
if (mode === 'cached') return globalThis.cachedFetch(endpoint, { method: 'POST', body: 'leak' }).then(() => 'bad')
|
||||
if (mode === 'timer') { setTimeout(() => fetch(endpoint, { method: 'POST', body: 'leak' }), 100); return 'ok' }
|
||||
const value = mode === 'stale' || mode === 'controlled' ? savedState : args[0]
|
||||
const send = () => {
|
||||
if (mode.startsWith('multipart')) {
|
||||
const form = new FormData(); form.set('password', value.password)
|
||||
if (mode === 'multipart-xhr') { const xhr = new XMLHttpRequest(); xhr.open('POST', endpoint); return xhr.send(form) }
|
||||
if (mode === 'multipart-beacon') return navigator.sendBeacon(endpoint, form)
|
||||
return fetch(endpoint, { method: 'POST', body: form })
|
||||
}
|
||||
return fetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(value) })
|
||||
}
|
||||
if (mode.startsWith('concurrent')) return new Promise(resolve => setTimeout(resolve, 40)).then(send)
|
||||
if (prerequisites.length) return fetch(mode === 'prerequisite-unplanned' ? '/unplanned' : prerequisites[0].url).then(response => response.json()).then(send)
|
||||
return send()
|
||||
} })
|
||||
return id
|
||||
}, { key: registryKey, mode, transaction })
|
||||
}
|
||||
const execute = (id, password = 'new') => request('callable.execute', { callableId: id, args: [{ password }] })
|
||||
await assert.rejects(execute(await register('cached')), /fetch|网络|阻止|Failed/i)
|
||||
assert.equal((await execute(await register('timer'))).value, 'ok')
|
||||
await targetPage.waitForTimeout(160)
|
||||
assert(!received.some(item => ['/cached', '/timer'].includes(item.url)))
|
||||
await assert.rejects(execute(await register('stale', true)), /新明文|旧状态/)
|
||||
for (const mode of ['controlled', 'multipart', 'multipart-xhr', 'multipart-beacon']) {
|
||||
assert.deepEqual((await execute(await register(mode, true))).value, { password: 'new' })
|
||||
assert.equal(await targetPage.locator('input').inputValue(), 'old')
|
||||
}
|
||||
const concurrent = await Promise.all([register('concurrent-a', true), register('concurrent-b', true)])
|
||||
const results = await Promise.all(concurrent.map((id, index) => execute(id, `new-${index}`)))
|
||||
assert.deepEqual(results.map(result => result.value), [{ password: 'new-0' }, { password: 'new-1' }])
|
||||
assert(!received.some(item => ['/stale', '/controlled', '/multipart', '/multipart-xhr', '/multipart-beacon', '/concurrent-a', '/concurrent-b'].includes(item.url)))
|
||||
assert.deepEqual((await execute(await register('prerequisite', true))).value, { password: 'new' })
|
||||
assert.equal(received.filter(item => item.url === '/key').length, 1)
|
||||
await assert.rejects(execute(await register('prerequisite-invalid', true)), /缺少目标字段/)
|
||||
await assert.rejects(execute(await register('prerequisite-unplanned', true)), /未授权请求/)
|
||||
assert(!received.some(item => item.url === '/unplanned' || item.method === 'POST' && item.url.startsWith('/prerequisite')))
|
||||
assert.equal((await controlPage.evaluate(() => chrome.declarativeNetRequest.getSessionRules())).length, 0)
|
||||
const nonWritableId = await register('timer')
|
||||
await targetPage.evaluate(() => {
|
||||
globalThis.beforeIsolation = { fetch, setTimeout, sendBeacon: navigator.sendBeacon }
|
||||
Object.defineProperty(navigator, 'sendBeacon', { value: navigator.sendBeacon, writable: false, configurable: true })
|
||||
})
|
||||
await assert.rejects(execute(nonWritableId), /不能隔离页面边界/)
|
||||
assert(await targetPage.evaluate(() => {
|
||||
const restored = fetch === globalThis.beforeIsolation.fetch && setTimeout === globalThis.beforeIsolation.setTimeout
|
||||
Object.defineProperty(navigator, 'sendBeacon', { value: globalThis.beforeIsolation.sendBeacon, writable: true, configurable: true })
|
||||
return restored
|
||||
}))
|
||||
assert.equal((await controlPage.evaluate(() => chrome.declarativeNetRequest.getSessionRules())).length, 0)
|
||||
|
||||
// A strict arrow listener in a one-line script must resolve by its exact function location.
|
||||
await targetPage.addScriptTag({ content: `document.querySelector('form').addEventListener('submit', e => { 'use strict'; e.preventDefault(); fetch('/strict', { method: 'POST', body: 'cipher' }) }); document.querySelector('form').addEventListener('submit', e => { 'use strict'; e.preventDefault() });` })
|
||||
await request('deep.capture.start', { matcher: { kind: 'request', urlPattern: '/strict' } })
|
||||
const submit = targetPage.locator('form button').click({ noWaitAfter: true, timeout: 20_000 })
|
||||
const paused = await waitFor(controlPage, 'deep.capture.status', target, value => value.state === 'paused' && !value.pause.collecting)
|
||||
const resolved = paused.pause.frames.filter(frame => frame.functionInspection?.resolution === 'event-listener')
|
||||
assert.equal(resolved.length, 1, JSON.stringify(paused.pause))
|
||||
assert(resolved[0].functionInspection.resolved)
|
||||
await request('deep.capture.resume')
|
||||
await submit
|
||||
const nativeFormId = await register('native-form')
|
||||
const blockedForm = targetPage.waitForEvent('requestfailed', { predicate: request => request.url().endsWith('/native-form'), timeout: 10_000 })
|
||||
void blockedForm.catch(() => undefined)
|
||||
let nativeFormError
|
||||
await assert.rejects(execute(nativeFormId), error => { nativeFormError = error.message; return true })
|
||||
const failedForm = await blockedForm.catch(error => { throw new Error(`${error.message}; execution=${nativeFormError}; url=${targetPage.url()}; received=${JSON.stringify(received)}`) })
|
||||
assert(failedForm.failure().errorText.includes('BLOCKED_BY_CLIENT'))
|
||||
assert(!received.some(item => item.url === '/native-form'))
|
||||
console.log('Capture regressions passed: Request body, native network guard, timer cleanup, fresh input, controlled forms, multipart, concurrent callables, strict same-line listeners.')
|
||||
} finally {
|
||||
await harness?.close()
|
||||
await new Promise(resolve => server.close(resolve))
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { createDecipheriv } from 'node:crypto'
|
||||
import { extensionRequest, launchBrowserAgentContractHarness, transformedFetchOptions, waitFor } from './browser-agent-contract-harness.mjs'
|
||||
|
||||
const targetURL = process.env.LOGIN_TARGET || 'http://localhost:8080/crypto/sqli/aes-ecb/encrypt/login'
|
||||
function decrypt(envelope) {
|
||||
const decipher = createDecipheriv('aes-128-cbc', Buffer.from(envelope.key, 'hex'), Buffer.from(envelope.iv, 'hex'))
|
||||
return JSON.parse(Buffer.concat([decipher.update(Buffer.from(envelope.message, 'base64')), decipher.final()]).toString())
|
||||
}
|
||||
let harness
|
||||
try {
|
||||
harness = await launchBrowserAgentContractHarness({ profilePrefix: 'yakit-login-gateway-', targetURL })
|
||||
const { targetPage, controlPage, tabId, extensionId } = harness
|
||||
const target = { tabId, frameId: 0 }
|
||||
let completedPosts = 0
|
||||
let blockedPosts = 0
|
||||
targetPage.on('response', response => { if (response.url() === targetURL && response.request().method() === 'POST') completedPosts++ })
|
||||
targetPage.on('requestfailed', request => {
|
||||
if (request.url() === targetURL && request.method() === 'POST' && request.failure()?.errorText.includes('BLOCKED_BY_CLIENT')) blockedPosts++
|
||||
})
|
||||
await extensionRequest(controlPage, 'recording.start', { ...target, captureValues: true, maxEntries: 300, maxValueBytes: 8192 })
|
||||
await targetPage.locator('#username').fill('audit-original')
|
||||
await targetPage.locator('#password').fill('original-wrong-password')
|
||||
await targetPage.locator('button[type=submit]').click()
|
||||
const recorded = await waitFor(controlPage, 'recording.get', target, snapshot =>
|
||||
snapshot.profileCandidates.some(candidate => candidate.direction === 'response' && candidate.status === 'ready'))
|
||||
await extensionRequest(controlPage, 'recording.stop', target)
|
||||
assert.equal(completedPosts, 1)
|
||||
const responseCandidate = recorded.profileCandidates.find(candidate => candidate.direction === 'response' && candidate.status === 'ready')
|
||||
const requestCandidate = recorded.profileCandidates.find(candidate => candidate.direction === 'request' && candidate.transactionId === responseCandidate.transactionId)
|
||||
assert.equal(requestCandidate.status, 'capture-required', JSON.stringify(recorded.profileCandidates))
|
||||
|
||||
await controlPage.goto(`chrome-extension://${extensionId}/options.html?tabId=${tabId}#gateway`)
|
||||
await controlPage.evaluate(() => {
|
||||
globalThis.captureActions = []
|
||||
const send = chrome.runtime.sendMessage.bind(chrome.runtime)
|
||||
chrome.runtime.sendMessage = (...args) => { globalThis.captureActions.push(args[0]); return send(...args) }
|
||||
})
|
||||
const traceIndex = recorded.traces.findIndex(trace => trace.id === responseCandidate.traceId)
|
||||
await controlPage.locator('.recording-traces button').nth(traceIndex).click()
|
||||
await controlPage.locator(`[data-event-id="${responseCandidate.source.eventId}"]`).click()
|
||||
await controlPage.getByRole('button', { name: '继续捕获请求方向', exact: true }).click()
|
||||
await waitFor(controlPage, 'deep.capture.status', target, status => status.state === 'armed')
|
||||
let submitError
|
||||
const submit = targetPage.locator('button[type=submit]').click({ noWaitAfter: true, timeout: 45_000 }).catch(error => { submitError = error })
|
||||
let profiles
|
||||
try {
|
||||
profiles = await waitFor(controlPage, 'transform.profile.list', {}, values => values.some(profile => profile.request.enabled && profile.response.enabled), 30_000)
|
||||
} catch (error) {
|
||||
const status = await extensionRequest(controlPage, 'deep.capture.status', target)
|
||||
if (status.pause) status.pause.frames = status.pause.frames.map(({ scopes, ...frame }) => frame)
|
||||
const actions = await controlPage.evaluate(() => globalThis.captureActions.filter(item => /create|start|save/.test(item.action)))
|
||||
throw new Error(`${error.message}\n${JSON.stringify(status)}\n${JSON.stringify(actions)}\n${await controlPage.locator('body').innerText()}`)
|
||||
}
|
||||
await submit
|
||||
if (submitError) throw submitError
|
||||
assert.equal(completedPosts, 1, 'the capture submission must not reach the server')
|
||||
assert.equal(blockedPosts, 1, 'the browser must confirm cancellation of the capture submission')
|
||||
const profile = profiles.find(value => value.request.enabled && value.response.enabled)
|
||||
assert.equal(profiles.length, 1, 'both directions must share one gateway')
|
||||
|
||||
for (const plaintext of [
|
||||
{ username: 'audit-new-user', password: 'new-wrong-password' },
|
||||
{ username: 'audit-second-user', password: 'second-wrong-password' },
|
||||
]) {
|
||||
const packet = { method: 'POST', url: targetURL, headers: [{ name: 'Content-Type', value: 'application/json' }], bodyBase64: Buffer.from(JSON.stringify(plaintext)).toString('base64') }
|
||||
const encrypted = await extensionRequest(controlPage, 'transform.execute', { profileId: profile.id, direction: 'request', packet })
|
||||
const envelope = JSON.parse(Buffer.from(encrypted.bodyBase64, 'base64').toString())
|
||||
assert.deepEqual(decrypt(envelope), plaintext, 'the complete new input must be encrypted')
|
||||
const response = await fetch(targetURL, transformedFetchOptions(encrypted, packet.headers))
|
||||
const wireResponse = await response.text()
|
||||
const decrypted = await extensionRequest(controlPage, 'transform.execute', {
|
||||
profileId: profile.id, direction: 'response', packet: { ...packet, bodyBase64: Buffer.from(wireResponse).toString('base64') },
|
||||
})
|
||||
assert.deepEqual(JSON.parse(Buffer.from(decrypted.bodyBase64, 'base64').toString()), decrypt(JSON.parse(wireResponse)))
|
||||
}
|
||||
assert.equal(completedPosts, 1, 'local gateway replay must not send browser requests')
|
||||
assert.equal(await targetPage.locator('#username').inputValue(), 'audit-original')
|
||||
assert.equal(await targetPage.locator('#password').inputValue(), 'original-wrong-password')
|
||||
// Also pause inside the native Fetch boundary, where changing window.fetch is too late.
|
||||
await extensionRequest(controlPage, 'deep.capture.start', {
|
||||
...target, matcher: { kind: 'request', urlPattern: targetURL, frameHints: requestCandidate.capturePlan.frameHints },
|
||||
})
|
||||
const boundaryBlocked = targetPage.waitForEvent('requestfailed', {
|
||||
predicate: request => request.url() === targetURL && request.failure()?.errorText.includes('BLOCKED_BY_CLIENT'),
|
||||
timeout: 30_000,
|
||||
})
|
||||
void boundaryBlocked.catch(() => undefined)
|
||||
const boundarySubmit = targetPage.locator('button[type=submit]').click({ noWaitAfter: true, timeout: 30_000 }).catch(error => { submitError = error })
|
||||
const paused = await waitFor(controlPage, 'deep.capture.status', target, status => status.state === 'paused' && !status.pause.collecting)
|
||||
assert.equal(paused.pause.automaticCapture.state, 'ready')
|
||||
const boundaryCallable = await extensionRequest(controlPage, 'callable.create', {
|
||||
...target, source: 'deep-capture', strategy: 'request-transaction',
|
||||
callFrameId: paused.pause.automaticCapture.frameId, candidateId: requestCandidate.id,
|
||||
})
|
||||
await boundarySubmit
|
||||
await boundaryBlocked
|
||||
if (submitError) throw submitError
|
||||
assert.equal(completedPosts, 1)
|
||||
assert.equal(blockedPosts, 2)
|
||||
await extensionRequest(controlPage, 'callable.delete', { ...target, callableId: boundaryCallable.id })
|
||||
console.log('Login gateway passed: one recording, automatic request capture, browser-confirmed cancellation, one bidirectional profile, two distinct plaintexts and real server response decryption.')
|
||||
} finally { await harness?.close() }
|
||||
Reference in New Issue
Block a user