From 9c4a613b448acd059eac7ee2bbf86a9351f2ae01 Mon Sep 17 00:00:00 2001 From: Tomi Turtiainen <10324676+tomi@users.noreply.github.com> Date: Fri, 7 Aug 2026 11:02:40 +0300 Subject: [PATCH] fix(core): Fix task runner health check failing (#35808) --- .../task-runner/src/health-check-server.ts | 20 +++ .../__tests__/prototype-hardening.test.ts | 128 ++++++++++++++++++ 2 files changed, 148 insertions(+) create mode 100644 packages/@n8n/task-runner/src/js-task-runner/__tests__/prototype-hardening.test.ts diff --git a/packages/@n8n/task-runner/src/health-check-server.ts b/packages/@n8n/task-runner/src/health-check-server.ts index 93413229c5f..67221602aec 100644 --- a/packages/@n8n/task-runner/src/health-check-server.ts +++ b/packages/@n8n/task-runner/src/health-check-server.ts @@ -1,12 +1,32 @@ import { OperationalError } from 'n8n-workflow'; import { createServer } from 'node:http'; +/** Methods that Node's HTTP listener replaces on every inbound socket. */ +const METHODS = ['prependListener', 'prependOnceListener'] as const; + export class HealthCheckServer { private server = createServer((_, res) => { res.writeHead(200); res.end('OK'); }); + constructor() { + // Run before Node's HTTP connection listener so its assignments use writable + // properties on this socket instead of the locked EventEmitter prototype. + this.server.prependListener('connection', (socket) => { + for (const method of METHODS) { + // Keep the exception local to health-check sockets rather than weakening + // the shared stream prototypes for sandboxed code. + Object.defineProperty(socket, method, { + value: socket[method], + writable: true, + configurable: true, + enumerable: true, + }); + } + }); + } + async start(host: string, port: number) { return await new Promise((resolve, reject) => { const portInUseErrorHandler = (error: NodeJS.ErrnoException) => { diff --git a/packages/@n8n/task-runner/src/js-task-runner/__tests__/prototype-hardening.test.ts b/packages/@n8n/task-runner/src/js-task-runner/__tests__/prototype-hardening.test.ts new file mode 100644 index 00000000000..f0cb6dab197 --- /dev/null +++ b/packages/@n8n/task-runner/src/js-task-runner/__tests__/prototype-hardening.test.ts @@ -0,0 +1,128 @@ +import { execFile } from 'node:child_process'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { promisify } from 'node:util'; + +const execFileAsync = promisify(execFile); +const CHILD_PROCESS_TIMEOUT_MS = 20_000; +const TEST_TIMEOUT_MS = 25_000; + +const HARDENING_MODULE = path.resolve(__dirname, '../prototype-hardening.ts'); +const HEALTH_CHECK_MODULE = path.resolve(__dirname, '../../health-check-server.ts'); + +/** + * Run a script in a throwaway process, because `freezeGlobals` mutates + * process-wide prototypes and would wreck the Vitest worker. Node's type + * stripping loads the hardening module straight from source. + */ +const runInChildProcess = async (body: string, harden: boolean) => { + const script = ` + import { EventEmitter } from 'node:events'; + import { registerHooks } from 'node:module'; + import { Stream } from 'node:stream'; + import { Socket } from 'node:net'; + import { get } from 'node:http'; + ${harden ? `import { freezeGlobals } from ${JSON.stringify(HARDENING_MODULE)};\nfreezeGlobals();` : ''} + + const evil = function evil() { return 'pwned'; }; + + ${body} + `; + + const dir = await mkdtemp(path.join(tmpdir(), 'n8n-hardening-')); + const scriptPath = path.join(dir, 'hardening-probe.mts'); + try { + await writeFile(scriptPath, script); + + const { stdout } = await execFileAsync(process.execPath, ['--no-warnings', scriptPath], { + timeout: CHILD_PROCESS_TIMEOUT_MS, + }); + + return stdout; + } finally { + await rm(dir, { recursive: true, force: true }); + } +}; + +/** Serve a single request through the runner's real health check server. */ +const SERVE_ONE_REQUEST = ` + registerHooks({ + resolve(specifier, context, nextResolve) { + if (specifier === 'n8n-workflow') { + return { + shortCircuit: true, + url: 'data:text/javascript,export class OperationalError extends Error {}', + }; + } + return nextResolve(specifier, context); + }, + }); + const { HealthCheckServer } = await import(${JSON.stringify(HEALTH_CHECK_MODULE)}); + const server = new HealthCheckServer(); + await server.start('127.0.0.1', 0); + const address = server.server.address(); + const status = await new Promise((resolve, reject) => { + get('http://127.0.0.1:' + address.port + '/healthz', (res) => { + res.resume(); + res.on('end', () => resolve(res.statusCode)); + }).on('error', reject); + }); + console.log('status:' + status); + await server.stop(); +`; + +/** + * Each probe throws when the write is refused outright and returns `false` + * when it is silently dropped; either way the prototype stayed intact. + */ +const PROBES = ` + const probe = (label, attempt) => { + try { + console.log(label + ':' + (attempt() ? 'allowed' : 'blocked')); + } catch { + console.log(label + ':blocked'); + } + }; + + probe('ee-emit', () => { EventEmitter.prototype.emit = evil; return EventEmitter.prototype.emit === evil; }); + probe('ee-prepend', () => { EventEmitter.prototype.prependListener = evil; return EventEmitter.prototype.prependListener === evil; }); + probe('stream-prepend', () => { Stream.prototype.prependListener = evil; return Stream.prototype.prependListener === evil; }); + probe('socket-prepend', () => { Socket.prototype.prependListener = evil; return Socket.prototype.prependListener === evil; }); + probe('ee-emit-delete', () => { delete EventEmitter.prototype.emit; return EventEmitter.prototype.emit !== evil && !Object.hasOwn(EventEmitter.prototype, 'emit'); }); + probe('process-emit', () => { + const original = process.emit; + process.emit = evil; + const shadowed = process.emit === evil; + if (shadowed) Object.defineProperty(process, 'emit', { value: original, writable: true, configurable: true, enumerable: true }); + return shadowed; + }); +`; + +describe('freezeGlobals', { timeout: TEST_TIMEOUT_MS }, () => { + it('serves an HTTP request in a process that has not been hardened', async () => { + const stdout = await runInChildProcess(SERVE_ONE_REQUEST, false); + + expect(stdout).toContain('status:200'); + }); + + // CAT-3995: Node's HTTP server assigns `prependListener` on every inbound + // socket, which used to hit the locked `EventEmitter.prototype` property and + // crash the runner on every health check probe. + it('serves an HTTP request in a hardened process', async () => { + const stdout = await runInChildProcess(SERVE_ONE_REQUEST, true); + + expect(stdout).toContain('status:200'); + }); + + it('keeps the shared prototypes sealed against sandboxed code', async () => { + const stdout = await runInChildProcess(PROBES, true); + + expect(stdout).toContain('ee-emit:blocked'); + expect(stdout).toContain('ee-prepend:blocked'); + expect(stdout).toContain('ee-emit-delete:blocked'); + expect(stdout).toContain('process-emit:blocked'); + expect(stdout).toContain('stream-prepend:blocked'); + expect(stdout).toContain('socket-prepend:blocked'); + }); +});