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:
2026-06-19 10:00:23 +02:00
parent 7fe0e27fec
commit cea8a7e994
28 changed files with 3687 additions and 164 deletions
@@ -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);
}