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,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);
|
||||
}
|
||||
Reference in New Issue
Block a user