From 161b51bbd49c6741889da8d7fb5aceff86db2cfb Mon Sep 17 00:00:00 2001 From: Steve Larson <9larsons@gmail.com> Date: Tue, 5 May 2026 17:45:40 -0500 Subject: [PATCH 1/4] Added ip-address override (#27688) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit no ref A newly-published moderate XSS advisory landed against `ip-address`'s `Address6` HTML-emitting methods (the helpers that produce HTML representations of IPv6 addresses). Affected versions are `<=10.1.0`; fixed upstream in `10.2.0`. In our tree it reaches as `sqlite3 > node-gyp > make-fetch-happen > socks-proxy-agent > socks > ip-address`, so it's a build-time chain (sqlite3 native compilation) rather than runtime — the practical risk surface is limited. The override is still worth taking because the fix is a same-major patch and the override is mechanical. --- package.json | 1 + pnpm-lock.yaml | 9 +++++---- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index 78d8d387b85..aefb7e1b909 100644 --- a/package.json +++ b/package.json @@ -90,6 +90,7 @@ "form-data@<2.5.4": "^2.5.4", "growl@<1.10.0": "^1.10.0", "handlebars@>=4.0.0 <=4.7.8": "^4.7.9", + "ip-address@<=10.1.0": "^10.2.0", "js-yaml@>=4.0.0 <4.1.1": "^4.1.1", "json5@<1.0.2": "^1.0.2", "lodash@<4.18.0": "^4.18.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 734723602ba..0a82b89fe6a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -51,6 +51,7 @@ overrides: form-data@<2.5.4: ^2.5.4 growl@<1.10.0: ^1.10.0 handlebars@>=4.0.0 <=4.7.8: ^4.7.9 + ip-address@<=10.1.0: ^10.2.0 js-yaml@>=4.0.0 <4.1.1: ^4.1.1 json5@<1.0.2: ^1.0.2 lodash@<4.18.0: ^4.18.0 @@ -15110,8 +15111,8 @@ packages: resolution: {integrity: sha512-tVrCrc4LWJwX82GD79dZ0teZQGq+5KJEGpXJRgzHOrhHtLgF9ME6rTwDV5+HN5bjnvmtrnS8ioXhflY16sy2HQ==} engines: {node: '>=6'} - ip-address@10.1.0: - resolution: {integrity: sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==} + ip-address@10.2.0: + resolution: {integrity: sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==} engines: {node: '>= 12'} ip-regex@4.3.0: @@ -39287,7 +39288,7 @@ snapshots: transitivePeerDependencies: - supports-color - ip-address@10.1.0: {} + ip-address@10.2.0: {} ip-regex@4.3.0: {} @@ -45481,7 +45482,7 @@ snapshots: socks@2.8.7: dependencies: - ip-address: 10.1.0 + ip-address: 10.2.0 smart-buffer: 4.2.0 sonner@2.0.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1): From 029582221bfb4fb23a6e9bdb341e0aaa7c231729 Mon Sep 17 00:00:00 2001 From: Steve Larson <9larsons@gmail.com> Date: Tue, 5 May 2026 18:53:38 -0500 Subject: [PATCH 2/4] Fixed flaky OTC magic-link assertion matching token digits (#27689) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit no ref `assertNoOTCInEmailContent` in `send-magic-link.test.js` uses `/\d{6}/` to verify no one-time code appears in the email, but `mail.text` and the rendered HTML text both contain the magic-link URL — and the URL's random token can incidentally contain six consecutive digits, false-positiving the assertion. The fix strips `http(s)://...` URLs from the scanned text before applying the regex. Subject is left alone since URLs don't appear there. --- .../e2e-api/members/send-magic-link.test.js | 20 ++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/ghost/core/test/e2e-api/members/send-magic-link.test.js b/ghost/core/test/e2e-api/members/send-magic-link.test.js index afc67dc573a..ece3f1d5763 100644 --- a/ghost/core/test/e2e-api/members/send-magic-link.test.js +++ b/ghost/core/test/e2e-api/members/send-magic-link.test.js @@ -768,14 +768,24 @@ describe('sendMagicLink', function () { function assertNoOTCInEmailContent(mail) { const otcRegex = /\d{6}|\scode\s|\sotc\s/i; + // \d{6} would otherwise match digits inside the magic-link token. + const stripUrls = string => string.replace(/https?:\/\/\S+/g, ' '); + const subjectMatch = mail.subject.match(otcRegex); assert(!subjectMatch, `Email subject should not contain OTC. Found: "${subjectMatch?.[0]}" in subject: "${mail.subject}"`); - const textMatch = mail.text.match(otcRegex); - assert(!textMatch, `Email text should not contain OTC. Found: "${textMatch?.[0]}" near: "${mail.text.substring(mail.text.search(otcRegex) - 50, mail.text.search(otcRegex) + 100)}"`); - - // It's possible that there's an OTC-like in an href, so only check the rendered text. - const htmlText = cheerio.load(mail.html).text(); + const text = stripUrls(mail.text); + const textMatch = text.match(otcRegex); + assert(!textMatch, `Email text should not contain OTC. Found: "${textMatch?.[0]}" near: "${text.substring(text.search(otcRegex) - 50, text.search(otcRegex) + 100)}"`); + + // Per text node — cheerio's .text() concatenates without + // separators, so URLs could otherwise merge with adjacent digits. + const $ = cheerio.load(mail.html); + const htmlText = $('*').contents() + .toArray() + .filter(n => n.type === 'text') + .map(n => stripUrls(n.data)) + .join(' '); const htmlMatch = htmlText.match(otcRegex); assert(!htmlMatch, `Email HTML should not contain OTC. Found: "${htmlMatch?.[0]}" near: "${htmlText.substring(htmlText.search(otcRegex) - 50, htmlText.search(otcRegex) + 100)}"`); } From d128c64a41bdb02992bf4ae9473497226073099e Mon Sep 17 00:00:00 2001 From: Steve Larson <9larsons@gmail.com> Date: Tue, 5 May 2026 18:55:41 -0500 Subject: [PATCH 3/4] Fixed flaky timeout-threshold tests racing real setTimeout (#27690) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit no ref The `{{#get}}` and `{{#recommendations}}` "timeout threshold" tests asserted that the helper's `setTimeout(threshold=1ms)` fires before the API stub's `setTimeout(5ms)`. That's a Node scheduler race, not a behavior assertion. Switch each test to `sinon.useFakeTimers({toFake: ['setTimeout', 'clearTimeout']})`, kick off the helper, then `clock.tickAsync(2)` — fires the 1ms threshold timer but not the 5ms stub timer, so the timeout branch wins deterministically. --- .../test/unit/frontend/helpers/get.test.js | 40 +++++++++++-------- .../frontend/helpers/recommendations.test.js | 16 +++++++- 2 files changed, 38 insertions(+), 18 deletions(-) diff --git a/ghost/core/test/unit/frontend/helpers/get.test.js b/ghost/core/test/unit/frontend/helpers/get.test.js index cd4ed36bec5..9124d4085f4 100644 --- a/ghost/core/test/unit/frontend/helpers/get.test.js +++ b/ghost/core/test/unit/frontend/helpers/get.test.js @@ -551,23 +551,29 @@ describe('{{#get}} helper', function () { }); it('should log an error and return safely if it hits the timeout threshold', async function () { - configUtils.set('optimization:getHelper:timeout:threshold', 1); - - const result = await get.call( - {}, - 'posts', - {hash: {}, data: locals, fn: fn, inverse: inverse} - ); - - assert(result.toString().includes('data-aborted-get-helper')); - // A log message will be output - sinon.assert.calledOnce(logging.error); - // The get helper gets called with an empty array of results - sinon.assert.calledOnce(fn); - const args = fn.firstCall.args[0]; - assert(args && typeof args === 'object'); - assert('posts' in args); - assert.deepEqual(args.posts, []); + const clock = sinon.useFakeTimers({toFake: ['setTimeout', 'clearTimeout']}); + try { + configUtils.set('optimization:getHelper:timeout:threshold', 1); + + const resultPromise = get.call( + {}, + 'posts', + {hash: {}, data: locals, fn: fn, inverse: inverse} + ); + // 2 > threshold (1), < stub's 5 — fires only the helper's timer. + await clock.tickAsync(2); + const result = await resultPromise; + + assert(result.toString().includes('data-aborted-get-helper')); + sinon.assert.calledOnce(logging.error); + sinon.assert.calledOnce(fn); + const args = fn.firstCall.args[0]; + assert(args && typeof args === 'object'); + assert('posts' in args); + assert.deepEqual(args.posts, []); + } finally { + clock.restore(); + } }); }); diff --git a/ghost/core/test/unit/frontend/helpers/recommendations.test.js b/ghost/core/test/unit/frontend/helpers/recommendations.test.js index 7245f13b1d0..f333b1b7994 100644 --- a/ghost/core/test/unit/frontend/helpers/recommendations.test.js +++ b/ghost/core/test/unit/frontend/helpers/recommendations.test.js @@ -145,6 +145,8 @@ describe('{{#recommendations}} helper', function () { }); describe('when timeout is exceeded', function () { + let clock; + before(function () { sinon.stub(api, 'recommendationsPublic').get(() => { return { @@ -158,6 +160,15 @@ describe('{{#recommendations}} helper', function () { }; }); }); + + beforeEach(function () { + clock = sinon.useFakeTimers({toFake: ['setTimeout', 'clearTimeout']}); + }); + + afterEach(function () { + clock.restore(); + }); + after(async function () { await configUtils.restore(); }); @@ -165,9 +176,12 @@ describe('{{#recommendations}} helper', function () { it('should log an error and return safely if it hits the timeout threshold', async function () { configUtils.set('optimization:getHelper:timeout:threshold', 1); - const response = await recommendations.call( + const responsePromise = recommendations.call( 'recommendations' ); + // 2 > threshold (1), < stub's 5 — fires only the helper's timer. + await clock.tickAsync(2); + const response = await responsePromise; // An error message is logged sinon.assert.calledOnce(logging.error); From dc2485e44b8318fb88ab4cd10730290c982c67d2 Mon Sep 17 00:00:00 2001 From: Steve Larson <9larsons@gmail.com> Date: Tue, 5 May 2026 19:59:32 -0500 Subject: [PATCH 4/4] Pinned vitest pool to forks in createVitestConfig (#27691) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit no ref This is a small adjustment for (what is currently) the posts, stats, and admin-x-settings. We've observed some occasional flakiness that seems to be from crossing boundaries, and moving to forks is going to be a slight perf hit but given this is what are otherwise-speedy unit tests with a limited app scope, it seems fine for now. Switch the shared `createVitestConfig` from Vitest's default `threads` pool to `forks`. Forks runs each worker as a separate child process, giving stronger isolation than threads (which run in separate V8 isolates but still share the Node process — and therefore process-scoped state). --- apps/admin-x-framework/src/test/vitest-config.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/admin-x-framework/src/test/vitest-config.ts b/apps/admin-x-framework/src/test/vitest-config.ts index c39e5295a4c..84ab02c0ac5 100644 --- a/apps/admin-x-framework/src/test/vitest-config.ts +++ b/apps/admin-x-framework/src/test/vitest-config.ts @@ -42,6 +42,10 @@ export function createVitestConfig(options: VitestConfigOptions = {}) { test: { globals: true, environment: 'jsdom', + // pool: 'forks' for process-level isolation in jsdom-heavy + // React suites; sidesteps Vitest threads-pool edge cases. + pool: 'forks', + isolate: true, setupFiles, include, silent,