From a607fc8299e7ab5065850cb9c8d36e3f311deb31 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 6 Nov 2026 15:13:10 +0000 Subject: [PATCH 1/4] chore(deps): bump body-parser from 1.20.8 to 2.4.0 Bumps [body-parser](https://github.com/expressjs/body-parser) from 1.20.8 to 2.4.0. - [Release notes](https://github.com/expressjs/body-parser/releases) - [Changelog](https://github.com/expressjs/body-parser/blob/master/HISTORY.md) - [Commits](https://github.com/expressjs/body-parser/compare/1.20.8...v2.4.0) --- updated-dependencies: - dependency-name: body-parser dependency-version: 2.4.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- package-lock.json | 221 +++++++++++++++++++++++++++++++++++++--------- package.json | 2 +- 2 files changed, 181 insertions(+), 42 deletions(-) diff --git a/package-lock.json b/package-lock.json index 57d7bbb..72653c3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "license": "MIT", "dependencies": { "accept-language-parser": "^1.5.0", - "body-parser": "^1.20.1", + "body-parser": "^2.4.0", "cookie-parser": "^1.4.7", "csp-header": "^5.2.1", "express": "^4.21.1", @@ -6341,35 +6341,40 @@ } }, "node_modules/body-parser": { - "version": "1.20.8", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.8.tgz", - "integrity": "sha512-JNcyFQ64OiijEkPzUBTCe+hyPXUD/3LEldGQ6iF5LR1w00mx9o7xtDWHXBY2iItjdCFGoilOLNQbH943ut7pHA==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.4.0.tgz", + "integrity": "sha512-Ifk1KcLrxaGt1occQagQNFNNOqRt1LFuehEnHHzB8Z6cRgIxRU1TBaiIsUiCdA+gy5PL7Y1IX31DNKkRmxtXjQ==", "license": "MIT", "dependencies": { - "bytes": "~3.1.2", - "content-type": "~1.0.5", - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "~1.2.0", - "http-errors": "~2.0.1", - "iconv-lite": "~0.4.24", - "on-finished": "~2.4.1", - "qs": "~6.16.0", - "raw-body": "~2.5.3", - "type-is": "~1.6.18", - "unpipe": "~1.0.0" + "bytes": "^3.1.2", + "content-type": "^2.1.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.3", + "on-finished": "^2.4.1", + "qs": "^6.16.1", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" }, "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, - "node_modules/body-parser/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dependencies": { - "ms": "2.0.0" + "node_modules/body-parser/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/body-parser/node_modules/http-errors": { @@ -6392,10 +6397,43 @@ "url": "https://opencollective.com/express" } }, - "node_modules/body-parser/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + "node_modules/body-parser/node_modules/media-typer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", + "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/body-parser/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } }, "node_modules/body-parser/node_modules/statuses": { "version": "2.0.2", @@ -6406,6 +6444,24 @@ "node": ">= 0.8" } }, + "node_modules/body-parser/node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/brace-expansion": { "version": "1.1.18", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", @@ -6949,7 +7005,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -8175,6 +8230,30 @@ "node": ">=10" } }, + "node_modules/express/node_modules/body-parser": { + "version": "1.20.9", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.9.tgz", + "integrity": "sha512-FISIQxV5u91Y9gvuFq3q/tA/qTqLJn3AhRcEvyozyhJTh5OBFFtv827AdHU7Rbyct4r+Ba1/99oFa1Y3yuFsdw==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.16.0", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, "node_modules/express/node_modules/debug": { "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", @@ -8183,11 +8262,67 @@ "ms": "2.0.0" } }, + "node_modules/express/node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express/node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/express/node_modules/ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" }, + "node_modules/express/node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/express/node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", @@ -8958,15 +9093,19 @@ } }, "node_modules/iconv-lite": { - "version": "0.4.24", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", - "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", "license": "MIT", "dependencies": { - "safer-buffer": ">= 2.1.2 < 3" + "safer-buffer": ">= 2.1.2 < 3.0.0" }, "engines": { "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/ignore": { @@ -12360,9 +12499,9 @@ "license": "MIT" }, "node_modules/qs": { - "version": "6.16.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", - "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", + "version": "6.16.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.1.tgz", + "integrity": "sha512-5Px6J7rfHBAGUhNQ3es4YzcOdBBw0SDcVtThJml4RdnKw4tKs2epA0kPZ+K8X+WiHktT93RHVcQ/3ym5aTCgtg==", "license": "BSD-3-Clause", "dependencies": { "es-define-property": "^1.0.1", @@ -12410,18 +12549,18 @@ } }, "node_modules/raw-body": { - "version": "2.5.3", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", - "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", "license": "MIT", "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", - "iconv-lite": "~0.4.24", + "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" }, "engines": { - "node": ">= 0.8" + "node": ">= 0.10" } }, "node_modules/raw-body/node_modules/http-errors": { diff --git a/package.json b/package.json index 7dd2c74..10d4004 100644 --- a/package.json +++ b/package.json @@ -57,7 +57,7 @@ }, "dependencies": { "accept-language-parser": "^1.5.0", - "body-parser": "^1.20.1", + "body-parser": "^2.4.0", "cookie-parser": "^1.4.7", "csp-header": "^5.2.1", "express": "^4.21.1", From f1ecd1863bcc8df741258ca8f8a5d13d99bcea6c Mon Sep 17 00:00:00 2001 From: Denis Vershkov Date: Mon, 9 Nov 2026 12:39:48 +0300 Subject: [PATCH 2/4] feat: request detail on 5xx and app-provided response extra (#133) * feat: request detail on 5xx and app-provided response extra * fix: remove unnecessary code --- src/base-middleware.ts | 140 ++++++++++++++++++++++++++------------ src/tests/logging.test.ts | 82 +++++++++++++++++++++- src/types.ts | 10 ++- 3 files changed, 184 insertions(+), 48 deletions(-) diff --git a/src/base-middleware.ts b/src/base-middleware.ts index 7fb0f86..684225d 100644 --- a/src/base-middleware.ts +++ b/src/base-middleware.ts @@ -1,10 +1,23 @@ import {randomUUID} from 'node:crypto'; import {type AppContext, REQUEST_ID_PARAM_NAME} from '@gravity-ui/nodekit'; -import type {Express} from 'express'; +import type {Express, Request, Response} from 'express'; import {DEFAULT_REQUEST_ID_HEADER} from './constants'; +interface FinishedRequestParams { + req: Request; + res: Response; + requestId: string; + responseTime: number; + statusCode: string; + shouldLogRequest: boolean; +} + +function isFailedStatus(statusCode: string) { + return statusCode.startsWith('5'); +} + function prepareHeaderNames(names?: string[]) { // an empty whitelist is meaningful: it omits every header if (!names) { @@ -25,8 +38,64 @@ function pickLoggedHeaders(headers: T, names?: Set): P } export function setupBaseMiddleware(ctx: AppContext, expressApp: Express) { - const requestHeaderNames = prepareHeaderNames(ctx.config.appLoggingRequestHeaders); - const responseHeaderNames = prepareHeaderNames(ctx.config.appLoggingResponseHeaders); + const allowedHeaderNames = { + request: prepareHeaderNames(ctx.config.appLoggingRequestHeaders), + response: prepareHeaderNames(ctx.config.appLoggingResponseHeaders), + errorResponse: prepareHeaderNames( + ctx.config.appLoggingErrorResponseHeaders ?? ctx.config.appLoggingResponseHeaders, + ), + }; + + const buildLoggedRequestInfo = (req: Request, requestId: string) => ({ + id: requestId, + method: req.method, + url: ctx.utils.redactSensitiveQueryParams(req.originalUrl), + ...(ctx.config.appDevMode + ? {} + : { + headers: ctx.utils.redactSensitiveHeaders( + pickLoggedHeaders(req.headers, allowedHeaderNames.request), + ), + remoteAddress: req.socket && req.socket.remoteAddress, + remotePort: req.socket && req.socket.remotePort, + }), + }); + + const buildFinishedExtra = ({ + req, + res, + requestId, + responseTime, + statusCode, + shouldLogRequest, + }: FinishedRequestParams) => { + const isFailed = isFailedStatus(statusCode); + const responseHeaderNames = isFailed + ? allowedHeaderNames.errorResponse + : allowedHeaderNames.response; + + return { + res: ctx.config.appDevMode + ? {responseTime, statusCode} + : { + responseTime, + statusCode, + // TODO: drop the redactSensitiveKeys pass in the next major, + // appSensitiveHeaders should be the only option for response headers. + // It is kept on top of the header redacter for backward compatibility: + // before the switch to redactSensitiveHeaders response headers were + // redacted by key, so appSensitiveKeys was the only working option + headers: ctx.utils.redactSensitiveKeys( + ctx.utils.redactSensitiveHeaders( + pickLoggedHeaders(res.getHeaders(), responseHeaderNames), + ), + ), + }, + // request details are already in "Request started", so they are + // repeated here only when the filter suppressed that log + ...(isFailed && !shouldLogRequest ? {req: buildLoggedRequestInfo(req, requestId)} : {}), + }; + }; expressApp.use((req, res, next) => { try { @@ -83,52 +152,35 @@ export function setupBaseMiddleware(ctx: AppContext, expressApp: Express) { const shouldLogRequest = ctx.config.appLoggingRequestFilter?.(req) ?? true; if (shouldLogRequest) { - const requestStartedExtra = ctx.config.appDevMode - ? {req: {url: ctx.utils.redactSensitiveQueryParams(req.url)}} - : { - req: { - id: requestId, - method: req.method, - url: ctx.utils.redactSensitiveQueryParams(req.url), - headers: ctx.utils.redactSensitiveHeaders( - pickLoggedHeaders(req.headers, requestHeaderNames), - ), - remoteAddress: req.socket && req.socket.remoteAddress, - remotePort: req.socket && req.socket.remotePort, - }, - }; - req.ctx.log('Request started', requestStartedExtra); + req.ctx.log('Request started', { + req: buildLoggedRequestInfo(req, requestId), + }); } res.on('finish', () => { const statusCode = String(res.statusCode); - const responseTime = Date.now() - startTime; - - const responseExtra = ctx.config.appDevMode - ? {res: {responseTime, statusCode}} - : { - res: { - responseTime, - statusCode, - // TODO: drop the redactSensitiveKeys pass in the next major, - // appSensitiveHeaders should be the only option for response headers. - // It is kept on top of the header redacter for backward compatibility: - // before the switch to redactSensitiveHeaders response headers were - // redacted by key, so appSensitiveKeys was the only working option - headers: ctx.utils.redactSensitiveKeys( - ctx.utils.redactSensitiveHeaders( - pickLoggedHeaders(res.getHeaders(), responseHeaderNames), - ), - ), - }, - }; - - if (statusCode.startsWith('5')) { - req.originalContext.logError('Request failed', null, responseExtra); - } else if (shouldLogRequest) { - req.originalContext.log('Request completed', responseExtra); - } + const isFailed = isFailedStatus(statusCode); + req.originalContext.setTag('http.status_code', statusCode); + + if (!isFailed && !shouldLogRequest) { + return; + } + + const extra = buildFinishedExtra({ + req, + res, + requestId, + responseTime: Date.now() - startTime, + statusCode, + shouldLogRequest, + }); + + if (isFailed) { + req.originalContext.logError('Request failed', null, extra); + } else { + req.originalContext.log('Request completed', extra); + } }); res.on('close', () => { diff --git a/src/tests/logging.test.ts b/src/tests/logging.test.ts index 63e5357..eb71a56 100644 --- a/src/tests/logging.test.ts +++ b/src/tests/logging.test.ts @@ -1,4 +1,4 @@ -import {ExpressKit, Request, Response} from '..'; +import {AppMountHandler, ExpressKit, Request, Response} from '..'; import {DEFAULT_REQUEST_ID_HEADER} from '../constants'; import {AppConfig, NodeKit} from '@gravity-ui/nodekit'; import request from 'supertest'; @@ -70,6 +70,17 @@ const setupApp = ({config}: {config?: AppConfig} = {}) => { res.send({status: 'ok'}); }, }, + 'MOUNT /mounted': { + handler: (({router, wrapRouteHandler}) => { + router.get( + '/500', + wrapRouteHandler((_: Request, res: Response) => { + res.status(500); + res.send({status: 'error'}); + }), + ); + }) as AppMountHandler, + }, }; const app = new ExpressKit(nodekit, routes); @@ -239,7 +250,7 @@ describe('log system', () => { }); }); - it('still logs 5xx errors for excluded paths', async () => { + it('still logs 5xx errors for excluded paths, with the suppressed request details', async () => { const {app, logger} = setupApp({ config: {appLoggingRequestFilter: (req) => req.path !== '/500'}, }); @@ -255,7 +266,14 @@ describe('log system', () => { expect(log).toMatchObject({ msg: `[Express GET] Request failed [${requestId}]`, level: 50, - req: {id: requestId, method: 'GET', url: '/500'}, + req: { + id: requestId, + method: 'GET', + url: '/500', + headers: {[DEFAULT_REQUEST_ID_HEADER]: requestId}, + remoteAddress: expect.any(String), + remotePort: expect.any(Number), + }, res: {statusCode: '500'}, }); }); @@ -292,6 +310,64 @@ describe('log system', () => { }); }); + it('does not repeat request details in "Request failed" when "Request started" was logged', async () => { + const {app, logger} = setupApp(); + const requestId = 'logged-start-error'; + + await request(app.express).get('/500').set(DEFAULT_REQUEST_ID_HEADER, requestId); + + const [started, failed] = logger.write.mock.calls.map(([line]) => JSON.parse(line)); + expect(started.msg).toBe(`[Express GET] Request started [${requestId}]`); + expect(started.req.headers).toMatchObject({[DEFAULT_REQUEST_ID_HEADER]: requestId}); + + expect(failed.msg).toBe(`[Express GET] Request failed [${requestId}]`); + // only the baseline req extra, without headers and the remote address + expect(failed.req).toStrictEqual({id: requestId, method: 'GET', url: '/500'}); + }); + + it('keeps the full url in the suppressed request details of a mounted route', async () => { + const {app, logger} = setupApp({ + config: {appLoggingRequestFilter: (req) => !req.path.startsWith('/mounted')}, + }); + + await request(app.express).get('/mounted/500?q=1').expect(500); + + const failed = JSON.parse(logger.write.mock.calls[0][0]); + expect(failed.req.url).toBe('/mounted/500?q=1'); + }); + + describe('error log headers whitelist', () => { + it('uses appLoggingErrorResponseHeaders for the failed response headers', async () => { + const {app, logger} = setupApp({ + config: { + appLoggingResponseHeaders: [], + appLoggingErrorResponseHeaders: ['Content-Type'], + }, + }); + + await request(app.express).get('/500').expect(500); + + const [, failed] = logger.write.mock.calls.map(([line]) => JSON.parse(line)); + expect(failed.res.headers).toStrictEqual({'content-type': expect.any(String)}); + }); + + it('falls back to appLoggingResponseHeaders and applies the request whitelist', async () => { + const {app, logger} = setupApp({ + config: { + appLoggingRequestFilter: (req) => req.path !== '/500', + appLoggingRequestHeaders: ['User-Agent'], + appLoggingResponseHeaders: [], + }, + }); + + await request(app.express).get('/500').set(USER_AGENT_HEADER, 'test-agent').expect(500); + + const failed = JSON.parse(logger.write.mock.calls[0][0]); + expect(failed.req.headers).toStrictEqual({[USER_AGENT_HEADER]: 'test-agent'}); + expect(failed.res.headers).toStrictEqual({}); + }); + }); + it('log message should not contain req id if appLoggingOmitIdInMessages is specified', async () => { const {app, logger} = setupApp({config: {appLoggingOmitIdInMessages: true}}); diff --git a/src/types.ts b/src/types.ts index 9df192a..6b75cbb 100644 --- a/src/types.ts +++ b/src/types.ts @@ -35,7 +35,9 @@ declare module '@gravity-ui/nodekit' { appLoggingRequestFilter?: (req: Request) => boolean; /** - * case-insensitive whitelist of request headers in the "Request started" log + * case-insensitive whitelist of request headers in the "Request started" log, + * and in the "Request failed" log of a request whose start log was suppressed + * by [appLoggingRequestFilter] * an empty array omits request headers; by default all headers are logged */ appLoggingRequestHeaders?: string[]; @@ -46,6 +48,12 @@ declare module '@gravity-ui/nodekit' { */ appLoggingResponseHeaders?: string[]; + /** + * case-insensitive whitelist of response headers in the "Request failed" log + * defaults to [appLoggingResponseHeaders] + */ + appLoggingErrorResponseHeaders?: string[]; + expressDisableBodyParserJSON?: boolean; expressBodyParserJSONConfig?: bodyParser.OptionsJson; expressExtendedBodyParserJSONLimitError?: boolean; From 4f20087a139a589742ad33afac43fcd3622d8c61 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 9 Nov 2026 14:22:17 +0300 Subject: [PATCH 3/4] chore(deps-dev): bump @grpc/grpc-js (#128) Bumps the npm_and_yarn group with 1 update in the / directory: [@grpc/grpc-js](https://github.com/grpc/grpc-node). Updates `@grpc/grpc-js` from 1.14.4 to 1.14.7 - [Release notes](https://github.com/grpc/grpc-node/releases) - [Commits](https://github.com/grpc/grpc-node/compare/@grpc/grpc-js@1.14.4...@grpc/grpc-js@1.14.7) --- updated-dependencies: - dependency-name: "@grpc/grpc-js" dependency-version: 1.14.5 dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 57d7bbb..cf492d6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1433,9 +1433,9 @@ "dev": true }, "node_modules/@grpc/grpc-js": { - "version": "1.14.4", - "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.4.tgz", - "integrity": "sha512-k9Dj3DV/itK9D06Y8f190Qgop7/Ui+D0njFV3LHMPwPT75DpXLQohE9Wmz0QElrJnzsjB7KPWiKJbOl7IPDArQ==", + "version": "1.14.7", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.7.tgz", + "integrity": "sha512-W4W/fcBMGRVtG6dW2c7cw2mnpS3hvbBd+oQXjh2iYFTZDk1JJmcNqkM5KSaPubQpamlUUurrlb7G1s9NJeERmQ==", "dev": true, "license": "Apache-2.0", "dependencies": { From 09c1a56cb17913be4c0afe6f9f80d19f14e9c622 Mon Sep 17 00:00:00 2001 From: Gadzhi Gadzhiev <168296+resure@users.noreply.github.com> Date: Mon, 9 Nov 2026 14:26:57 +0300 Subject: [PATCH 4/4] fix: preserve parser defaults with body-parser 2 --- src/parsers.ts | 18 +++++- src/tests/parsers.test.ts | 131 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 148 insertions(+), 1 deletion(-) create mode 100644 src/tests/parsers.test.ts diff --git a/src/parsers.ts b/src/parsers.ts index cd37b6d..a07893a 100644 --- a/src/parsers.ts +++ b/src/parsers.ts @@ -27,6 +27,19 @@ function isBodyLimitError(err: Error): err is BodyLimitError { export function setupParsers(ctx: AppContext, expressApp: Express) { expressApp.use(cookieParser(ctx.config.expressCookieSecret)); + if ( + !ctx.config.expressDisableBodyParserJSON || + !ctx.config.expressDisableBodyParserURLEncoded || + ctx.config.expressBodyParserRawConfig + ) { + expressApp.use((req, _res, next) => { + if (req.body === undefined) { + req.body = {}; + } + next(); + }); + } + if (!ctx.config.expressDisableBodyParserJSON) { expressApp.use( bodyParser.json(ctx.config.expressBodyParserJSONConfig || DEFAULT_JSON_PARSER_CONFIG), @@ -56,9 +69,12 @@ export function setupParsers(ctx: AppContext, expressApp: Express) { } if (!ctx.config.expressDisableBodyParserURLEncoded) { + const config = ctx.config.expressBodyParserURLEncodedConfig; expressApp.use( bodyParser.urlencoded( - ctx.config.expressBodyParserURLEncodedConfig || DEFAULT_URLENCODED_PARSER_CONFIG, + config + ? {...config, extended: config.extended ?? true} + : DEFAULT_URLENCODED_PARSER_CONFIG, ), ); } diff --git a/src/tests/parsers.test.ts b/src/tests/parsers.test.ts new file mode 100644 index 0000000..62545bb --- /dev/null +++ b/src/tests/parsers.test.ts @@ -0,0 +1,131 @@ +import type {AppConfig, AppContext} from '@gravity-ui/nodekit'; +import express, {type ErrorRequestHandler} from 'express'; +import request from 'supertest'; +import {z} from 'zod'; + +import {setupParsers} from '../parsers'; +import {withContract} from '../validator/with-contract'; + +const disabledParsers = { + expressDisableBodyParserJSON: true, + expressDisableBodyParserURLEncoded: true, +}; + +function setupApp(config: AppConfig = {}) { + const app = express(); + setupParsers({config} as AppContext, app); + app.post('/body', (req, res) => res.json({body: req.body})); + return app; +} + +describe('body parser compatibility', () => { + test.each([ + {}, + {expressDisableBodyParserURLEncoded: true}, + {expressDisableBodyParserJSON: true}, + {...disabledParsers, expressBodyParserRawConfig: {type: 'application/octet-stream'}}, + ])('initializes absent and unparsed bodies with config %j', async (config) => { + const app = setupApp(config); + await request(app).post('/body').expect(200, {body: {}}); + await request(app) + .post('/body') + .type('text/plain') + .send('unparsed') + .expect(200, {body: {}}); + }); + + test('leaves the body absent when all parsers are disabled', async () => { + await request(setupApp(disabledParsers)).post('/body').expect(200, {}); + }); + + test('makes the initial body available to custom type and verify callbacks', async () => { + const seenBodies: unknown[] = []; + const app = setupApp({ + expressBodyParserJSONConfig: { + type: (req) => { + seenBodies.push((req as express.Request).body); + return true; + }, + verify: (req) => { + seenBodies.push((req as express.Request).body); + }, + }, + }); + await request(app) + .post('/body') + .send({value: 1}) + .expect(200, {body: {value: 1}}); + expect(seenBodies).toEqual([{}, {}]); + }); + + test('preserves an existing parsed body', async () => { + const app = express(); + app.use((req, _res, next) => { + req.body = {value: 'already parsed'}; + next(); + }); + setupParsers({config: {}} as AppContext, app); + app.post('/body', (req, res) => res.json({body: req.body})); + await request(app) + .post('/body') + .expect(200, {body: {value: 'already parsed'}}); + }); + + test('allows object contracts to apply defaults to an absent body', async () => { + const app = setupApp(); + const body = z.object({pageSize: z.number().default(20)}); + const handler = withContract({ + request: {body}, + response: {content: {200: body}}, + })(async (req, res) => res.sendTyped(200, req.body)); + app.post('/defaults', (req, res, next) => { + Promise.resolve(handler(req, res)).catch(next); + }); + await request(app).post('/defaults').expect(200, {pageSize: 20}); + }); + + test.each([ + [undefined, {'user[name]': 'alice'}], + [{limit: '10mb'}, {user: {name: 'alice'}}], + [{extended: true}, {user: {name: 'alice'}}], + [{extended: false}, {'user[name]': 'alice'}], + ])('preserves URL-encoded parsing with config %j', async (config, body) => { + const app = setupApp({expressBodyParserURLEncodedConfig: config}); + await request(app).post('/body').type('form').send('user[name]=alice').expect(200, {body}); + }); + + test('parses raw bodies after the JSON and URL-encoded parsers skip them', async () => { + const app = setupApp({expressBodyParserRawConfig: {type: 'application/octet-stream'}}); + await request(app) + .post('/body') + .type('application/octet-stream') + .send(Buffer.from('raw')) + .expect(200, {body: {type: 'Buffer', data: [114, 97, 119]}}); + }); + + test('keeps malformed JSON and size-limit errors', async () => { + const app = setupApp({expressBodyParserJSONConfig: {limit: 16}}); + await request(app) + .post('/body') + .type('json') + .send('{') + .expect(400, 'Invalid JSON supplied'); + await request(app) + .post('/body') + .send({value: 'too large for the configured limit'}) + .expect(413, 'Request entity too large'); + }); + + test('retains the custom URL-encoded parameter limit', async () => { + const app = setupApp({expressBodyParserURLEncodedConfig: {parameterLimit: 1}}); + const handleError: ErrorRequestHandler = (error, _req, res, _next) => { + res.status(error.status).json({type: error.type}); + }; + app.use(handleError); + await request(app) + .post('/body') + .type('form') + .send('a=1&b=2') + .expect(413, {type: 'parameters.too.many'}); + }); +});