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,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)));
});
});
}