feat: add n8n serve node and harden remote access
- Add the n8n community node package with credentials, command mapping, direct serve TCP client, and browser-cli protocol crypto helpers. - Cover Ed25519 signing, canonical JSON, PQ transport encryption, request mapping, and security behavior with unit tests. - Harden serve-http with per-address rate limiting, an 8 MB request body cap, and clear warnings when binding plain HTTP beyond loopback. - Stop one-shot --key overrides from being persisted automatically; document explicit remote trust and keep key-management behind the keys policy tier. - Make HTML-to-Markdown conversion safer by bounding tree depth and dropping unsafe link/image URL schemes. - Bump package and extension release metadata to 0.16.3.
This commit is contained in:
@@ -0,0 +1,382 @@
|
||||
import type {
|
||||
ICredentialsDecrypted,
|
||||
ICredentialTestFunctions,
|
||||
IExecuteFunctions,
|
||||
IDataObject,
|
||||
IDisplayOptions,
|
||||
INodeCredentialTestResult,
|
||||
INodeExecutionData,
|
||||
INodeType,
|
||||
INodeTypeDescription,
|
||||
} from 'n8n-workflow';
|
||||
import { NodeConnectionTypes, NodeOperationError } from 'n8n-workflow';
|
||||
|
||||
import { buildCommand, type CommandParams } from './request';
|
||||
import { sendServeCommand, type ServeConnectOptions } from './serveClient';
|
||||
|
||||
/** Only show a property for the given resource/operation combinations. */
|
||||
function showFor(resource: string, operations: string[]): NonNullable<IDisplayOptions['show']> {
|
||||
return { resource: [resource], operation: operations };
|
||||
}
|
||||
|
||||
export class BrowserCli implements INodeType {
|
||||
description: INodeTypeDescription = {
|
||||
displayName: 'Browser CLI',
|
||||
name: 'browserCli',
|
||||
icon: 'file:browserCli.svg',
|
||||
group: ['transform'],
|
||||
version: 1,
|
||||
subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}',
|
||||
description: 'Control a remote browser by talking directly to a browser-cli serve endpoint',
|
||||
defaults: { name: 'Browser CLI' },
|
||||
inputs: [NodeConnectionTypes.Main],
|
||||
outputs: [NodeConnectionTypes.Main],
|
||||
usableAsTool: true,
|
||||
credentials: [{ name: 'browserCliApi', required: true }],
|
||||
properties: [
|
||||
{
|
||||
displayName: 'Resource',
|
||||
name: 'resource',
|
||||
type: 'options',
|
||||
noDataExpression: true,
|
||||
options: [
|
||||
{ name: 'Tab', value: 'tab' },
|
||||
{ name: 'Page', value: 'page' },
|
||||
{ name: 'DOM', value: 'dom' },
|
||||
{ name: 'Client', value: 'client' },
|
||||
{ name: 'Command', value: 'command' },
|
||||
{ name: 'Gateway', value: 'gateway' },
|
||||
],
|
||||
default: 'tab',
|
||||
},
|
||||
|
||||
// --- Tab operations ---------------------------------------------------
|
||||
{
|
||||
displayName: 'Operation',
|
||||
name: 'operation',
|
||||
type: 'options',
|
||||
noDataExpression: true,
|
||||
displayOptions: { show: { resource: ['tab'] } },
|
||||
options: [
|
||||
{ name: 'List', value: 'list', action: 'List open tabs', description: 'tabs.list (safe)' },
|
||||
{ name: 'Open', value: 'open', action: 'Open a URL in a new tab', description: 'navigate.open (needs --allow-control)' },
|
||||
{ name: 'Close', value: 'close', action: 'Close tabs', description: 'tabs.close (needs --allow-control)' },
|
||||
{ name: 'Get HTML', value: 'getHtml', action: 'Get a tab raw HTML', description: 'tabs.html (needs --allow-read-page)' },
|
||||
],
|
||||
default: 'list',
|
||||
},
|
||||
|
||||
// --- Page operations --------------------------------------------------
|
||||
{
|
||||
displayName: 'Operation',
|
||||
name: 'operation',
|
||||
type: 'options',
|
||||
noDataExpression: true,
|
||||
displayOptions: { show: { resource: ['page'] } },
|
||||
options: [
|
||||
{ name: 'Get Info', value: 'info', action: 'Get page info', description: 'page.info (safe)' },
|
||||
{ name: 'Extract Text', value: 'extractText', action: 'Extract visible text', description: 'extract.text (needs --allow-read-page)' },
|
||||
{ name: 'Extract Links', value: 'extractLinks', action: 'Extract links', description: 'extract.links (needs --allow-read-page)' },
|
||||
{ name: 'Extract Images', value: 'extractImages', action: 'Extract images', description: 'extract.images (needs --allow-read-page)' },
|
||||
{ name: 'Extract HTML', value: 'extractHtml', action: 'Extract HTML', description: 'extract.html (needs --allow-read-page)' },
|
||||
{ name: 'Extract Markdown', value: 'extractMarkdown', action: 'Extract Markdown payload', description: 'extract.markdown — returns the raw page payload (not SDK-rendered) (needs --allow-read-page)' },
|
||||
],
|
||||
default: 'extractText',
|
||||
},
|
||||
|
||||
// --- DOM operations ---------------------------------------------------
|
||||
{
|
||||
displayName: 'Operation',
|
||||
name: 'operation',
|
||||
type: 'options',
|
||||
noDataExpression: true,
|
||||
displayOptions: { show: { resource: ['dom'] } },
|
||||
options: [
|
||||
{ name: 'Query', value: 'query', action: 'Query elements by selector', description: 'dom.query (needs --allow-read-page)' },
|
||||
{ name: 'Click', value: 'click', action: 'Click an element', description: 'dom.click (needs --allow-control)' },
|
||||
{ name: 'Type', value: 'type', action: 'Type into an element', description: 'dom.type (needs --allow-control)' },
|
||||
{ name: 'Eval', value: 'eval', action: 'Evaluate JavaScript', description: 'dom.eval (needs --allow-dangerous)' },
|
||||
],
|
||||
default: 'query',
|
||||
},
|
||||
|
||||
// --- Client operations ------------------------------------------------
|
||||
{
|
||||
displayName: 'Operation',
|
||||
name: 'operation',
|
||||
type: 'options',
|
||||
noDataExpression: true,
|
||||
displayOptions: { show: { resource: ['client'] } },
|
||||
options: [
|
||||
{ name: 'List', value: 'list', action: 'List connected browser clients', description: 'clients.list (safe)' },
|
||||
],
|
||||
default: 'list',
|
||||
},
|
||||
|
||||
// --- Command operations -----------------------------------------------
|
||||
{
|
||||
displayName: 'Operation',
|
||||
name: 'operation',
|
||||
type: 'options',
|
||||
noDataExpression: true,
|
||||
displayOptions: { show: { resource: ['command'] } },
|
||||
options: [
|
||||
{ name: 'Execute', value: 'execute', action: 'Execute a raw browser-cli command', description: 'Any command name (subject to server policy)' },
|
||||
],
|
||||
default: 'execute',
|
||||
},
|
||||
|
||||
// --- Gateway operations -----------------------------------------------
|
||||
{
|
||||
displayName: 'Operation',
|
||||
name: 'operation',
|
||||
type: 'options',
|
||||
noDataExpression: true,
|
||||
displayOptions: { show: { resource: ['gateway'] } },
|
||||
options: [
|
||||
{ name: 'Health', value: 'health', action: 'Check serve connectivity', description: 'Pings the endpoint with tabs.list (safe)' },
|
||||
],
|
||||
default: 'health',
|
||||
},
|
||||
|
||||
// --- Shared parameter fields -----------------------------------------
|
||||
{
|
||||
displayName: 'URL',
|
||||
name: 'url',
|
||||
type: 'string',
|
||||
default: '',
|
||||
required: true,
|
||||
placeholder: 'https://example.com',
|
||||
displayOptions: { show: showFor('tab', ['open']) },
|
||||
},
|
||||
{
|
||||
displayName: 'Focus Tab',
|
||||
name: 'focus',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description: 'Whether to focus the new tab/window (steals OS focus). Off opens in the background.',
|
||||
displayOptions: { show: showFor('tab', ['open']) },
|
||||
},
|
||||
{
|
||||
displayName: 'Close By',
|
||||
name: 'mode',
|
||||
type: 'options',
|
||||
default: 'ids',
|
||||
options: [
|
||||
{ name: 'Tab IDs', value: 'ids' },
|
||||
{ name: 'Inactive Tabs', value: 'inactive' },
|
||||
{ name: 'Duplicate Tabs', value: 'duplicates' },
|
||||
],
|
||||
displayOptions: { show: showFor('tab', ['close']) },
|
||||
},
|
||||
{
|
||||
displayName: 'Tab IDs',
|
||||
name: 'tabIds',
|
||||
type: 'string',
|
||||
default: '',
|
||||
placeholder: '123, 456',
|
||||
description: 'Comma/space separated tab IDs, or a JSON array',
|
||||
displayOptions: { show: { resource: ['tab'], operation: ['close'], mode: ['ids'] } },
|
||||
},
|
||||
{
|
||||
displayName: 'Tab ID',
|
||||
name: 'tabId',
|
||||
type: 'number',
|
||||
default: 0,
|
||||
description: 'Target tab ID. Leave 0 for the active tab.',
|
||||
displayOptions: { show: { resource: ['tab'], operation: ['getHtml'] } },
|
||||
},
|
||||
{
|
||||
displayName: 'Selector',
|
||||
name: 'selector',
|
||||
type: 'string',
|
||||
default: '',
|
||||
placeholder: '#main, .content',
|
||||
description: 'CSS selector. Leave empty on extract operations to use the whole page.',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['dom', 'page'],
|
||||
operation: ['query', 'click', 'type', 'extractText', 'extractLinks', 'extractImages', 'extractHtml', 'extractMarkdown'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Text',
|
||||
name: 'text',
|
||||
type: 'string',
|
||||
default: '',
|
||||
required: true,
|
||||
displayOptions: { show: showFor('dom', ['type']) },
|
||||
},
|
||||
{
|
||||
displayName: 'JavaScript',
|
||||
name: 'code',
|
||||
type: 'string',
|
||||
typeOptions: { rows: 4 },
|
||||
default: '',
|
||||
required: true,
|
||||
placeholder: 'return document.title',
|
||||
description: 'Evaluated in the page (dom.eval). The gateway must be started with --allow-dangerous.',
|
||||
displayOptions: { show: showFor('dom', ['eval']) },
|
||||
},
|
||||
{
|
||||
displayName: 'Tab ID',
|
||||
name: 'tabId',
|
||||
type: 'number',
|
||||
default: 0,
|
||||
description: 'Target tab ID. Leave 0 for the active tab.',
|
||||
displayOptions: { show: { resource: ['dom'], operation: ['eval'] } },
|
||||
},
|
||||
{
|
||||
displayName: 'Command',
|
||||
name: 'command',
|
||||
type: 'string',
|
||||
default: '',
|
||||
required: true,
|
||||
placeholder: 'tabs.list',
|
||||
description: 'Raw browser-cli command name, e.g. tabs.list, navigate.open, extract.markdown',
|
||||
displayOptions: { show: showFor('command', ['execute']) },
|
||||
},
|
||||
{
|
||||
displayName: 'Arguments',
|
||||
name: 'args',
|
||||
type: 'json',
|
||||
default: '{}',
|
||||
description: 'JSON object of command arguments',
|
||||
displayOptions: { show: showFor('command', ['execute']) },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
methods = {
|
||||
credentialTest: {
|
||||
// Runs a real authenticated handshake so "Test" in the credential UI
|
||||
// reflects the actual serve protocol, not a stand-in HTTP request.
|
||||
async browserCliApiTest(
|
||||
this: ICredentialTestFunctions,
|
||||
credential: ICredentialsDecrypted,
|
||||
): Promise<INodeCredentialTestResult> {
|
||||
const options = connectOptionsFromCredentials((credential.data ?? {}) as IDataObject);
|
||||
options.timeoutMs = 10_000;
|
||||
try {
|
||||
const response = await sendServeCommand(options, 'clients.list', {});
|
||||
if (response && response.success === false) {
|
||||
const message = String(response.error ?? 'serve returned an error');
|
||||
// A rejected key is a real credential failure; any other server-side
|
||||
// message means the handshake + auth already succeeded.
|
||||
if (/unauthorized|untrusted|invalid signature|pubkey auth required/i.test(message)) {
|
||||
return { status: 'Error', message };
|
||||
}
|
||||
return { status: 'OK', message: `Connected and authenticated. Note: ${message.split('\n')[0]}` };
|
||||
}
|
||||
return { status: 'OK', message: 'Connected to browser-cli serve' };
|
||||
} catch (error) {
|
||||
return { status: 'Error', message: (error as Error).message };
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
|
||||
const items = this.getInputData();
|
||||
const returnData: INodeExecutionData[] = [];
|
||||
|
||||
const connectOptions = connectOptionsFromCredentials(
|
||||
(await this.getCredentials('browserCliApi')) as IDataObject,
|
||||
);
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
try {
|
||||
const resource = this.getNodeParameter('resource', i) as string;
|
||||
const operation = this.getNodeParameter('operation', i) as string;
|
||||
|
||||
const params = collectParams(this, resource, operation, i);
|
||||
const { command, args } = buildCommand(resource, operation, params);
|
||||
|
||||
const response = await sendServeCommand(connectOptions, command, args);
|
||||
if (response && response.success === false) {
|
||||
throw new Error(String(response.error || 'serve command failed'));
|
||||
}
|
||||
const data = response && typeof response === 'object' && 'data' in response ? response.data : response;
|
||||
|
||||
const rows = Array.isArray(data) ? data : [data];
|
||||
for (const row of rows) {
|
||||
returnData.push({
|
||||
json: isObject(row) ? (row as IDataObject) : { result: row },
|
||||
pairedItem: { item: i },
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
if (this.continueOnFail()) {
|
||||
returnData.push({ json: { error: (error as Error).message }, pairedItem: { item: i } });
|
||||
continue;
|
||||
}
|
||||
throw new NodeOperationError(this.getNode(), error as Error, { itemIndex: i });
|
||||
}
|
||||
}
|
||||
|
||||
return [returnData];
|
||||
}
|
||||
}
|
||||
|
||||
function isObject(value: unknown): boolean {
|
||||
return typeof value === 'object' && value !== null;
|
||||
}
|
||||
|
||||
/** Map decrypted credential fields to serve connection options. */
|
||||
function connectOptionsFromCredentials(creds: IDataObject): ServeConnectOptions {
|
||||
return {
|
||||
host: String(creds.host || '127.0.0.1'),
|
||||
port: Number(creds.port || 8765),
|
||||
tls: Boolean(creds.tls),
|
||||
rejectUnauthorized: !creds.allowUnauthorizedCerts,
|
||||
privateKeyPem: creds.privateKey ? String(creds.privateKey) : null,
|
||||
route: creds.browser ? String(creds.browser) : null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Read the UI fields relevant to this operation into a plain params object. */
|
||||
function collectParams(
|
||||
ctx: IExecuteFunctions,
|
||||
resource: string,
|
||||
operation: string,
|
||||
i: number,
|
||||
): CommandParams {
|
||||
const get = (name: string, fallback: unknown = undefined) => ctx.getNodeParameter(name, i, fallback);
|
||||
const key = `${resource}:${operation}`;
|
||||
|
||||
switch (key) {
|
||||
case 'command:execute': {
|
||||
const raw = get('args', {});
|
||||
let args: Record<string, unknown> = {};
|
||||
if (typeof raw === 'string') {
|
||||
const trimmed = raw.trim();
|
||||
args = trimmed ? JSON.parse(trimmed) : {};
|
||||
} else if (isObject(raw)) {
|
||||
args = raw as Record<string, unknown>;
|
||||
}
|
||||
return { command: get('command'), args };
|
||||
}
|
||||
case 'tab:open':
|
||||
return { url: get('url'), focus: get('focus', false) };
|
||||
case 'tab:close':
|
||||
return { mode: get('mode', 'ids'), tabIds: get('tabIds', '') };
|
||||
case 'tab:getHtml':
|
||||
return { tabId: get('tabId', 0) };
|
||||
case 'dom:query':
|
||||
case 'dom:click':
|
||||
return { selector: get('selector', '') };
|
||||
case 'dom:type':
|
||||
return { selector: get('selector', ''), text: get('text', '') };
|
||||
case 'dom:eval':
|
||||
return { code: get('code', ''), tabId: get('tabId', 0) };
|
||||
case 'page:extractText':
|
||||
case 'page:extractLinks':
|
||||
case 'page:extractImages':
|
||||
case 'page:extractHtml':
|
||||
case 'page:extractMarkdown':
|
||||
return { selector: get('selector', '') };
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" role="img" aria-labelledby="title">
|
||||
<title>browser-cli icon</title>
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="16" y1="16" x2="112" y2="112" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#0f766e" />
|
||||
<stop offset="1" stop-color="#0f172a" />
|
||||
</linearGradient>
|
||||
<linearGradient id="panel" x1="32" y1="29" x2="96" y2="99" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#f8fafc" />
|
||||
<stop offset="1" stop-color="#cbd5e1" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<!-- Chrome Web Store compliant: 96x96 artwork centered in 128x128 canvas. -->
|
||||
<rect x="16" y="16" width="96" height="96" rx="24" fill="url(#bg)" />
|
||||
<rect x="17" y="17" width="94" height="94" rx="23" fill="none" stroke="#ccfbf1" stroke-opacity="0.55" stroke-width="2" />
|
||||
|
||||
<rect x="32" y="31" width="64" height="54" rx="11" fill="url(#panel)" />
|
||||
<path d="M32 42c0-6.075 4.925-11 11-11h42c6.075 0 11 4.925 11 11v3H32z" fill="#94a3b8" />
|
||||
<circle cx="42" cy="38.5" r="2.2" fill="#f8fafc" />
|
||||
<circle cx="49" cy="38.5" r="2.2" fill="#f8fafc" opacity="0.85" />
|
||||
<circle cx="56" cy="38.5" r="2.2" fill="#f8fafc" opacity="0.7" />
|
||||
|
||||
<path d="M49 57 40 64l9 7" fill="none" stroke="#0f172a" stroke-linecap="round" stroke-linejoin="round" stroke-width="7" />
|
||||
<path d="M62 55h17" fill="none" stroke="#0f766e" stroke-linecap="round" stroke-width="7" />
|
||||
<path d="M62 67h23" fill="none" stroke="#0f766e" stroke-linecap="round" stroke-width="7" />
|
||||
|
||||
<rect x="70" y="78" width="22" height="15" rx="5" fill="#14b8a6" />
|
||||
<rect x="59" y="84" width="22" height="15" rx="5" fill="#2dd4bf" />
|
||||
<rect x="48" y="90" width="22" height="15" rx="5" fill="#99f6e4" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1,302 @@
|
||||
/**
|
||||
* Pure protocol + crypto for talking to a raw `browser-cli serve` endpoint.
|
||||
*
|
||||
* This module imports nothing from n8n and only touches Node's `crypto` plus a
|
||||
* lazily-loaded ML-KEM implementation, so it can be unit-tested with a plain
|
||||
* esbuild/node toolchain. The socket mechanics live in `serveClient.ts`.
|
||||
*
|
||||
* The wire protocol mirrors the Python client in `browser_cli/remote` and
|
||||
* `browser_cli/auth`:
|
||||
*
|
||||
* 1. Server sends a framed `challenge` JSON: {nonce, min_client_version, pq_kex?}.
|
||||
* 2. Client replies with one framed message. With a private key this is an
|
||||
* Ed25519 signature over `nonce + sha256(canonical_json(msg))`, optionally
|
||||
* bound to an ML-KEM-768 shared secret. When the server offers `pq_kex`
|
||||
* (it always does once authorized_keys is set) the request body is also
|
||||
* ChaCha20-Poly1305 encrypted under that secret.
|
||||
* 3. Server replies with one framed payload, encrypted the same way.
|
||||
*
|
||||
* Every value the client signs must serialize byte-for-byte like Python's
|
||||
* `json.dumps(sort_keys=True, separators=(",", ":"))` with `ensure_ascii=True`
|
||||
* or the signature is rejected — see `canonicalJson` and `protocol.test.ts`.
|
||||
*/
|
||||
import {
|
||||
createPrivateKey,
|
||||
createPublicKey,
|
||||
createHash,
|
||||
createHmac,
|
||||
createCipheriv,
|
||||
createDecipheriv,
|
||||
randomBytes,
|
||||
sign as nodeSign,
|
||||
type KeyObject,
|
||||
} from 'node:crypto';
|
||||
|
||||
export const PQ_KEX_ALG = 'ML-KEM-768';
|
||||
export const PQ_TRANSPORT_ALG = 'ML-KEM-768+ChaCha20Poly1305';
|
||||
|
||||
/** Auth-protocol fields that are never part of the signed canonical payload. */
|
||||
const AUTH_FIELDS = new Set(['pubkey', 'sig', 'pq_kex', 'encrypted']);
|
||||
|
||||
// --- Framing ---------------------------------------------------------------
|
||||
|
||||
/** Prefix `payload` with browser-cli's 4-byte little-endian length header. */
|
||||
export function frame(payload: Buffer): Buffer {
|
||||
const header = Buffer.allocUnsafe(4);
|
||||
header.writeUInt32LE(payload.length, 0);
|
||||
return Buffer.concat([header, payload]);
|
||||
}
|
||||
|
||||
// --- Canonical JSON (matches Python json.dumps sort_keys + ensure_ascii) ----
|
||||
|
||||
/** Deterministic JSON string identical to the Python signing canonicalization. */
|
||||
export function canonicalJson(value: unknown): string {
|
||||
return encode(value);
|
||||
}
|
||||
|
||||
function encode(value: unknown): string {
|
||||
if (value === null) return 'null';
|
||||
const type = typeof value;
|
||||
if (type === 'string') return encodeString(value as string);
|
||||
if (type === 'boolean') return value ? 'true' : 'false';
|
||||
if (type === 'number') {
|
||||
const n = value as number;
|
||||
if (!Number.isFinite(n)) throw new Error('cannot encode non-finite number');
|
||||
// Integers match Python exactly; non-integers are rare in args and fall
|
||||
// back to JS formatting (documented limitation, see protocol.test.ts).
|
||||
return Number.isInteger(n) ? String(n) : JSON.stringify(n);
|
||||
}
|
||||
if (Array.isArray(value)) return '[' + value.map(encode).join(',') + ']';
|
||||
if (type === 'object') {
|
||||
const obj = value as Record<string, unknown>;
|
||||
const keys = Object.keys(obj)
|
||||
.filter((key) => obj[key] !== undefined)
|
||||
.sort();
|
||||
return '{' + keys.map((key) => encodeString(key) + ':' + encode(obj[key])).join(',') + '}';
|
||||
}
|
||||
throw new Error(`cannot encode value of type ${type} in canonical JSON`);
|
||||
}
|
||||
|
||||
/** JSON-encode a string and escape every non-ASCII char as \uXXXX, like Python. */
|
||||
function encodeString(str: string): string {
|
||||
return JSON.stringify(str).replace(/[-]/g, (char) =>
|
||||
'\\u' + char.charCodeAt(0).toString(16).padStart(4, '0'),
|
||||
);
|
||||
}
|
||||
|
||||
// --- Ed25519 ---------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Reconstruct a clean PEM from a value mangled by a credential field:
|
||||
* surrounding whitespace, newlines escaped to literal `\n`, or — common with
|
||||
* single-line/password inputs — the internal line breaks stripped entirely so
|
||||
* the base64 body and the BEGIN/END markers run together.
|
||||
*/
|
||||
export function normalizePem(input: string): string {
|
||||
let pem = (input || '').trim().replace(/\\n/g, '\n');
|
||||
const match = pem.match(/-----BEGIN ([A-Z0-9 ]+?)-----([\s\S]*?)-----END \1-----/);
|
||||
if (match) {
|
||||
const label = match[1].trim();
|
||||
const body = (match[2].match(/[A-Za-z0-9+/=]+/g) || []).join('');
|
||||
const wrapped = body.match(/.{1,64}/g) || [];
|
||||
pem = `-----BEGIN ${label}-----\n${wrapped.join('\n')}\n-----END ${label}-----\n`;
|
||||
}
|
||||
return pem;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a PKCS8 PEM Ed25519 private key, tolerating the ways credential fields
|
||||
* mangle multi-line secrets (see {@link normalizePem}).
|
||||
*/
|
||||
export function loadPrivateKey(privatePem: string): KeyObject {
|
||||
const pem = normalizePem(privatePem);
|
||||
try {
|
||||
return createPrivateKey(pem);
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
'Invalid Ed25519 private key: expected a PKCS8 PEM block ' +
|
||||
'("-----BEGIN PRIVATE KEY-----"), e.g. the file from `browser-cli auth keygen`. ' +
|
||||
`(${(err as Error).message})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Raw 32-byte Ed25519 public key (hex) derived from a PKCS8 PEM private key. */
|
||||
export function ed25519PublicKeyHex(privatePem: string): string {
|
||||
const publicKey = createPublicKey(loadPrivateKey(privatePem));
|
||||
const jwk = publicKey.export({ format: 'jwk' }) as { x?: string };
|
||||
if (!jwk.x) throw new Error('private key is not an Ed25519 key');
|
||||
return Buffer.from(jwk.x, 'base64url').toString('hex');
|
||||
}
|
||||
|
||||
/** Bytes signed for auth: nonce + sha256(canonical) [+ sha256(label + secret)]. */
|
||||
export function authMessage(nonceHex: string, msg: Record<string, unknown>, pqSecret: Buffer | null): Buffer {
|
||||
const nonce = Buffer.from(nonceHex, 'hex');
|
||||
const canonical = createHash('sha256').update(canonicalJson(stripAuthFields(msg)), 'utf8').digest();
|
||||
let data = Buffer.concat([nonce, canonical]);
|
||||
if (pqSecret) {
|
||||
const bound = createHash('sha256')
|
||||
.update(Buffer.concat([Buffer.from('browser-cli ml-kem-768 v1', 'ascii'), pqSecret]))
|
||||
.digest();
|
||||
data = Buffer.concat([data, bound]);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
/** Ed25519 signature (hex) over the canonical auth payload. */
|
||||
export function signAuth(
|
||||
privatePem: string,
|
||||
nonceHex: string,
|
||||
msg: Record<string, unknown>,
|
||||
pqSecret: Buffer | null,
|
||||
): string {
|
||||
return nodeSign(null, authMessage(nonceHex, msg, pqSecret), loadPrivateKey(privatePem)).toString('hex');
|
||||
}
|
||||
|
||||
function stripAuthFields(msg: Record<string, unknown>): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(msg)) {
|
||||
if (!AUTH_FIELDS.has(key)) out[key] = value;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// --- ML-KEM-768 transport encryption ---------------------------------------
|
||||
|
||||
// Node has no native ML-KEM, so load @noble/post-quantum at runtime. The
|
||||
// indirection through `Function` keeps a real dynamic `import()` even after
|
||||
// TypeScript downlevels this module to CommonJS (a plain `import()` would be
|
||||
// rewritten to `require()` and fail on the ESM-only package).
|
||||
const importEsm = new Function('specifier', 'return import(specifier)') as (specifier: string) => Promise<any>;
|
||||
let mlkemPromise: Promise<any> | null = null;
|
||||
|
||||
async function mlKem768(): Promise<any> {
|
||||
if (!mlkemPromise) {
|
||||
mlkemPromise = importEsm('@noble/post-quantum/ml-kem.js').then((mod) => mod.ml_kem768);
|
||||
}
|
||||
return mlkemPromise;
|
||||
}
|
||||
|
||||
/** Encapsulate to the server's ML-KEM public key. Returns (ciphertext hex, secret). */
|
||||
export async function pqEncapsulate(serverPublicKeyHex: string): Promise<{ ciphertextHex: string; secret: Buffer }> {
|
||||
const ml = await mlKem768();
|
||||
const { cipherText, sharedSecret } = ml.encapsulate(Buffer.from(serverPublicKeyHex, 'hex'));
|
||||
return { ciphertextHex: Buffer.from(cipherText).toString('hex'), secret: Buffer.from(sharedSecret) };
|
||||
}
|
||||
|
||||
/** HKDF-SHA256 with a 32-zero-byte salt (Python `salt=None`) and the given info. */
|
||||
export function pqTransportKey(secret: Buffer, direction: string): Buffer {
|
||||
const salt = Buffer.alloc(32, 0);
|
||||
const prk = createHmac('sha256', salt).update(secret).digest();
|
||||
const info = Buffer.concat([Buffer.from(`browser-cli pq transport v1 ${direction}`, 'ascii'), Buffer.from([1])]);
|
||||
return createHmac('sha256', prk).update(info).digest().subarray(0, 32);
|
||||
}
|
||||
|
||||
export interface PqEnvelope {
|
||||
alg?: string;
|
||||
nonce: string;
|
||||
ciphertext: string;
|
||||
}
|
||||
|
||||
/** ChaCha20-Poly1305 encrypt an app-layer frame (ciphertext field is ct||tag). */
|
||||
export function pqEncrypt(secret: Buffer, direction: string, plaintext: Buffer): PqEnvelope {
|
||||
const key = pqTransportKey(secret, direction);
|
||||
const nonce = randomBytes(12);
|
||||
const cipher = createCipheriv('chacha20-poly1305', key, nonce, { authTagLength: 16 });
|
||||
const body = Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
||||
const tag = cipher.getAuthTag();
|
||||
return {
|
||||
alg: PQ_TRANSPORT_ALG,
|
||||
nonce: nonce.toString('hex'),
|
||||
ciphertext: Buffer.concat([body, tag]).toString('hex'),
|
||||
};
|
||||
}
|
||||
|
||||
/** Inverse of {@link pqEncrypt}. */
|
||||
export function pqDecrypt(secret: Buffer, direction: string, envelope: PqEnvelope): Buffer {
|
||||
if (!envelope || envelope.alg !== PQ_TRANSPORT_ALG) {
|
||||
throw new Error('unsupported encrypted transport envelope');
|
||||
}
|
||||
const key = pqTransportKey(secret, direction);
|
||||
const nonce = Buffer.from(envelope.nonce, 'hex');
|
||||
const blob = Buffer.from(envelope.ciphertext, 'hex');
|
||||
const tag = blob.subarray(blob.length - 16);
|
||||
const body = blob.subarray(0, blob.length - 16);
|
||||
const decipher = createDecipheriv('chacha20-poly1305', key, nonce, { authTagLength: 16 });
|
||||
decipher.setAuthTag(tag);
|
||||
return Buffer.concat([decipher.update(body), decipher.final()]);
|
||||
}
|
||||
|
||||
// --- Handshake payload + response decoding ---------------------------------
|
||||
|
||||
export interface Challenge {
|
||||
type?: string;
|
||||
nonce?: string;
|
||||
min_client_version?: string;
|
||||
pq_kex?: { alg?: string; public_key?: string };
|
||||
}
|
||||
|
||||
export interface AuthPayload {
|
||||
payload: Record<string, unknown>;
|
||||
pqSecret: Buffer | null;
|
||||
}
|
||||
|
||||
function pqPublicKey(challenge: Challenge): string | null {
|
||||
const kex = challenge.pq_kex;
|
||||
if (kex && kex.alg === PQ_KEX_ALG && kex.public_key) return String(kex.public_key);
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the single framed message a client sends in response to the challenge.
|
||||
* Mirrors `browser_cli.remote.auth.build_auth_message` + `signed_payload`.
|
||||
*/
|
||||
export async function buildAuthPayload(
|
||||
baseMsg: Record<string, unknown>,
|
||||
challenge: Challenge,
|
||||
privatePem: string | null,
|
||||
): Promise<AuthPayload> {
|
||||
const nonceHex = challenge.type === 'challenge' ? challenge.nonce : undefined;
|
||||
if (!nonceHex || !privatePem) {
|
||||
// No-auth endpoint (loopback `serve --no-auth`): send the bare message.
|
||||
return { payload: baseMsg, pqSecret: null };
|
||||
}
|
||||
|
||||
const clean = stripAuthFields(baseMsg);
|
||||
let secret: Buffer | null = null;
|
||||
const serverPub = pqPublicKey(challenge);
|
||||
if (serverPub) {
|
||||
const enc = await pqEncapsulate(serverPub);
|
||||
secret = enc.secret;
|
||||
clean.pq_kex = { alg: PQ_KEX_ALG, ciphertext: enc.ciphertextHex };
|
||||
}
|
||||
|
||||
const sig = signAuth(privatePem, nonceHex, clean, secret);
|
||||
const pubkey = ed25519PublicKeyHex(privatePem);
|
||||
|
||||
if (!secret) {
|
||||
return { payload: { ...clean, pubkey, sig }, pqSecret: null };
|
||||
}
|
||||
|
||||
const encrypted = pqEncrypt(secret, 'request', Buffer.from(JSON.stringify(clean), 'utf8'));
|
||||
return {
|
||||
payload: {
|
||||
id: clean.id,
|
||||
user_agent: clean.user_agent,
|
||||
pubkey,
|
||||
sig,
|
||||
pq_kex: clean.pq_kex,
|
||||
encrypted,
|
||||
},
|
||||
pqSecret: secret,
|
||||
};
|
||||
}
|
||||
|
||||
/** Decode a framed server response, decrypting the PQ envelope when present. */
|
||||
export function decodeResponse(raw: Buffer, pqSecret: Buffer | null): any {
|
||||
const outer = JSON.parse(raw.toString('utf8'));
|
||||
if (pqSecret && outer && typeof outer === 'object' && 'encrypted' in outer) {
|
||||
return JSON.parse(pqDecrypt(pqSecret, 'response', outer.encrypted).toString('utf8'));
|
||||
}
|
||||
return outer;
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* Pure (resource, operation) -> browser-cli command mapping.
|
||||
*
|
||||
* This module imports nothing from n8n so it can be unit-tested with a plain
|
||||
* esbuild/node toolchain. The node layer collects UI parameters into a plain
|
||||
* object and asks here for the command + args to run over the `serve` socket
|
||||
* (see `serveClient.ts`). Every operation maps to one raw extension command;
|
||||
* what the server returns is the *raw* command result (no SDK-side rendering),
|
||||
* still subject to the server's --allow-* policy noted per operation.
|
||||
*/
|
||||
|
||||
export type CommandParams = Record<string, unknown>;
|
||||
|
||||
export interface BrowserCommand {
|
||||
/** Raw browser-cli command name, e.g. "tabs.list" or "navigate.open". */
|
||||
command: string;
|
||||
/** Argument object forwarded to the command. */
|
||||
args: Record<string, unknown>;
|
||||
}
|
||||
|
||||
function str(params: CommandParams, key: string): string {
|
||||
const value = params[key];
|
||||
return value === undefined || value === null ? '' : String(value);
|
||||
}
|
||||
|
||||
/** Drop keys whose value is undefined/null/"" so we don't send empty args. */
|
||||
function compact(args: Record<string, unknown>): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(args)) {
|
||||
if (value !== undefined && value !== null && value !== '') out[key] = value;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a (resource, operation) pair plus collected parameters to a single raw
|
||||
* browser-cli command. Throws on an unknown pairing so the node fails loudly
|
||||
* rather than silently issuing a wrong call.
|
||||
*/
|
||||
export function buildCommand(
|
||||
resource: string,
|
||||
operation: string,
|
||||
params: CommandParams,
|
||||
): BrowserCommand {
|
||||
const key = `${resource}:${operation}`;
|
||||
switch (key) {
|
||||
// --- Raw escape hatch -------------------------------------------------
|
||||
case 'command:execute':
|
||||
return { command: str(params, 'command'), args: (params.args as Record<string, unknown>) ?? {} };
|
||||
|
||||
// --- Tabs -------------------------------------------------------------
|
||||
case 'tab:list':
|
||||
return { command: 'tabs.list', args: {} };
|
||||
case 'tab:open': {
|
||||
const focus = Boolean(params.focus);
|
||||
return { command: 'navigate.open', args: compact({ url: str(params, 'url'), focus, background: !focus }) };
|
||||
}
|
||||
case 'tab:close': {
|
||||
const mode = str(params, 'mode') || 'ids';
|
||||
if (mode === 'inactive') return { command: 'tabs.close', args: { inactive: true } };
|
||||
if (mode === 'duplicates') return { command: 'tabs.close', args: { duplicates: true } };
|
||||
return { command: 'tabs.close', args: { tabIds: parseTabIds(params.tabIds) } };
|
||||
}
|
||||
case 'tab:getHtml':
|
||||
return { command: 'tabs.html', args: compact({ tabId: tabIdArg(params.tabId) }) };
|
||||
|
||||
// --- Page / extraction ------------------------------------------------
|
||||
case 'page:info':
|
||||
return { command: 'page.info', args: {} };
|
||||
case 'page:extractText':
|
||||
return { command: 'extract.text', args: compact({ selector: str(params, 'selector') }) };
|
||||
case 'page:extractLinks':
|
||||
return { command: 'extract.links', args: compact({ selector: str(params, 'selector') }) };
|
||||
case 'page:extractImages':
|
||||
return { command: 'extract.images', args: compact({ selector: str(params, 'selector') }) };
|
||||
case 'page:extractHtml':
|
||||
return { command: 'extract.html', args: compact({ selector: str(params, 'selector') }) };
|
||||
case 'page:extractMarkdown':
|
||||
return { command: 'extract.markdown', args: compact({ selector: str(params, 'selector') }) };
|
||||
|
||||
// --- DOM --------------------------------------------------------------
|
||||
case 'dom:query':
|
||||
return { command: 'dom.query', args: { selector: str(params, 'selector') } };
|
||||
case 'dom:click':
|
||||
return { command: 'dom.click', args: { selector: str(params, 'selector') } };
|
||||
case 'dom:type':
|
||||
return { command: 'dom.type', args: { selector: str(params, 'selector'), text: str(params, 'text') } };
|
||||
case 'dom:eval':
|
||||
return { command: 'dom.eval', args: compact({ code: str(params, 'code'), tabId: tabIdArg(params.tabId) }) };
|
||||
|
||||
// --- Clients ----------------------------------------------------------
|
||||
case 'client:list':
|
||||
return { command: 'clients.list', args: {} };
|
||||
|
||||
// --- Gateway: serve has no health route, so ping with a safe command --
|
||||
case 'gateway:health':
|
||||
return { command: 'tabs.list', args: {} };
|
||||
|
||||
default:
|
||||
throw new Error(`Unsupported operation "${operation}" for resource "${resource}"`);
|
||||
}
|
||||
}
|
||||
|
||||
/** A tab ID arg; 0 (the UI default) means "active tab", so it is omitted. */
|
||||
function tabIdArg(value: unknown): number | undefined {
|
||||
if (value === undefined || value === null || value === '') return undefined;
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) && n > 0 ? n : undefined;
|
||||
}
|
||||
|
||||
/** Accept an array, a JSON array string, or a comma/space separated list. */
|
||||
export function parseTabIds(value: unknown): number[] {
|
||||
if (Array.isArray(value)) return value.map(Number).filter(Number.isFinite);
|
||||
if (typeof value === 'number') return [value];
|
||||
const raw = String(value ?? '').trim();
|
||||
if (!raw) return [];
|
||||
if (raw.startsWith('[')) {
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (Array.isArray(parsed)) return parsed.map(Number).filter(Number.isFinite);
|
||||
} catch {
|
||||
/* fall through to split */
|
||||
}
|
||||
}
|
||||
return raw
|
||||
.split(/[\s,]+/)
|
||||
.map((part) => Number(part))
|
||||
.filter(Number.isFinite);
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* Socket client for a raw `browser-cli serve` endpoint.
|
||||
*
|
||||
* One command per connection: connect, read the challenge frame, send the
|
||||
* authenticated (and PQ-encrypted) request frame, read the response frame,
|
||||
* close. The crypto and payload shapes live in `protocol.ts`.
|
||||
*/
|
||||
import { connect as netConnect, isIP } from 'node:net';
|
||||
import { connect as tlsConnect } from 'node:tls';
|
||||
import type { Socket } from 'node:net';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { buildAuthPayload, decodeResponse, frame, type Challenge } from './protocol';
|
||||
|
||||
/** Version advertised to the server. Must be >= the server's PROTOCOL_MIN_CLIENT
|
||||
* (0.9.0) and >= 0.9.5 so the server enforces the post-quantum handshake this
|
||||
* client implements. */
|
||||
const CLIENT_VERSION = '0.15.4';
|
||||
const USER_AGENT = `browser-cli/${CLIENT_VERSION}`;
|
||||
// Force a plain-JSON, uncompressed response so no msgpack/zstd decoder is needed.
|
||||
const ACCEPT_ENCODING = { ser: ['json'], comp: [] as string[] };
|
||||
const MAX_MSG_BYTES = 32 * 1024 * 1024;
|
||||
const DEFAULT_TIMEOUT_MS = 30_000;
|
||||
|
||||
export interface ServeConnectOptions {
|
||||
host: string;
|
||||
port: number;
|
||||
/** Wrap the TCP connection in TLS (for a serve behind a TLS-terminating proxy). */
|
||||
tls?: boolean;
|
||||
/** Reject self-signed / invalid certs when `tls` is on. */
|
||||
rejectUnauthorized?: boolean;
|
||||
/** Ed25519 PKCS8 PEM private key, or null/empty for a `--no-auth` endpoint. */
|
||||
privateKeyPem?: string | null;
|
||||
/** Optional `_route` target for a multi-browser serve. */
|
||||
route?: string | null;
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
/** Reassembles browser-cli's 4-byte length-prefixed frames from a socket. */
|
||||
class FrameReader {
|
||||
private buffer = Buffer.alloc(0);
|
||||
private waiters: Array<(frame: Buffer) => void> = [];
|
||||
private failed: Error | null = null;
|
||||
|
||||
constructor(socket: Socket) {
|
||||
socket.on('data', (chunk: Buffer) => this.onData(chunk));
|
||||
}
|
||||
|
||||
private onData(chunk: Buffer): void {
|
||||
this.buffer = Buffer.concat([this.buffer, chunk]);
|
||||
while (this.buffer.length >= 4) {
|
||||
const length = this.buffer.readUInt32LE(0);
|
||||
if (length > MAX_MSG_BYTES) {
|
||||
this.fail(new Error(`serve frame too large (${length} bytes)`));
|
||||
return;
|
||||
}
|
||||
if (this.buffer.length < 4 + length) break;
|
||||
const payload = this.buffer.subarray(4, 4 + length);
|
||||
this.buffer = this.buffer.subarray(4 + length);
|
||||
const waiter = this.waiters.shift();
|
||||
if (waiter) waiter(Buffer.from(payload));
|
||||
}
|
||||
}
|
||||
|
||||
fail(error: Error): void {
|
||||
this.failed = error;
|
||||
}
|
||||
|
||||
/** Resolve with the next complete frame, or reject on error/EOF/timeout. */
|
||||
next(): Promise<Buffer> {
|
||||
if (this.failed) return Promise.reject(this.failed);
|
||||
return new Promise((resolve) => this.waiters.push(resolve));
|
||||
}
|
||||
}
|
||||
|
||||
function openSocket(opts: ServeConnectOptions): Socket {
|
||||
if (opts.tls) {
|
||||
return tlsConnect({
|
||||
host: opts.host,
|
||||
port: opts.port,
|
||||
rejectUnauthorized: opts.rejectUnauthorized !== false,
|
||||
// SNI must be a hostname; Node rejects an IP literal as servername.
|
||||
...(isIP(opts.host) ? {} : { servername: opts.host }),
|
||||
});
|
||||
}
|
||||
return netConnect({ host: opts.host, port: opts.port });
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a single browser-cli command against a `serve` endpoint and return the
|
||||
* server's response object: `{id, success, data}` or `{id, success:false, error}`.
|
||||
*/
|
||||
export async function sendServeCommand(
|
||||
opts: ServeConnectOptions,
|
||||
command: string,
|
||||
args: Record<string, unknown>,
|
||||
): Promise<any> {
|
||||
const socket = openSocket(opts);
|
||||
socket.setNoDelay(true);
|
||||
const reader = new FrameReader(socket);
|
||||
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
||||
|
||||
return new Promise<any>((resolve, reject) => {
|
||||
let settled = false;
|
||||
const finish = (fn: () => void) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
socket.destroy();
|
||||
fn();
|
||||
};
|
||||
|
||||
const timer = setTimeout(
|
||||
() => finish(() => reject(new Error(`serve request to ${opts.host}:${opts.port} timed out after ${timeoutMs}ms`))),
|
||||
timeoutMs,
|
||||
);
|
||||
|
||||
socket.on('error', (err) => finish(() => reject(err)));
|
||||
socket.on('close', () => finish(() => reject(new Error('serve connection closed before a response was received'))));
|
||||
|
||||
const run = async () => {
|
||||
const challengeRaw = await reader.next();
|
||||
const challenge = JSON.parse(challengeRaw.toString('utf8')) as Challenge;
|
||||
|
||||
const baseMsg: Record<string, unknown> = {
|
||||
id: randomUUID(),
|
||||
command,
|
||||
args: args ?? {},
|
||||
user_agent: USER_AGENT,
|
||||
accept_encoding: ACCEPT_ENCODING,
|
||||
};
|
||||
if (opts.route) baseMsg._route = opts.route;
|
||||
|
||||
const { payload, pqSecret } = await buildAuthPayload(baseMsg, challenge, opts.privateKeyPem || null);
|
||||
socket.write(frame(Buffer.from(JSON.stringify(payload), 'utf8')));
|
||||
|
||||
const responseRaw = await reader.next();
|
||||
const response = decodeResponse(responseRaw, pqSecret);
|
||||
finish(() => resolve(response));
|
||||
};
|
||||
|
||||
// A TLS socket is ready only after 'secureConnect'; a plain socket on 'connect'.
|
||||
socket.once(opts.tls ? 'secureConnect' : 'connect', () => {
|
||||
run().catch((err) => finish(() => reject(err)));
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user