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,141 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
|
||||
import {
|
||||
authMessage,
|
||||
buildAuthPayload,
|
||||
canonicalJson,
|
||||
decodeResponse,
|
||||
ed25519PublicKeyHex,
|
||||
frame,
|
||||
pqDecrypt,
|
||||
pqEncrypt,
|
||||
pqTransportKey,
|
||||
signAuth,
|
||||
} from '../nodes/BrowserCli/protocol';
|
||||
|
||||
// Known-answer vectors produced by the real Python implementation
|
||||
// (browser_cli.auth.*) so the TypeScript port is provably byte-compatible.
|
||||
// Regenerate with the snippet in the PR description if the protocol changes.
|
||||
const PEM =
|
||||
'-----BEGIN PRIVATE KEY-----\n' +
|
||||
'MC4CAQAwBQYDK2VwBCIEIAABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4f\n' +
|
||||
'-----END PRIVATE KEY-----\n';
|
||||
const PUB_HEX = '03a107bff3ce10be1d70dd18e74bc09967e4d6309ba50d5f1ddc8664125531b8';
|
||||
const MSG = {
|
||||
id: 'abc-123',
|
||||
command: 'dom.type',
|
||||
args: { selector: '#q', text: 'héllo ☃ "x"' },
|
||||
user_agent: 'browser-cli/0.15.4',
|
||||
accept_encoding: { ser: ['json'], comp: [] },
|
||||
};
|
||||
const CANON =
|
||||
'{"accept_encoding":{"comp":[],"ser":["json"]},"args":{"selector":"#q",' +
|
||||
'"text":"h\\u00e9llo \\u2603 \\"x\\""},"command":"dom.type","id":"abc-123",' +
|
||||
'"user_agent":"browser-cli/0.15.4"}';
|
||||
const NONCE_HEX = '11'.repeat(32);
|
||||
const SIG_NOPQ =
|
||||
'13734cce77d1c861995e003041c66e555569d53df660b0584858247eee2e98fc' +
|
||||
'ad13a4cef880c0fe732f8559e990e889d343f002f2e6554d4149bb5d7e31670e';
|
||||
const SECRET_HEX = '202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f';
|
||||
const SIG_PQ =
|
||||
'e06f97ee0b628d7c84f570dc5ea07490eade9372da2ef145c9f06521b05778c8' +
|
||||
'25b9b268e15e75b7e0bbde784071852b9c6046dfee0839057f5e096b968e8f04';
|
||||
const TKEY_REQ = 'cf792b1e9b96642ef86c113b4ab5826661fb64e9f04d028c7f10f514ae6553d4';
|
||||
const TKEY_RESP = '72f3219bc809b7fbec0185f514b577cfec60d1264193da6afcc27d860f0382b7';
|
||||
const DECRYPT_ENV = {
|
||||
alg: 'ML-KEM-768+ChaCha20Poly1305',
|
||||
nonce: 'a77e8a72e13f49aab713e0dc',
|
||||
ciphertext: '7e4f75fa68098ea9a162dfd49af7824526186b77e9ac346b58f30d73df2bef88d5e6cd',
|
||||
};
|
||||
const DECRYPT_PLAIN = 'hello world payload';
|
||||
|
||||
test('canonicalJson matches Python json.dumps(sort_keys, ensure_ascii)', () => {
|
||||
assert.equal(canonicalJson(MSG), CANON);
|
||||
});
|
||||
|
||||
test('canonicalJson sorts nested keys and omits undefined', () => {
|
||||
assert.equal(canonicalJson({ b: 1, a: { d: 4, c: 3 }, z: undefined }), '{"a":{"c":3,"d":4},"b":1}');
|
||||
});
|
||||
|
||||
test('ed25519PublicKeyHex derives the raw public key from the PEM', () => {
|
||||
assert.equal(ed25519PublicKeyHex(PEM), PUB_HEX);
|
||||
});
|
||||
|
||||
test('ed25519PublicKeyHex tolerates escaped newlines and surrounding whitespace', () => {
|
||||
assert.equal(ed25519PublicKeyHex(PEM.replace(/\n/g, '\\n')), PUB_HEX);
|
||||
assert.equal(ed25519PublicKeyHex(` \n${PEM}\n `), PUB_HEX);
|
||||
});
|
||||
|
||||
test('ed25519PublicKeyHex rebuilds a PEM with its line breaks stripped', () => {
|
||||
// What a single-line/password credential field does to a multi-line PEM.
|
||||
assert.equal(ed25519PublicKeyHex(PEM.replace(/\n/g, '')), PUB_HEX);
|
||||
assert.equal(ed25519PublicKeyHex(PEM.replace(/\n/g, ' ')), PUB_HEX);
|
||||
});
|
||||
|
||||
test('loadPrivateKey throws a helpful error on a non-PEM value', () => {
|
||||
assert.throws(() => ed25519PublicKeyHex(PUB_HEX), /Invalid Ed25519 private key/);
|
||||
});
|
||||
|
||||
test('signAuth reproduces the Python signature without PQ', () => {
|
||||
assert.equal(signAuth(PEM, NONCE_HEX, MSG, null), SIG_NOPQ);
|
||||
});
|
||||
|
||||
test('signAuth reproduces the Python signature bound to a PQ secret', () => {
|
||||
assert.equal(signAuth(PEM, NONCE_HEX, MSG, Buffer.from(SECRET_HEX, 'hex')), SIG_PQ);
|
||||
});
|
||||
|
||||
test('authMessage binds nonce + payload hash (+ secret) at the expected lengths', () => {
|
||||
assert.equal(authMessage(NONCE_HEX, MSG, null).length, 64);
|
||||
assert.equal(authMessage(NONCE_HEX, MSG, Buffer.from(SECRET_HEX, 'hex')).length, 96);
|
||||
});
|
||||
|
||||
test('pqTransportKey matches Python HKDF for both directions', () => {
|
||||
const secret = Buffer.from(SECRET_HEX, 'hex');
|
||||
assert.equal(pqTransportKey(secret, 'request').toString('hex'), TKEY_REQ);
|
||||
assert.equal(pqTransportKey(secret, 'response').toString('hex'), TKEY_RESP);
|
||||
});
|
||||
|
||||
test('pqDecrypt opens a Python-produced ChaCha20-Poly1305 envelope', () => {
|
||||
const plain = pqDecrypt(Buffer.from(SECRET_HEX, 'hex'), 'response', DECRYPT_ENV);
|
||||
assert.equal(plain.toString('utf8'), DECRYPT_PLAIN);
|
||||
});
|
||||
|
||||
test('pqEncrypt/pqDecrypt round-trip', () => {
|
||||
const secret = Buffer.from(SECRET_HEX, 'hex');
|
||||
const env = pqEncrypt(secret, 'request', Buffer.from('round trip ✓', 'utf8'));
|
||||
assert.equal(env.alg, 'ML-KEM-768+ChaCha20Poly1305');
|
||||
assert.equal(pqDecrypt(secret, 'request', env).toString('utf8'), 'round trip ✓');
|
||||
});
|
||||
|
||||
test('pqDecrypt rejects an unknown envelope', () => {
|
||||
assert.throws(() => pqDecrypt(Buffer.alloc(32), 'response', { alg: 'nope', nonce: '00', ciphertext: '00' }), /unsupported/);
|
||||
});
|
||||
|
||||
test('frame prepends a 4-byte little-endian length', () => {
|
||||
const out = frame(Buffer.from('abc'));
|
||||
assert.equal(out.readUInt32LE(0), 3);
|
||||
assert.equal(out.subarray(4).toString(), 'abc');
|
||||
});
|
||||
|
||||
test('buildAuthPayload signs in the clear when no PQ kex is offered', async () => {
|
||||
const { payload, pqSecret } = await buildAuthPayload({ ...MSG }, { type: 'challenge', nonce: NONCE_HEX }, PEM);
|
||||
assert.equal(pqSecret, null);
|
||||
assert.equal((payload as any).pubkey, PUB_HEX);
|
||||
assert.equal((payload as any).sig, SIG_NOPQ);
|
||||
});
|
||||
|
||||
test('buildAuthPayload returns the bare message for a no-auth endpoint', async () => {
|
||||
const base = { ...MSG };
|
||||
const { payload, pqSecret } = await buildAuthPayload(base, { type: 'challenge', nonce: NONCE_HEX }, null);
|
||||
assert.equal(pqSecret, null);
|
||||
assert.equal(payload, base);
|
||||
});
|
||||
|
||||
test('decodeResponse parses plain JSON and decrypts PQ envelopes', () => {
|
||||
assert.deepEqual(decodeResponse(Buffer.from('{"success":true,"data":[]}'), null), { success: true, data: [] });
|
||||
const secret = Buffer.from(SECRET_HEX, 'hex');
|
||||
const env = pqEncrypt(secret, 'response', Buffer.from('{"success":true,"data":1}', 'utf8'));
|
||||
const raw = Buffer.from(JSON.stringify({ encrypted: env }));
|
||||
assert.deepEqual(decodeResponse(raw, secret), { success: true, data: 1 });
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
|
||||
import { buildCommand, parseTabIds } from '../nodes/BrowserCli/request';
|
||||
|
||||
test('tab:list maps to tabs.list', () => {
|
||||
assert.deepEqual(buildCommand('tab', 'list', {}), { command: 'tabs.list', args: {} });
|
||||
});
|
||||
|
||||
test('client:list maps to clients.list', () => {
|
||||
assert.deepEqual(buildCommand('client', 'list', {}), { command: 'clients.list', args: {} });
|
||||
});
|
||||
|
||||
test('gateway:health pings with tabs.list (serve has no health route)', () => {
|
||||
assert.deepEqual(buildCommand('gateway', 'health', {}), { command: 'tabs.list', args: {} });
|
||||
});
|
||||
|
||||
test('tab:open sends navigate.open with background derived from focus', () => {
|
||||
const bg = buildCommand('tab', 'open', { url: 'https://example.com', focus: false });
|
||||
assert.deepEqual(bg, {
|
||||
command: 'navigate.open',
|
||||
args: { url: 'https://example.com', focus: false, background: true },
|
||||
});
|
||||
|
||||
const fg = buildCommand('tab', 'open', { url: 'https://example.com', focus: true });
|
||||
assert.equal(fg.args.focus, true);
|
||||
assert.equal(fg.args.background, false, 'focused open sends background:false (matches SDK)');
|
||||
});
|
||||
|
||||
test('tab:close supports ids, inactive, duplicates', () => {
|
||||
assert.deepEqual(buildCommand('tab', 'close', { mode: 'ids', tabIds: '12, 34' }), {
|
||||
command: 'tabs.close',
|
||||
args: { tabIds: [12, 34] },
|
||||
});
|
||||
assert.deepEqual(buildCommand('tab', 'close', { mode: 'inactive' }).args, { inactive: true });
|
||||
assert.deepEqual(buildCommand('tab', 'close', { mode: 'duplicates' }).args, { duplicates: true });
|
||||
});
|
||||
|
||||
test('dom:type sends dom.type with selector + text', () => {
|
||||
assert.deepEqual(buildCommand('dom', 'type', { selector: '#q', text: 'hello' }), {
|
||||
command: 'dom.type',
|
||||
args: { selector: '#q', text: 'hello' },
|
||||
});
|
||||
});
|
||||
|
||||
test('dom:eval includes tabId only when set', () => {
|
||||
assert.deepEqual(buildCommand('dom', 'eval', { code: 'return 1', tabId: 0 }).args, { code: 'return 1' });
|
||||
assert.deepEqual(buildCommand('dom', 'eval', { code: 'return 1', tabId: 7 }).args, { code: 'return 1', tabId: 7 });
|
||||
});
|
||||
|
||||
test('page:extractMarkdown omits empty selector', () => {
|
||||
assert.deepEqual(buildCommand('page', 'extractMarkdown', { selector: '' }).args, {});
|
||||
assert.deepEqual(buildCommand('page', 'extractMarkdown', { selector: 'main' }).args, { selector: 'main' });
|
||||
});
|
||||
|
||||
test('command:execute passes command and args through', () => {
|
||||
assert.deepEqual(buildCommand('command', 'execute', { command: 'tabs.query', args: { search: 'docs' } }), {
|
||||
command: 'tabs.query',
|
||||
args: { search: 'docs' },
|
||||
});
|
||||
});
|
||||
|
||||
test('unknown operation throws', () => {
|
||||
assert.throws(() => buildCommand('tab', 'nope', {}), /Unsupported operation/);
|
||||
});
|
||||
|
||||
test('parseTabIds accepts array, json, and delimited strings', () => {
|
||||
assert.deepEqual(parseTabIds([1, 2, 3]), [1, 2, 3]);
|
||||
assert.deepEqual(parseTabIds('[4, 5]'), [4, 5]);
|
||||
assert.deepEqual(parseTabIds('6, 7 8'), [6, 7, 8]);
|
||||
assert.deepEqual(parseTabIds(''), []);
|
||||
assert.deepEqual(parseTabIds(9), [9]);
|
||||
});
|
||||
Reference in New Issue
Block a user