Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,16 +12,19 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
node: [22, 24, 26]
# First release per line with the eMudhra blocklist (nodejs/node#66871);
# keep in step with engines.node (test/node-versions.test.ts).
node: ['22.23.4', '24.22.1', '26.12.1']
steps:
- uses: actions/checkout@v4

- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
check-latest: true
cache: npm

- name: TLS probe (eMudhra chain rejected, trusted host accepted)
run: node scripts/tls-probe.mjs
- run: npm ci
- run: npm run lint
- run: npm test
Expand Down
5 changes: 3 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,11 @@ jobs:

- uses: actions/setup-node@v4
with:
node-version: 24
check-latest: true
node-version: '24.22.1'
cache: npm

- name: TLS probe (eMudhra chain rejected, trusted host accepted)
run: node scripts/tls-probe.mjs
- run: npm ci
- run: npm test
- run: npm run build
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ npm install @last9/otel-cron @opentelemetry/sdk-node @opentelemetry/exporter-tra

Peer dependency: `@opentelemetry/api ^1.9.0`.

Requires Node.js 22 or later (a supported release line). The OTLP/HTTPS exporters validate TLS with your process's Node.js, so keep it on the latest patch release of its line.
Requires Node.js v22.23.4+, v24.22.1+ or v26.12.1+. The OTLP/HTTPS exporters validate TLS with your process's Node.js, and these are the first releases on each line that reject certificate chains through the compromised eMudhra intermediates ([nodejs/node#66871](https://github.com/nodejs/node/pull/66871)). Keep your runtime on the latest patch release of its line.

## Configuration

