cea8a7e994
- 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.
383 lines
14 KiB
TypeScript
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 {};
|
|
}
|
|
}
|