From fdd8b0fc125fd41747ae8e28022b1f9cbae07f85 Mon Sep 17 00:00:00 2001 From: Andrew Foote Date: Wed, 23 Sep 2026 13:27:30 -0400 Subject: [PATCH 1/7] fix: preserve escaped CPE components in candidate patterns --- controllers/searchController.test.js | 2 +- utils/cpe.js | 3 ++- utils/cpe.test.js | 10 +++++----- utils/searchQueryBuilder.test.js | 29 +++++++++++++++++++++++++++- 4 files changed, 36 insertions(+), 8 deletions(-) diff --git a/controllers/searchController.test.js b/controllers/searchController.test.js index ae242dd..774f69f 100644 --- a/controllers/searchController.test.js +++ b/controllers/searchController.test.js @@ -1113,7 +1113,7 @@ describe('Search controller SEARCH handler', () => { { regexp: { 'containers.cna.cpeApplicability.nodes.cpeMatch.criteria.keyword': { - value: 'cpe:2\\.3:[^:]*:[^:]*:[^:]*:[^:]*:[^:]*:[^:]*:[^:]*:[^:]*:[^:]*:[^:]*:[^:]*', + value: 'cpe:2\\.3:([^:\\\\]|\\\\.)*:([^:\\\\]|\\\\.)*:([^:\\\\]|\\\\.)*:([^:\\\\]|\\\\.)*:([^:\\\\]|\\\\.)*:([^:\\\\]|\\\\.)*:([^:\\\\]|\\\\.)*:([^:\\\\]|\\\\.)*:([^:\\\\]|\\\\.)*:([^:\\\\]|\\\\.)*:([^:\\\\]|\\\\.)*', case_insensitive: true } } diff --git a/utils/cpe.js b/utils/cpe.js index ca814b9..c02f841 100644 --- a/utils/cpe.js +++ b/utils/cpe.js @@ -218,7 +218,8 @@ function escapeLuceneRegexpLiteral(input) { } function compatibleComponentRegexp(userComponent) { - if (userComponent === '*') return '[^:]*'; + // Escaped colons belong to the component, not the surrounding CPE delimiters. + if (userComponent === '*') return '([^:\\\\]|\\\\.)*'; return `(${escapeLuceneRegexpLiteral(userComponent)}|\\*)`; } diff --git a/utils/cpe.test.js b/utils/cpe.test.js index 311e12c..fbe98c5 100644 --- a/utils/cpe.test.js +++ b/utils/cpe.test.js @@ -163,7 +163,7 @@ describe('CPE helpers', () => { const parsed = cpe.parseCpeName('cpe:2.3:a:artifex:ghostscript:10.05.1:*:*:*:*:*:*:*'); expect(cpe.buildCpeCriteriaRegexp(parsed)).toBe( - 'cpe:2\\.3:(a|\\*):(artifex|\\*):(ghostscript|\\*):10\\.05\\.1:[^:]*:[^:]*:[^:]*:[^:]*:[^:]*:[^:]*:[^:]*' + 'cpe:2\\.3:(a|\\*):(artifex|\\*):(ghostscript|\\*):10\\.05\\.1:([^:\\\\]|\\\\.)*:([^:\\\\]|\\\\.)*:([^:\\\\]|\\\\.)*:([^:\\\\]|\\\\.)*:([^:\\\\]|\\\\.)*:([^:\\\\]|\\\\.)*:([^:\\\\]|\\\\.)*' ); }); @@ -171,7 +171,7 @@ describe('CPE helpers', () => { const parsed = cpe.parseCpeName('cpe:2.3:a:artifex:ghostscript:10.05.1:*:*:*:*:*:*:*'); expect(cpe.buildCpeCriteriaRegexp(parsed, { versionMode: 'wildcard' })).toBe( - 'cpe:2\\.3:(a|\\*):(artifex|\\*):(ghostscript|\\*):\\*:[^:]*:[^:]*:[^:]*:[^:]*:[^:]*:[^:]*:[^:]*' + 'cpe:2\\.3:(a|\\*):(artifex|\\*):(ghostscript|\\*):\\*:([^:\\\\]|\\\\.)*:([^:\\\\]|\\\\.)*:([^:\\\\]|\\\\.)*:([^:\\\\]|\\\\.)*:([^:\\\\]|\\\\.)*:([^:\\\\]|\\\\.)*:([^:\\\\]|\\\\.)*' ); }); @@ -179,7 +179,7 @@ describe('CPE helpers', () => { const parsed = cpe.parseCpeName('cpe:2.3:a:vendor:product(name):1.0+build:*:*:*:*:*:*:*'); expect(cpe.buildCpeCriteriaRegexp(parsed)).toBe( - 'cpe:2\\.3:(a|\\*):(vendor|\\*):(product\\(name\\)|\\*):1\\.0\\+build:[^:]*:[^:]*:[^:]*:[^:]*:[^:]*:[^:]*:[^:]*' + 'cpe:2\\.3:(a|\\*):(vendor|\\*):(product\\(name\\)|\\*):1\\.0\\+build:([^:\\\\]|\\\\.)*:([^:\\\\]|\\\\.)*:([^:\\\\]|\\\\.)*:([^:\\\\]|\\\\.)*:([^:\\\\]|\\\\.)*:([^:\\\\]|\\\\.)*:([^:\\\\]|\\\\.)*' ); }); @@ -187,7 +187,7 @@ describe('CPE helpers', () => { const parsed = cpe.parseVirtualMatchString('cpe:2.3:o:microsoft:windows_10_1809'); expect(cpe.buildVirtualMatchStringCriteriaRegexp(parsed)).toBe( - 'cpe:2\\.3:(o|\\*):(microsoft|\\*):(windows_10_1809|\\*):[^:]*:[^:]*:[^:]*:[^:]*:[^:]*:[^:]*:[^:]*:[^:]*' + 'cpe:2\\.3:(o|\\*):(microsoft|\\*):(windows_10_1809|\\*):([^:\\\\]|\\\\.)*:([^:\\\\]|\\\\.)*:([^:\\\\]|\\\\.)*:([^:\\\\]|\\\\.)*:([^:\\\\]|\\\\.)*:([^:\\\\]|\\\\.)*:([^:\\\\]|\\\\.)*:([^:\\\\]|\\\\.)*' ); }); @@ -195,7 +195,7 @@ describe('CPE helpers', () => { const parsed = cpe.parseVirtualMatchString('cpe:2.3:o:microsoft:windows_10_1809:*:*:*:*:*:*:x86:*'); expect(cpe.buildVirtualMatchStringCriteriaRegexp(parsed)).toBe( - 'cpe:2\\.3:(o|\\*):(microsoft|\\*):(windows_10_1809|\\*):[^:]*:[^:]*:[^:]*:[^:]*:[^:]*:[^:]*:(x86|\\*):[^:]*' + 'cpe:2\\.3:(o|\\*):(microsoft|\\*):(windows_10_1809|\\*):([^:\\\\]|\\\\.)*:([^:\\\\]|\\\\.)*:([^:\\\\]|\\\\.)*:([^:\\\\]|\\\\.)*:([^:\\\\]|\\\\.)*:([^:\\\\]|\\\\.)*:(x86|\\*):([^:\\\\]|\\\\.)*' ); }); diff --git a/utils/searchQueryBuilder.test.js b/utils/searchQueryBuilder.test.js index 335aae7..fd628fc 100644 --- a/utils/searchQueryBuilder.test.js +++ b/utils/searchQueryBuilder.test.js @@ -151,7 +151,7 @@ describe('searchQueryBuilder buildSearchRequest()', () => { { regexp: { 'containers.cna.cpeApplicability.nodes.cpeMatch.criteria.keyword': { - value: 'cpe:2\\.3:(o|\\*):(microsoft|\\*):(windows_10_1809|\\*):[^:]*:[^:]*:[^:]*:[^:]*:[^:]*:[^:]*:(x86|\\*):[^:]*', + value: 'cpe:2\\.3:(o|\\*):(microsoft|\\*):(windows_10_1809|\\*):([^:\\\\]|\\\\.)*:([^:\\\\]|\\\\.)*:([^:\\\\]|\\\\.)*:([^:\\\\]|\\\\.)*:([^:\\\\]|\\\\.)*:([^:\\\\]|\\\\.)*:(x86|\\*):([^:\\\\]|\\\\.)*', case_insensitive: true } } @@ -160,6 +160,33 @@ describe('searchQueryBuilder buildSearchRequest()', () => { expect(output.queryStringObjects).toStrictEqual([]); }); + test.each([ + ['product\\:edition', true], + ['product\\\\edition', true], + ['product\\\\\\:edition', true], + ['product:edition', false] + ])('wildcard CPE candidate patterns preserve component boundaries for %s', (product, expected) => { + const output = queryBuilder.buildSearchRequest({ virtualMatchString: 'cpe:2.3:a' }); + const clause = output.searchOptions.filters[0].regexp; + const pattern = clause['containers.cna.cpeApplicability.nodes.cpeMatch.criteria.keyword'].value; + const criteria = 'cpe:2.3:a:vendor:' + product + ':1.0:*:*:*:*:*:*:*'; + + // This pattern uses only syntax shared by JavaScript and Lucene regular expressions. + expect(new RegExp('^' + pattern + '$').test(criteria)).toBe(expected); + }); + + test('CPE name candidate patterns allow escaped colons in wildcard optional components', () => { + const output = queryBuilder.buildSearchRequest({ + cpeName: 'cpe:2.3:a:vendor:product:1.0:*:*:*:*:android:*:*' + }); + const clause = output.searchOptions.filters[0].bool.should[0].regexp; + const pattern = clause['containers.cna.cpeApplicability.nodes.cpeMatch.criteria.keyword'].value; + + expect( + new RegExp('^' + pattern + '$').test('cpe:2.3:a:vendor:product:1.0:*:edition\\:name:*:*:android:*:*') + ).toBe(true); + }); + test('buildSearchRequest over-fetches virtualMatchString version range candidates for post-filtering', () => { const output = queryBuilder.buildSearchRequest({ virtualMatchString: 'cpe:2.3:o:linux:linux_kernel', From ba3ce4512905809c3feef86665f9f20d298deef0 Mon Sep 17 00:00:00 2001 From: Andrew Foote Date: Wed, 23 Sep 2026 13:29:12 -0400 Subject: [PATCH 2/7] fix: reject empty stored CPE version intervals --- README.md | 2 +- utils/cpe.js | 7 ++++++- utils/cpe.test.js | 27 +++++++++++++++++++++++++++ 3 files changed, 34 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 7a9bf8d..4efccfd 100644 --- a/README.md +++ b/README.md @@ -226,7 +226,7 @@ curl -X POST "http://localhost:3000/search" \ -d '{"virtualMatchString":"cpe:2.3:o:linux:linux_kernel","versionStart":"2.2","versionStartType":"including","versionEnd":"2.6","versionEndType":"excluding"}' ``` -Version ranges use overlap semantics. A stored wildcard-version CPE Match Criteria entry such as `cpe:2.3:o:linux:linux_kernel:*:*:*:*:*:*:*:*` with `versionEndExcluding=6.9` matches requested ranges that overlap versions before `6.9`, and does not match a requested range that starts at `6.9`. +Version ranges use overlap semantics. A stored wildcard-version CPE Match Criteria entry such as `cpe:2.3:o:linux:linux_kernel:*:*:*:*:*:*:*:*` with `versionEndExcluding=6.9` matches requested ranges that overlap versions before `6.9`, and does not match a requested range that starts at `6.9`. Stored intervals with a start after their end, or equal boundaries where either boundary is exclusive, are empty and do not match range searches. CPE version comparisons used by both `cpeName` applicability and `virtualMatchString` version ranges use Maven-style qualifier precedence: `alpha`/`a` < `beta`/`b` < `milestone`/`m` < `rc`/`cr` < `snapshot` < an unqualified release/`ga`/`final`/`release` < `sp` < unknown qualifiers. Unknown qualifiers, including `hf`, sort after known qualifiers in case-insensitive lexical order. This means a range beginning at `1.0.0-rc1` includes `1.0.0`, while a range ending before `1.0.0` can include prerelease versions. diff --git a/utils/cpe.js b/utils/cpe.js index c02f841..26548a4 100644 --- a/utils/cpe.js +++ b/utils/cpe.js @@ -462,7 +462,12 @@ function lowerBoundDoesNotExceedUpperBound(lower, upper) { } function versionRangesOverlap(a, b) { - return lowerBoundDoesNotExceedUpperBound(a.start, b.end) && lowerBoundDoesNotExceedUpperBound(b.start, a.end); + return ( + lowerBoundDoesNotExceedUpperBound(a.start, a.end) && + lowerBoundDoesNotExceedUpperBound(b.start, b.end) && + lowerBoundDoesNotExceedUpperBound(a.start, b.end) && + lowerBoundDoesNotExceedUpperBound(b.start, a.end) + ); } function rangeBoundaryMatches(assetVersion, cpeMatch) { diff --git a/utils/cpe.test.js b/utils/cpe.test.js index fbe98c5..17e668c 100644 --- a/utils/cpe.test.js +++ b/utils/cpe.test.js @@ -596,6 +596,33 @@ describe('CPE helpers', () => { ).toBe(false); }); + test.each([ + [{ versionStartIncluding: '3', versionEndIncluding: '2' }, false], + [{ versionStartExcluding: '3', versionEndExcluding: '2' }, false], + [{ versionStartIncluding: '2', versionEndExcluding: '2' }, false], + [{ versionStartExcluding: '2', versionEndIncluding: '2' }, false], + [{ versionStartExcluding: '2', versionEndExcluding: '2' }, false], + [{ versionStartIncluding: '2', versionEndIncluding: '2' }, true], + [{ versionStartIncluding: '2' }, true], + [{ versionEndExcluding: '2' }, true] + ])('validates stored intervals before overlap: %j', (bounds, expected) => { + expect( + cpe.cpeMatchOverlapsVirtualMatchStringRange( + { + criteria: 'cpe:2.3:a:vendor:product:*:*:*:*:*:*:*:*', + ...bounds + }, + { + matchString: cpe.parseVirtualMatchString('cpe:2.3:a:vendor:product'), + versionStart: '1', + versionStartType: 'including', + versionEnd: '4', + versionEndType: 'including' + } + ) + ).toBe(expected); + }); + test('cpeMatchOverlapsVirtualMatchStringRange applies qualifier precedence to range overlap', () => { const matchString = cpe.parseVirtualMatchString('cpe:2.3:a:vendor:product'); From b9f48fc35f07e7559dcc5800a35c572c48a79f64 Mon Sep 17 00:00:00 2001 From: Andrew Foote Date: Wed, 23 Sep 2026 13:53:38 -0400 Subject: [PATCH 3/7] fix: enforce deadlines during semantic filtering --- README.md | 2 + controllers/searchController.js | 4 +- controllers/searchController.test.js | 27 ++++++++++++++ utils/searchDeadline.js | 9 +++++ utils/searchDeadline.test.js | 24 ++++++++++++ utils/searchPostFilters.js | 18 ++++++++- utils/searchPostFilters.test.js | 55 ++++++++++++++++++++-------- 7 files changed, 121 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 4efccfd..2e1390b 100644 --- a/README.md +++ b/README.md @@ -296,6 +296,8 @@ Access logs omit query strings and request bodies. By default, the API retains 1 Each `/search` request owns a lifecycle cancellation signal. When its overall deadline expires, the API aborts the active OpenSearch transport request before returning HTTP 504. When the caller disconnects, the API aborts work that is needed only by that caller and does not attempt to write a response. This prevents timed-out or abandoned standard searches from continuing until the transport timeout. +Deadline checks also compare elapsed monotonic time, so they do not depend on the timeout callback running first. Semantic filtering checks cancellation between candidates and after the final candidate, and yields to the event loop every 100 candidates. An expired search cannot start its page fetch or return a successful empty result merely because filtering delayed the timer. Cancellation is cooperative; it does not interrupt an individual synchronous operation in progress. + ### Semantic Post-Filtering Two separate 10,000-record controls apply to `/search`: diff --git a/controllers/searchController.js b/controllers/searchController.js index 2149a56..a8eec9d 100644 --- a/controllers/searchController.js +++ b/controllers/searchController.js @@ -123,7 +123,9 @@ async function buildPostFilterCandidateResult(candidateBody, postFilters, signal const candidateResp = { data: candidateData }; - const { candidateCount, filteredHits } = searchPostFilters.filterSearchHits(candidateResp, postFilters); + const { candidateCount, filteredHits } = await searchPostFilters.filterSearchHits(candidateResp, postFilters, { + signal + }); return { candidateCount, diff --git a/controllers/searchController.test.js b/controllers/searchController.test.js index 774f69f..8319ec4 100644 --- a/controllers/searchController.test.js +++ b/controllers/searchController.test.js @@ -31,6 +31,7 @@ jest.mock('../utils/searchProvider', () => ({ const controller = require('./searchController.js'); const constants = require('../utils/constants'); const searchPostFilters = require('../utils/searchPostFilters'); +const cpe = require('../utils/cpe'); function createResponse() { const res = {}; @@ -1234,6 +1235,32 @@ describe('Search controller SEARCH handler', () => { }); }); + test.each([true, false])( + 'SEARCH rejects elapsed deadlines after synchronous filtering with match=%s', + async (matches) => { + captureTimers(); + const clock = jest.spyOn(process.hrtime, 'bigint').mockReturnValue(0n); + mockGetSearchRequestDeadlineMs.mockReturnValue(10); + mockSearchRaw.mockResolvedValue( + rawResponse(1, [ + cpeHit('CVE-2024-0001', { + criteria: 'cpe:2.3:a:vendor:product:1.0:*:*:*:*:*:*:*' + }) + ]) + ); + jest.spyOn(cpe, 'hitAppliesToCpeName').mockImplementation(() => { + clock.mockReturnValue(10000000n); + return matches; + }); + + await controller.SEARCH({ searchParams: { cpeName: 'cpe:2.3:a:vendor:product:1.0' } }, res, next); + + expect(res.status).toHaveBeenCalledWith(504); + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ error: 'SEARCH_TIMEOUT' })); + expect(mockApiSearch).not.toHaveBeenCalled(); + } + ); + test('SEARCH stops without writing a response when the client disconnects', async () => { const req = new EventEmitter(); req.requestId = 'client-disconnect-request'; diff --git a/utils/searchDeadline.js b/utils/searchDeadline.js index ecb33c4..df7e4ce 100644 --- a/utils/searchDeadline.js +++ b/utils/searchDeadline.js @@ -1,3 +1,5 @@ +const requestDeadlines = new WeakMap(); + class SearchDeadlineExceededError extends Error { constructor() { super('Search request deadline exceeded.'); @@ -32,6 +34,10 @@ function getAbortReason(signal) { } function throwIfAborted(signal) { + const deadline = requestDeadlines.get(signal); + if (deadline && process.hrtime.bigint() >= deadline.expiresAt) { + deadline.abort(new SearchDeadlineExceededError()); + } if (signal?.aborted) throw getAbortReason(signal); } @@ -44,6 +50,7 @@ function addOnceListener(emitter, eventName, listener) { function createSearchRequestLifecycle(req, res, timeoutMs) { const controller = new AbortController(); + const expiresAt = process.hrtime.bigint() + BigInt(timeoutMs) * 1000000n; let completed = false; const abort = (reason) => { @@ -51,6 +58,7 @@ function createSearchRequestLifecycle(req, res, timeoutMs) { controller.abort(reason); } }; + requestDeadlines.set(controller.signal, { expiresAt, abort }); const removeRequestAbortedListener = addOnceListener(req, 'aborted', () => abort(new SearchClientDisconnectedError()) ); @@ -66,6 +74,7 @@ function createSearchRequestLifecycle(req, res, timeoutMs) { if (completed) return; completed = true; + requestDeadlines.delete(controller.signal); clearTimeout(timeoutId); removeRequestAbortedListener(); removeResponseCloseListener(); diff --git a/utils/searchDeadline.test.js b/utils/searchDeadline.test.js index cafbf3c..43d3d6e 100644 --- a/utils/searchDeadline.test.js +++ b/utils/searchDeadline.test.js @@ -62,6 +62,30 @@ describe('searchDeadline', () => { expect(clearTimeout).toHaveBeenCalledTimes(1); }); + test('checks elapsed monotonic time even when the timeout callback has not run', () => { + captureTimers(); + const clock = jest.spyOn(process.hrtime, 'bigint').mockReturnValue(0n); + const { lifecycle } = createLifecycle(10); + + clock.mockReturnValue(9999999n); + expect(() => lifecycle.throwIfAborted()).not.toThrow(); + clock.mockReturnValue(10000000n); + expect(() => searchDeadline.throwIfAborted(lifecycle.signal)).toThrow( + searchDeadline.SearchDeadlineExceededError + ); + lifecycle.complete(); + }); + + test('completion removes the elapsed-time deadline as well as the timer', () => { + captureTimers(); + const clock = jest.spyOn(process.hrtime, 'bigint').mockReturnValue(0n); + const { lifecycle } = createLifecycle(10); + + lifecycle.complete(); + clock.mockReturnValue(10000000n); + expect(() => lifecycle.throwIfAborted()).not.toThrow(); + }); + test('aborts when the request emits aborted', () => { captureTimers(); const { lifecycle, req } = createLifecycle(); diff --git a/utils/searchPostFilters.js b/utils/searchPostFilters.js index 5cca1a0..1454484 100644 --- a/utils/searchPostFilters.js +++ b/utils/searchPostFilters.js @@ -3,6 +3,10 @@ const cvss = require('./cvss'); const searchScope = require('./searchScope'); const ssvc = require('./ssvc'); const kev = require('./kev'); +const searchDeadline = require('./searchDeadline'); +const { setImmediate: yieldToEventLoop } = require('timers/promises'); + +const FILTER_BATCH_SIZE = 100; const CPE_SOURCE_FIELDS = Object.freeze([ 'containers.cna.cpeApplicability.nodes.cpeMatch.criteria', @@ -52,7 +56,8 @@ function hitMatches(hit, postFilters, cpeEvaluationContext) { return true; } -function filterSearchHits(resp, postFilters) { +async function filterSearchHits(resp, postFilters, { signal } = {}) { + searchDeadline.throwIfAborted(signal); const hitsContainer = resp?.data?.hits; const cpeEvaluationContext = cpe.createEvaluationContext(); @@ -64,10 +69,19 @@ function filterSearchHits(resp, postFilters) { }; } + const filteredHits = []; + for (let index = 0; index < hitsContainer.hits.length; index++) { + if (index > 0 && index % FILTER_BATCH_SIZE === 0) await yieldToEventLoop(); + searchDeadline.throwIfAborted(signal); + const hit = hitsContainer.hits[index]; + if (hitMatches(hit, postFilters, cpeEvaluationContext)) filteredHits.push(hit); + } + searchDeadline.throwIfAborted(signal); + return { hitsContainer, candidateCount: hitsContainer.hits.length, - filteredHits: hitsContainer.hits.filter((hit) => hitMatches(hit, postFilters, cpeEvaluationContext)) + filteredHits }; } diff --git a/utils/searchPostFilters.test.js b/utils/searchPostFilters.test.js index be3329c..16ae220 100644 --- a/utils/searchPostFilters.test.js +++ b/utils/searchPostFilters.test.js @@ -1,4 +1,5 @@ const searchPostFilters = require('./searchPostFilters'); +const searchDeadline = require('./searchDeadline'); function response(hits) { return { @@ -26,12 +27,12 @@ function cpeHit(id, cpeMatch) { }; } -function filteredHits(hits, postFilters) { - return searchPostFilters.filterSearchHits(response(hits), postFilters).filteredHits; +async function filteredHits(hits, postFilters) { + return (await searchPostFilters.filterSearchHits(response(hits), postFilters)).filteredHits; } describe('searchPostFilters CPE semantics', () => { - test('does not combine platform and version matches from different CPE entries', () => { + test('does not combine platform and version matches from different CPE entries', async () => { const candidate = cpeHit('CVE-2026-0250', [ { criteria: 'cpe:2.3:a:palo_alto_networks:globalprotect_app:*:*:macos:*:*:*:*:*', @@ -54,14 +55,14 @@ describe('searchPostFilters CPE semantics', () => { ]); expect( - filteredHits([candidate], { + await filteredHits([candidate], { cpeName: 'cpe:2.3:a:palo_alto_networks:globalprotect_app:6.1.1:*:macos:*:*:*:*:*', isVulnerable: true }) ).toStrictEqual([]); }); - test('matches CPE string literals without lexical case', () => { + test('matches CPE string literals without lexical case', async () => { const candidate = cpeHit('CVE-2026-0251', { criteria: 'cpe:2.3:a:palo_alto_networks:globalprotect_app:*:*:*:*:*:macOS:*:*', vulnerable: true, @@ -70,14 +71,14 @@ describe('searchPostFilters CPE semantics', () => { }); expect( - filteredHits([candidate], { + await filteredHits([candidate], { cpeName: 'cpe:2.3:a:palo_alto_networks:globalprotect_app:6.0.1:*:*:*:*:macos:*:*', isVulnerable: true }) ).toStrictEqual([candidate]); }); - test('preserves exact version padding while normalizing numeric range segments', () => { + test('preserves exact version padding while normalizing numeric range segments', async () => { const exactCandidate = cpeHit('CVE-2025-9392', { criteria: 'cpe:2.3:o:linksys:re6250_firmware:1.0.04.001:*:*:*:*:*:*:*', vulnerable: true @@ -89,26 +90,26 @@ describe('searchPostFilters CPE semantics', () => { }); expect( - filteredHits([exactCandidate], { + await filteredHits([exactCandidate], { cpeName: 'cpe:2.3:o:linksys:re6250_firmware:1.0.4.1:*:*:*:*:*:*:*', isVulnerable: true }) ).toStrictEqual([]); expect( - filteredHits([rangeCandidate], { + await filteredHits([rangeCandidate], { cpeName: 'cpe:2.3:o:buffalo:wsr3600be4-kh_firmware:6.1:*:*:*:*:*:*:*', isVulnerable: true }) ).toStrictEqual([rangeCandidate]); expect( - filteredHits([rangeCandidate], { + await filteredHits([rangeCandidate], { cpeName: 'cpe:2.3:o:buffalo:wsr3600be4-kh_firmware:6.2:*:*:*:*:*:*:*', isVulnerable: true }) ).toStrictEqual([]); }); - test('preserves CPE NA matches across numeric version ranges', () => { + test('preserves CPE NA matches across numeric version ranges', async () => { const criteria = 'cpe:2.3:a:microsoft:edge:*:*:*:*:*:android:*:*'; const candidates = [ cpeHit('CVE-2020-17153', { @@ -131,10 +132,34 @@ describe('searchPostFilters CPE semantics', () => { ]; expect( - filteredHits(candidates, { - cpeName: 'cpe:2.3:a:microsoft:edge:-:*:*:*:*:android:*:*', - isVulnerable: true - }).map((hit) => hit._id) + ( + await filteredHits(candidates, { + cpeName: 'cpe:2.3:a:microsoft:edge:-:*:*:*:*:android:*:*', + isVulnerable: true + }) + ).map((hit) => hit._id) ).toStrictEqual(['CVE-2020-17153', 'CVE-2024-38208', 'CVE-2026-35429']); }); + + test('preserves candidate order across filtering batches', async () => { + const hits = Array.from({ length: 201 }, (_, index) => ({ _id: String(index) })); + const result = await searchPostFilters.filterSearchHits(response(hits), {}); + expect(result.candidateCount).toBe(hits.length); + expect(result.filteredHits).toStrictEqual(hits); + }); + + test('yields between batches so a client disconnect can cancel remaining work', async () => { + const controller = new AbortController(); + const reason = new searchDeadline.SearchClientDisconnectedError(); + const abort = setImmediate(() => controller.abort(reason)); + const hits = Array.from({ length: 201 }, (_, index) => ({ _id: String(index) })); + + try { + await expect( + searchPostFilters.filterSearchHits(response(hits), {}, { signal: controller.signal }) + ).rejects.toBe(reason); + } finally { + clearImmediate(abort); + } + }); }); From f58dba5c26d227b9dfbdea15485566170a3082eb Mon Sep 17 00:00:00 2001 From: Andrew Foote Date: Wed, 23 Sep 2026 13:54:42 -0400 Subject: [PATCH 4/7] fix: reject conflicting field types during readiness checks --- README.md | 2 ++ utils/searchProvider.test.js | 14 ++++++++++++++ utils/searchReadiness.js | 16 ++++++++++------ utils/searchReadiness.test.js | 34 ++++++++++++++++++++++++++++++++++ 4 files changed, 60 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 2e1390b..7e9a070 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,8 @@ The Search API will be served at `localhost:3000`. An AWS load balancer can use `/api/ready-check` to stop routing search traffic to an unready task while the container orchestrator continues using `/api/health-check` for process liveness. Coordinate that probe selection with the infrastructure team before changing deployed health-check behavior. +When the configured index is an alias spanning multiple indices, every reported mapped type of each required field must be compatible and searchable. CVE ID sorting also requires aggregation support. One compatible type cannot conceal a conflicting type in another index; readiness returns HTTP 503 rather than advertising that configuration as ready. + ## API Input Validation `POST /search` accepts an optional JSON object request body and validates supported fields, boolean parameters, pagination values, CVSS severity values, required parameter combinations, CPE Name requirements, and date-time formats. Enum-valued fields are accepted case-insensitively and normalized to their documented canonical values. A non-empty body must use the `application/json` media type with UTF-8 encoding; unsupported media types or encodings return HTTP 415, and malformed JSON returns HTTP 400. If OpenSearch is unavailable, the endpoint returns HTTP 503 with a `Retry-After` header. diff --git a/utils/searchProvider.test.js b/utils/searchProvider.test.js index dcaada4..243396a 100644 --- a/utils/searchProvider.test.js +++ b/utils/searchProvider.test.js @@ -234,6 +234,20 @@ describe('searchProvider', () => { expect(mockClientSearch).not.toHaveBeenCalled(); }); + test('fails readiness before its search probe when an alias reports conflicting field types', async () => { + const response = fieldCapsResponse(); + response.body.fields['cveMetadata.cveId.keyword'].text = { + searchable: true, + aggregatable: false + }; + mockClientFieldCaps.mockResolvedValue(response); + + await expect(searchProvider.checkOpenSearchReadiness()).rejects.toBeInstanceOf( + searchReadiness.SearchMappingContractError + ); + expect(mockClientSearch).not.toHaveBeenCalled(); + }); + test('aborts the readiness field-capabilities request when its lifecycle is canceled', async () => { const pending = deferred(); const request = pending.promise; diff --git a/utils/searchReadiness.js b/utils/searchReadiness.js index 8442075..d65e456 100644 --- a/utils/searchReadiness.js +++ b/utils/searchReadiness.js @@ -120,12 +120,16 @@ class SearchMappingContractError extends Error { function supportsRequirement(capabilities, requirement) { if (!capabilities || typeof capabilities !== 'object') return false; - return requirement.types.some((type) => { - const capability = capabilities[type]; - return ( - capability?.searchable === true && (requirement.aggregatable !== true || capability.aggregatable === true) - ); - }); + const mappedTypes = Object.entries(capabilities).filter(([type]) => type !== 'unmapped'); + return ( + mappedTypes.length > 0 && + mappedTypes.every( + ([type, capability]) => + requirement.types.includes(type) && + capability?.searchable === true && + (requirement.aggregatable !== true || capability.aggregatable === true) + ) + ); } function hasMappedCapability(capabilities) { diff --git a/utils/searchReadiness.test.js b/utils/searchReadiness.test.js index 8b02e14..3cc3944 100644 --- a/utils/searchReadiness.test.js +++ b/utils/searchReadiness.test.js @@ -21,6 +21,40 @@ describe('search readiness mapping contract', () => { expect(searchReadiness.validateFieldCapabilities(validCapabilities())).toBe(true); }); + test('rejects conflicting types for the same field across indices', () => { + const body = validCapabilities(); + body.fields['cveMetadata.cveId.keyword'].text = { + searchable: true, + aggregatable: false + }; + + expect(() => searchReadiness.validateFieldCapabilities(body)).toThrow( + searchReadiness.SearchMappingContractError + ); + }); + + test.each([true, false])('checks searchability of every otherwise compatible type: %s', (searchable) => { + const body = validCapabilities(); + body.fields['containers.cna.descriptions.value'].keyword = { searchable, aggregatable: true }; + + if (searchable) { + expect(searchReadiness.validateFieldCapabilities(body)).toBe(true); + } else { + expect(() => searchReadiness.validateFieldCapabilities(body)).toThrow( + searchReadiness.SearchMappingContractError + ); + } + }); + + test('requires aggregation support for the CVE ID sort field', () => { + const body = validCapabilities(); + body.fields['cveMetadata.cveId.keyword'].keyword.aggregatable = false; + + expect(() => searchReadiness.validateFieldCapabilities(body)).toThrow( + searchReadiness.SearchMappingContractError + ); + }); + test('rejects a missing capability group', () => { const body = validCapabilities(); const group = searchReadiness.FIELD_CAPABILITY_GROUPS[0]; From ec6fca9ec25188f02ea035c9713c1e7cf5042df3 Mon Sep 17 00:00:00 2001 From: Andrew Foote Date: Wed, 23 Sep 2026 13:55:47 -0400 Subject: [PATCH 5/7] fix: honor Postman environment fault-check settings --- .../CVE-Search-API.postman_collection.json | 16 ++++++-- test/postman/README.md | 2 + test/postman/buildCollection.js | 4 +- test/postman/collection.test.js | 38 +++++++++++++++++++ 4 files changed, 55 insertions(+), 5 deletions(-) create mode 100644 test/postman/collection.test.js diff --git a/test/postman/CVE-Search-API.postman_collection.json b/test/postman/CVE-Search-API.postman_collection.json index 0b538e5..c87b1c5 100644 --- a/test/postman/CVE-Search-API.postman_collection.json +++ b/test/postman/CVE-Search-API.postman_collection.json @@ -6879,7 +6879,9 @@ "script": { "type": "text/javascript", "exec": [ - "if (pm.collectionVariables.get('runFaultInjection') !== 'true') {", + "const environmentSetting = pm.environment.get('runFaultInjection');", + "const setting = environmentSetting === undefined ? pm.collectionVariables.get('runFaultInjection') : environmentSetting;", + "if (setting !== 'true') {", " console.log('Skipping OpenSearch readiness failure; set runFaultInjection=true to enable it.');", " pm.execution.skipRequest();", "}" @@ -6931,7 +6933,9 @@ "script": { "type": "text/javascript", "exec": [ - "if (pm.collectionVariables.get('runFaultInjection') !== 'true') {", + "const environmentSetting = pm.environment.get('runFaultInjection');", + "const setting = environmentSetting === undefined ? pm.collectionVariables.get('runFaultInjection') : environmentSetting;", + "if (setting !== 'true') {", " console.log('Skipping Search service unavailable; set runFaultInjection=true to enable it.');", " pm.execution.skipRequest();", "}" @@ -6988,7 +6992,9 @@ "script": { "type": "text/javascript", "exec": [ - "if (pm.collectionVariables.get('runFaultInjection') !== 'true') {", + "const environmentSetting = pm.environment.get('runFaultInjection');", + "const setting = environmentSetting === undefined ? pm.collectionVariables.get('runFaultInjection') : environmentSetting;", + "if (setting !== 'true') {", " console.log('Skipping Search timeout; set runFaultInjection=true to enable it.');", " pm.execution.skipRequest();", "}" @@ -7045,7 +7051,9 @@ "script": { "type": "text/javascript", "exec": [ - "if (pm.collectionVariables.get('runFaultInjection') !== 'true') {", + "const environmentSetting = pm.environment.get('runFaultInjection');", + "const setting = environmentSetting === undefined ? pm.collectionVariables.get('runFaultInjection') : environmentSetting;", + "if (setting !== 'true') {", " console.log('Skipping Unexpected upstream response; set runFaultInjection=true to enable it.');", " pm.execution.skipRequest();", "}" diff --git a/test/postman/README.md b/test/postman/README.md index cc42259..e24ef13 100644 --- a/test/postman/README.md +++ b/test/postman/README.md @@ -68,6 +68,8 @@ Folder `17 - Manual fault injection - skipped by default` describes runtime erro Setting `runFaultInjection` to `true` only enables the requests; it does not create their required failure conditions. Prepare and run one request at a time because the conditions are different and can conflict. +The selected environment's `runFaultInjection` value takes precedence over the collection variable. When the environment does not define it, the collection default is used. Only the string `true` enables these requests; `false`, an empty value, and other values keep them disabled. Set the environment value back to `false` after each approved fault check. + | Request | Required condition | Expected response | | ---------------------------- | ------------------------------------------------------------------------------------------- | --------------------------- | | OpenSearch readiness failure | Make the configured OpenSearch index unavailable to the API. | `503 SEARCH_NOT_READY` | diff --git a/test/postman/buildCollection.js b/test/postman/buildCollection.js index 42c495f..60feeb1 100644 --- a/test/postman/buildCollection.js +++ b/test/postman/buildCollection.js @@ -171,7 +171,9 @@ function item({ script: { type: 'text/javascript', exec: [ - `if (pm.collectionVariables.get('${skipUnless}') !== 'true') {`, + `const environmentSetting = pm.environment.get('${skipUnless}');`, + `const setting = environmentSetting === undefined ? pm.collectionVariables.get('${skipUnless}') : environmentSetting;`, + "if (setting !== 'true') {", ` console.log('Skipping ${name}; set ${skipUnless}=true to enable it.');`, ' pm.execution.skipRequest();', '}' diff --git a/test/postman/collection.test.js b/test/postman/collection.test.js new file mode 100644 index 0000000..d7f958d --- /dev/null +++ b/test/postman/collection.test.js @@ -0,0 +1,38 @@ +const { runInNewContext } = require('vm'); +const collection = require('./CVE-Search-API.postman_collection.json'); + +describe('Postman fault-injection opt-in guards', () => { + const requests = collection.item.find((folder) => folder.name.startsWith('17 -')).item; + + test.each([ + ['true', 'false', false], + ['false', 'true', true], + [undefined, 'true', false], + [undefined, 'false', true], + [undefined, undefined, true], + ['', 'true', true], + [null, 'true', true], + [true, 'true', true], + ['TRUE', 'true', true] + ])('environment=%s collection=%s skips=%s', (environmentSetting, collectionSetting, shouldSkip) => { + for (const request of requests) { + const skipRequest = jest.fn(); + const environmentGet = jest.fn(() => environmentSetting); + const collectionGet = jest.fn(() => collectionSetting); + const script = request.event.find((event) => event.listen === 'prerequest').script.exec.join('\n'); + + runInNewContext(script, { + pm: { + environment: { get: environmentGet }, + collectionVariables: { get: collectionGet }, + execution: { skipRequest } + }, + console: { log: jest.fn() } + }); + + expect(environmentGet).toHaveBeenCalledWith('runFaultInjection'); + expect(collectionGet).toHaveBeenCalledTimes(environmentSetting === undefined ? 1 : 0); + expect(skipRequest).toHaveBeenCalledTimes(shouldSkip ? 1 : 0); + } + }); +}); From d8cc7fdfc1c03a2ab0ea0f6a5f1abf34190ff52a Mon Sep 17 00:00:00 2001 From: Andrew Foote Date: Wed, 23 Sep 2026 13:58:45 -0400 Subject: [PATCH 6/7] fix: sanitize terminal parser and server errors --- README.md | 2 + api-docs/openapi.json | 59 +++++++++++++++++++++++- api-docs/openapi.test.js | 9 ++++ routes/index.js | 48 ++++++++++++++++++++ server.js | 18 ++++++-- server.test.js | 96 ++++++++++++++++++++++++++++++++++++++++ utils/errors.js | 16 +++++++ 7 files changed, 244 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 7e9a070..8056acc 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,8 @@ When the configured index is an alias spanning multiple indices, every reported ## API Input Validation +JSON request bodies for both search endpoints are limited to 100 KiB (102,400 bytes, measured after decompression). Larger bodies return HTTP 413 `REQUEST_BODY_TOO_LARGE`. Parser errors return sanitized JSON rather than HTML or stack traces: malformed JSON returns HTTP 400 `MALFORMED_JSON`, other unreadable bodies return HTTP 400 `INVALID_REQUEST`, and unsupported encoding returns HTTP 415 (`SEARCH_CONTENT_TYPE` for `/search`, `REQUEST_ENCODING_UNSUPPORTED` for `/webSearch`). Unexpected server errors return a generic `INTERNAL_SERVER_ERROR` response without exception details, including outside production mode. + `POST /search` accepts an optional JSON object request body and validates supported fields, boolean parameters, pagination values, CVSS severity values, required parameter combinations, CPE Name requirements, and date-time formats. Enum-valued fields are accepted case-insensitively and normalized to their documented canonical values. A non-empty body must use the `application/json` media type with UTF-8 encoding; unsupported media types or encodings return HTTP 415, and malformed JSON returns HTTP 400. If OpenSearch is unavailable, the endpoint returns HTTP 503 with a `Retry-After` header. Successful `/search` responses use an API-owned response structure rather than exposing search-backend response objects. The top-level fields are `apiVersion`, `status`, `warnings`, `pagination`, and `records`, in that order. `records` contains complete CVE Records directly, without search-backend hit wrappers or infrastructure metadata. `pagination` contains `page`, `pageSize`, `resultsReturned`, `totalResults`, `totalPages`, and `totalIsExact`. Non-fatal conditions are reported in `warnings` before the potentially long `records` array. diff --git a/api-docs/openapi.json b/api-docs/openapi.json index 055e990..619b85b 100644 --- a/api-docs/openapi.json +++ b/api-docs/openapi.json @@ -52,6 +52,16 @@ } } }, + "413": { + "description": "JSON request body exceeds the 100 KiB parser limit (REQUEST_BODY_TOO_LARGE)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, "415": { "description": "A non-empty request body did not use the application/json media type with UTF-8 encoding", "content": { @@ -62,6 +72,16 @@ } } }, + "500": { + "description": "An unexpected server error; internal details are not returned (INTERNAL_SERVER_ERROR)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, "502": { "description": "The search backend returned an invalid or unexpected response", "content": { @@ -277,7 +297,14 @@ } }, "400": { - "description": "Bad Request" + "description": "Malformed JSON, unreadable request body, or invalid raw-query request parameters", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } }, "410": { "description": "The endpoint is disabled by service configuration", @@ -289,6 +316,36 @@ } } }, + "413": { + "description": "JSON request body exceeds the 100 KiB parser limit (REQUEST_BODY_TOO_LARGE)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "415": { + "description": "Unsupported request body encoding (REQUEST_ENCODING_UNSUPPORTED)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "500": { + "description": "An unexpected server error; internal details are not returned (INTERNAL_SERVER_ERROR)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, "502": { "description": "The search backend returned an error or invalid response", "content": { diff --git a/api-docs/openapi.test.js b/api-docs/openapi.test.js index 6699f2c..be8da04 100644 --- a/api-docs/openapi.test.js +++ b/api-docs/openapi.test.js @@ -202,6 +202,15 @@ describe('OpenAPI contract', () => { }); }); + test.each(['/search', '/webSearch'])('documents sanitized parser and server errors for %s', (path) => { + const responses = openapi.paths[path].post.responses; + for (const status of ['413', '415', '500']) { + expect(responses[status].content['application/json'].schema.$ref).toBe('#/components/schemas/ApiError'); + } + expect(responses['413'].description).toContain('REQUEST_BODY_TOO_LARGE'); + expect(responses['500'].description).toContain('INTERNAL_SERVER_ERROR'); + }); + test('documents reusable success and error response schemas', () => { const schemas = openapi.components.schemas; const searchResponse = schemas.SearchResponse; diff --git a/routes/index.js b/routes/index.js index 28838ef..c6649f8 100644 --- a/routes/index.js +++ b/routes/index.js @@ -309,6 +309,22 @@ content: { } }, } +#swagger.responses[413] = { +description: 'JSON request body exceeds the 100 KiB parser limit (REQUEST_BODY_TOO_LARGE)', +content: { + "application/json": { + schema: { $ref: "#/components/schemas/ApiError" } + } +} +} +#swagger.responses[500] = { +description: 'An unexpected server error; internal details are not returned (INTERNAL_SERVER_ERROR)', +content: { + "application/json": { + schema: { $ref: "#/components/schemas/ApiError" } + } +} +} #swagger.responses[415] = { description: 'A non-empty request body did not use the application/json media type with UTF-8 encoding', content: { @@ -538,6 +554,38 @@ content: { } }, } +#swagger.responses[400] = { +description: 'Malformed JSON, unreadable request body, or invalid raw-query request parameters', +content: { + "application/json": { + schema: { $ref: "#/components/schemas/ApiErrorResponse" } + } +} +} +#swagger.responses[413] = { +description: 'JSON request body exceeds the 100 KiB parser limit (REQUEST_BODY_TOO_LARGE)', +content: { + "application/json": { + schema: { $ref: "#/components/schemas/ApiError" } + } +} +} +#swagger.responses[415] = { +description: 'Unsupported request body encoding (REQUEST_ENCODING_UNSUPPORTED)', +content: { + "application/json": { + schema: { $ref: "#/components/schemas/ApiError" } + } +} +} +#swagger.responses[500] = { +description: 'An unexpected server error; internal details are not returned (INTERNAL_SERVER_ERROR)', +content: { + "application/json": { + schema: { $ref: "#/components/schemas/ApiError" } + } +} +} #swagger.responses[410] = { description: 'The endpoint is disabled by service configuration', content: { diff --git a/server.js b/server.js index 4680dcc..02f4eff 100644 --- a/server.js +++ b/server.js @@ -38,15 +38,26 @@ function createRequestLogger() { } function handleRequestError(err, req, res, next) { + if (res.headersSent) return next(err); + if (err?.type === 'entity.parse.failed') { return res.status(400).json(error.malformedJsonError()); } - if (err?.status === 415 && req.path === '/search') { - return res.status(415).json(error.searchContentTypeError()); + const errorStatus = err?.status ?? err?.statusCode; + const status = Number.isInteger(errorStatus) && errorStatus >= 400 && errorStatus <= 599 ? errorStatus : 500; + + if (err?.type === 'entity.too.large' || status === 413) { + return res.status(413).json(error.requestBodyTooLargeError()); + } + + if (status === 415) { + return res + .status(415) + .json(req.path === '/search' ? error.searchContentTypeError() : error.requestEncodingError()); } - next(err); + return res.status(status).json(status < 500 ? error.invalidRequestError() : error.internalServerError()); } function createApp() { @@ -90,6 +101,7 @@ module.exports = { createRequestLogger, getRedactedRequestPath: requestLogging.getRedactedRequestPath, getRuntimeConfig, + handleRequestError, startServer, swaggerSetupOptions }; diff --git a/server.test.js b/server.test.js index be14e86..7b0bcac 100644 --- a/server.test.js +++ b/server.test.js @@ -34,6 +34,7 @@ const { createRequestLogger, getRedactedRequestPath, getRuntimeConfig, + handleRequestError, startServer, swaggerSetupOptions } = require('./server'); @@ -183,6 +184,101 @@ describe('server application bootstrap', () => { expect(mockSearch).not.toHaveBeenCalled(); }); + test.each([ + ['development', '/search'], + ['development', '/webSearch'], + ['production', '/search'], + ['production', '/webSearch'], + [undefined, '/search'], + [undefined, '/webSearch'] + ])('oversized bodies return sanitized JSON in %s at %s', async (environment, path) => { + restoreEnv('NODE_ENV', environment); + const response = await request(createApp(), { + method: 'POST', + path, + body: { query: 'x'.repeat(102401) } + }); + + expect(response.status).toBe(413); + expect(response.headers['content-type']).toContain('application/json'); + expect(response.headers['x-request-id']).toBeDefined(); + expect(response.body).toStrictEqual({ + error: 'REQUEST_BODY_TOO_LARGE', + message: 'The request body exceeds the allowed size limit.' + }); + expect(mockSearch).not.toHaveBeenCalled(); + expect(mockWebSearch).not.toHaveBeenCalled(); + }); + + test('unsupported webSearch encoding returns sanitized JSON', async () => { + const response = await request(createApp(), { + method: 'POST', + path: '/webSearch', + rawBody: '{"query":"apache"}', + headers: { 'Content-Type': 'application/json; charset=iso-8859-1' } + }); + + expect(response.status).toBe(415); + expect(response.body).toStrictEqual({ + error: 'REQUEST_ENCODING_UNSUPPORTED', + message: 'The request body uses an unsupported encoding.' + }); + expect(mockWebSearch).not.toHaveBeenCalled(); + }); + + test.each(['/search', '/webSearch'])('corrupt compressed bodies return sanitized JSON at %s', async (path) => { + const response = await request(createApp(), { + method: 'POST', + path, + rawBody: 'not-a-gzip-stream', + headers: { 'Content-Type': 'application/json', 'Content-Encoding': 'gzip' } + }); + + expect(response.status).toBe(400); + expect(response.body).toStrictEqual({ + error: 'INVALID_REQUEST', + message: 'The request could not be processed.' + }); + expect(mockSearch).not.toHaveBeenCalled(); + expect(mockWebSearch).not.toHaveBeenCalled(); + }); + + test.each(['development', 'production'])( + 'unexpected errors never expose exception details in %s', + async (environment) => { + process.env.NODE_ENV = environment; + mockSearch.mockImplementationOnce((_req, _res, next) => next(new Error('private backend credentials'))); + + const response = await request(createApp(), { method: 'POST', path: '/search', body: {} }); + + expect(response.status).toBe(500); + expect(response.body).toStrictEqual({ + error: 'INTERNAL_SERVER_ERROR', + message: 'An unexpected server error occurred. Please try again later.' + }); + } + ); + + test('delegates errors after headers are sent without trying to rewrite the response', () => { + const failure = new Error('already streaming'); + const res = { headersSent: true, status: jest.fn() }; + const next = jest.fn(); + handleRequestError(failure, {}, res, next); + expect(next).toHaveBeenCalledWith(failure); + expect(res.status).not.toHaveBeenCalled(); + }); + + test.each([ + [{ statusCode: 400 }, 400, 'INVALID_REQUEST'], + [{ status: 503 }, 503, 'INTERNAL_SERVER_ERROR'], + [{ status: 200 }, 500, 'INTERNAL_SERVER_ERROR'] + ])('preserves valid error statuses without forwarding details: %j', (failure, status, code) => { + const res = { status: jest.fn().mockReturnThis(), json: jest.fn() }; + handleRequestError(failure, {}, res, jest.fn()); + expect(res.status).toHaveBeenCalledWith(status); + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ error: code })); + }); + test('getRuntimeConfig preserves baseURL behavior when PORT is set without BASE_URL', () => { delete process.env.BASE_URL; process.env.PORT = '4567'; diff --git a/utils/errors.js b/utils/errors.js index 951e14e..cdbaa2e 100644 --- a/utils/errors.js +++ b/utils/errors.js @@ -195,6 +195,22 @@ class errors { return makeError('MALFORMED_JSON', 'The request body contains malformed JSON.'); } + requestBodyTooLargeError() { + return makeError('REQUEST_BODY_TOO_LARGE', 'The request body exceeds the allowed size limit.'); + } + + requestEncodingError() { + return makeError('REQUEST_ENCODING_UNSUPPORTED', 'The request body uses an unsupported encoding.'); + } + + invalidRequestError() { + return makeError('INVALID_REQUEST', 'The request could not be processed.'); + } + + internalServerError() { + return makeError('INTERNAL_SERVER_ERROR', 'An unexpected server error occurred. Please try again later.'); + } + searchQueryUnsupportedError() { return makeError( 'SEARCH_QUERY_UNSUPPORTED', From fb69e72a85e9a3b2ebb3ab8c13e128f3802bb9f9 Mon Sep 17 00:00:00 2001 From: Andrew Foote Date: Wed, 23 Sep 2026 13:59:39 -0400 Subject: [PATCH 7/7] fix: bound configured search timeouts to timer limits --- README.md | 2 ++ utils/constants.js | 2 ++ utils/searchProvider.js | 15 ++++++++++----- utils/searchProvider.test.js | 22 +++++++++++++++++----- 4 files changed, 31 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 8056acc..7d4dfbc 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,8 @@ Rename `.env.example` to `.env` and replace or check the following values. - `WebSearchEnabled` - optional `/webSearch` availability flag, default `true` - `NPM_CA_CERT` - optional absolute host path to a corporate or self-signed CA certificate bundle used only during local Docker Compose builds +The three timeout settings (`SearchOpenSearchRequestTimeoutMs`, `SearchRequestDeadlineMs`, and `SearchReadinessTimeoutMs`) accept integer milliseconds from `1` through `2147483647`. Invalid or out-of-range settings fall back to the defaults listed above; they do not disable timeouts. This upper bound prevents Node.js timer overflow from turning a large configured duration into a 1 ms timeout. + ```bash mv .env.example .env ``` diff --git a/utils/constants.js b/utils/constants.js index 9e138d6..83f7b4d 100644 --- a/utils/constants.js +++ b/utils/constants.js @@ -24,6 +24,7 @@ const SEARCH_STRING_LENGTH_LIMITS = Object.freeze({ const SEARCH_OPEN_SEARCH_REQUEST_TIMEOUT_MS = 20 * 1000; const SEARCH_REQUEST_DEADLINE_MS = 30 * 1000; const SEARCH_READINESS_TIMEOUT_MS = 5 * 1000; +const MAX_TIMER_TIMEOUT_MS = 2147483647; const rejectedCveStates = ['REJECT', 'REJECTED', 'Rejected']; const cpeVersionRangeTypes = ['including', 'excluding']; @@ -136,6 +137,7 @@ module.exports = { SEARCH_OPEN_SEARCH_REQUEST_TIMEOUT_MS, SEARCH_REQUEST_DEADLINE_MS, SEARCH_READINESS_TIMEOUT_MS, + MAX_TIMER_TIMEOUT_MS, RESULTS_PER_PAGE, REJECTED_CVE_STATES: rejectedCveStates, CPE_VERSION_RANGE_TYPES: cpeVersionRangeTypes, diff --git a/utils/searchProvider.js b/utils/searchProvider.js index 8616d73..03b9855 100644 --- a/utils/searchProvider.js +++ b/utils/searchProvider.js @@ -42,11 +42,11 @@ function normalizeBoolean(value, defaultValue = false) { return defaultValue; } -function getIntegerConfig(path, defaultValue, { min = 0 } = {}) { +function getIntegerConfig(path, defaultValue, { min = 0, max = Number.MAX_SAFE_INTEGER } = {}) { if (!AppConfig.has(path)) return defaultValue; const value = Number(AppConfig.get(path)); - if (Number.isInteger(value) && value >= min) return value; + if (Number.isInteger(value) && value >= min && value <= max) return value; return defaultValue; } @@ -238,18 +238,23 @@ function isSearchProviderContractError(err) { function getOpenSearchReadinessTimeoutMs() { return getIntegerConfig('search.readinessTimeoutMs', constants.SEARCH_READINESS_TIMEOUT_MS, { - min: 1 + min: 1, + max: constants.MAX_TIMER_TIMEOUT_MS }); } function getOpenSearchRequestTimeoutMs() { return getIntegerConfig('search.openSearchRequestTimeoutMs', constants.SEARCH_OPEN_SEARCH_REQUEST_TIMEOUT_MS, { - min: 1 + min: 1, + max: constants.MAX_TIMER_TIMEOUT_MS }); } function getSearchRequestDeadlineMs() { - return getIntegerConfig('search.requestDeadlineMs', constants.SEARCH_REQUEST_DEADLINE_MS, { min: 1 }); + return getIntegerConfig('search.requestDeadlineMs', constants.SEARCH_REQUEST_DEADLINE_MS, { + min: 1, + max: constants.MAX_TIMER_TIMEOUT_MS + }); } function resetSearchProviderForTests() { diff --git a/utils/searchProvider.test.js b/utils/searchProvider.test.js index 243396a..050644b 100644 --- a/utils/searchProvider.test.js +++ b/utils/searchProvider.test.js @@ -396,12 +396,24 @@ describe('searchProvider', () => { expect(searchProvider.getOpenSearchReadinessTimeoutMs()).toBe(7000); }); - test('falls back to search timeout defaults when config values are invalid', () => { + test.each([1, '1', 2147483647, '2147483647'])('accepts timeout boundary %s', (value) => { mockAppConfigHas.mockReturnValue(true); - mockAppConfigGet.mockReturnValue('not-a-number'); + mockAppConfigGet.mockReturnValue(value); - expect(searchProvider.getOpenSearchRequestTimeoutMs()).toBe(20000); - expect(searchProvider.getSearchRequestDeadlineMs()).toBe(30000); - expect(searchProvider.getOpenSearchReadinessTimeoutMs()).toBe(5000); + expect(searchProvider.getOpenSearchRequestTimeoutMs()).toBe(Number(value)); + expect(searchProvider.getSearchRequestDeadlineMs()).toBe(Number(value)); + expect(searchProvider.getOpenSearchReadinessTimeoutMs()).toBe(Number(value)); }); + + test.each(['not-a-number', '', 0, -1, 1.5, 2147483648, '2147483648', Number.MAX_SAFE_INTEGER, Infinity])( + 'falls back to search timeout defaults for invalid or overflowing value %s', + (value) => { + mockAppConfigHas.mockReturnValue(true); + mockAppConfigGet.mockReturnValue(value); + + expect(searchProvider.getOpenSearchRequestTimeoutMs()).toBe(20000); + expect(searchProvider.getSearchRequestDeadlineMs()).toBe(30000); + expect(searchProvider.getOpenSearchReadinessTimeoutMs()).toBe(5000); + } + ); });