Expand Down
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
"author": "Last9 Inc.",
"license": "MIT",
"engines": {
"node": ">=22"
"node": "^22.23.4 || ^24.22.1 || >=26.12.1"
},
"publishConfig": {
"access": "public",
Expand Down
48 changes: 48 additions & 0 deletions scripts/tls-probe.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
// Checks that this Node.js rejects certificate chains through the compromised
// eMudhra intermediates (nodejs/node#66871) while still trusting a normal host.
// Uses default TLS verification; exits non-zero if either expectation fails.
import https from 'node:https';

const BLOCKED = 'https://dv-g3b-emudhra.we-have-a-problem.com/';
const TRUSTED = 'https://nodejs.org/';
// Overall per-request deadline, so a host that accepts the connection and then
// goes silent fails the probe instead of holding the CI/release job open.
const DEADLINE_MS = 15_000;

function get(url) {
return new Promise((resolve) => {
const req = https
.get(url, (res) => {
Comment thread
coder9io marked this conversation as resolved.
clearTimeout(deadline);
res.resume();
resolve({ status: res.statusCode });
})
.on('error', (err) => {
clearTimeout(deadline);
resolve({ code: err.code, message: err.message });
});
const deadline = setTimeout(() => {
req.destroy(
Object.assign(new Error(`no response within ${DEADLINE_MS} ms`), { code: 'ETIMEDOUT' }),
);
}, DEADLINE_MS);
});
}

console.log(`process.version ${process.version}`);

const blocked = await get(BLOCKED);
const trusted = await get(TRUSTED);
console.log(BLOCKED, JSON.stringify(blocked));
console.log(TRUSTED, JSON.stringify(trusted));

let failed = false;
if (blocked.code !== 'CERT_REVOKED') {
console.error(`expected ${BLOCKED} to fail with CERT_REVOKED`);
failed = true;
}
if (trusted.status === undefined) {
console.error(`expected ${TRUSTED} to connect`);
failed = true;
}
process.exit(failed ? 1 : 0);
39 changes: 39 additions & 0 deletions test/node-versions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { readFileSync } from 'fs';
import { join } from 'path';

// First release on each supported line that rejects the compromised eMudhra
// intermediates (SPKI blocklist, nodejs/node#66871). Odd lines 23/25 are
// end-of-life and never got the fix, so they stay excluded.
const PATCHED_NODE = ['22.23.4', '24.22.1', '26.12.1'];
const ENGINES = '^22.23.4 || ^24.22.1 || >=26.12.1';

const root = join(__dirname, '..');
const read = (file: string) => readFileSync(join(root, file), 'utf8');

describe('supported Node.js versions', () => {
it('floors engines.node at the patched release of each line', () => {
expect(JSON.parse(read('package.json')).engines.node).toBe(ENGINES);
expect(ENGINES.match(/\d+\.\d+\.\d+/g)).toEqual(PATCHED_NODE);
});

it('keeps package-lock.json in step with package.json', () => {
expect(JSON.parse(read('package-lock.json')).packages[''].engines.node).toBe(ENGINES);
});

it('documents every patched floor in the README', () => {
const readme = read('README.md');
for (const version of PATCHED_NODE) {
expect(readme).toContain(`v${version}`);
}
});

it('tests every patched floor in CI', () => {
const matrix = read('.github/workflows/ci.yml').match(/^\s*node: \[(.*)\]$/m);
expect(matrix?.[1].split(/,\s*/)).toEqual(PATCHED_NODE.map((v) => `'${v}'`));
});

it('releases on a Node version CI tests', () => {
const versions = read('.github/workflows/release.yml').match(/node-version: (.*)$/gm);
expect(versions).toEqual([`node-version: '${PATCHED_NODE[1]}'`]);
});
});
66 changes: 66 additions & 0 deletions test/tls-probe.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { spawnSync } from 'child_process';
import { join } from 'path';

type Outcome = number | string | 'stall';

const PROBE = join(__dirname, '..', 'scripts', 'tls-probe.mjs');

// Runs the real probe with https.get replaced: each request in turn answers
// with a status code, fails with an error code, or ('stall') never settles
// while holding the event loop open like a live socket. Timers are capped at
// 20 ms so the probe's own deadline fires well inside the 2 s watchdog.
function runProbe(blocked: Outcome, trusted: Outcome) {
const preload = `
import https from 'node:https';
import { EventEmitter } from 'node:events';
const realSetTimeout = globalThis.setTimeout;
globalThis.setTimeout = (fn, ms, ...args) => realSetTimeout(fn, Math.min(ms, 20), ...args);
const outcomes = ${JSON.stringify([blocked, trusted])};
https.get = (_url, options, callback) => {
if (typeof options === 'function') callback = options;
const outcome = outcomes.shift();
const req = new EventEmitter();
const liveSocket = setInterval(() => {}, 1000);
req.destroy = (error) => {
clearInterval(liveSocket);
queueMicrotask(() => req.emit('error', error ?? new Error('aborted')));
return req;
};
queueMicrotask(() => {
if (outcome === 'stall') return;
clearInterval(liveSocket);
if (typeof outcome === 'number') callback({ statusCode: outcome, resume() {} });
else req.emit('error', Object.assign(new Error(outcome), { code: outcome }));
});
return req;
};
`;
return spawnSync(
process.execPath,
['--import', 'data:text/javascript,' + encodeURIComponent(preload), PROBE],
{ encoding: 'utf8', timeout: 2000, killSignal: 'SIGKILL' },
);
}

describe('scripts/tls-probe.mjs', () => {
it.each<[string, Outcome, Outcome, number]>([
['passes when the eMudhra host is revoked and the trusted host answers', 'CERT_REVOKED', 307, 0],
['fails on a Node that still trusts the eMudhra chain', 200, 200, 1],
['fails when the eMudhra host fails for another reason', 'ENOTFOUND', 200, 1],
['fails when the trusted host does not connect', 'CERT_REVOKED', 'CERT_HAS_EXPIRED', 1],
])('%s', (_name, blocked, trusted, status) => {
const result = runProbe(blocked, trusted);
expect(result.error).toBeUndefined();
expect(result.status).toBe(status);
});

it.each<[string, Outcome, Outcome]>([
['eMudhra host', 'stall', 200],
['trusted host', 'CERT_REVOKED', 'stall'],
])('fails within its deadline when the %s stalls', (_name, blocked, trusted) => {
const result = runProbe(blocked, trusted);
expect(result.error).toBeUndefined();
expect(result.status).toBe(1);
expect(result.stdout).toContain('"code":"ETIMEDOUT"');
});
});
Loading