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
+141
View File
@@ -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 });
});