Files
browser-cli/n8n-nodes-browser-cli/nodes/BrowserCli/BrowserCli.node.ts
T
daniel156161 cea8a7e994 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.
2026-06-19 10:00:23 +02:00

383 lines
14 KiB
TypeScript

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 {};
}
}