From ca0412b58b84924cb32ab0fa0ab686451c8778ce Mon Sep 17 00:00:00 2001 From: shubh-bruno Date: Tue, 3 Mar 2026 14:35:54 +0530 Subject: [PATCH] fix: allow user to delete default bruno headers in pre-request (#7331) * fix: allow user to delete default bruno headers * fix: resolved comments --------- Co-authored-by: shubh-bruno --- .../bruno-cli/src/utils/axios-instance.js | 27 +++- .../src/ipc/network/axios-instance.js | 67 ++++++---- packages/bruno-js/src/bruno-request.js | 17 ++- .../tests/bruno-request-delete-header.spec.js | 116 ++++++++++++++++++ .../src/network/axios-instance.ts | 21 +++- 5 files changed, 219 insertions(+), 29 deletions(-) create mode 100644 packages/bruno-js/tests/bruno-request-delete-header.spec.js diff --git a/packages/bruno-cli/src/utils/axios-instance.js b/packages/bruno-cli/src/utils/axios-instance.js index f32bd85b9..372cdb66f 100644 --- a/packages/bruno-cli/src/utils/axios-instance.js +++ b/packages/bruno-cli/src/utils/axios-instance.js @@ -78,14 +78,35 @@ function makeAxiosInstance({ requestMaxRedirects = 5, disableCookies, followRedi const instance = axios.create({ proxy: false, maxRedirects: 0, - headers: { - 'User-Agent': `bruno-runtime/${CLI_VERSION}` - } + headers: {} }); + // Set User-Agent manually (using transformRequest to delete headers instead) + instance.defaults.headers.common = { + 'User-Agent': `bruno-runtime/${CLI_VERSION}` + }; + instance.interceptors.request.use((config) => { config.headers['request-start-time'] = Date.now(); + /** + Apply header deletions requested via req.deleteHeader() in pre-request scripts. + Using set(name, null) rather than delete(): the axios http adapter guards its + own defaults (User-Agent, Accept-Encoding) with set(..., false) which only + skips writing when the key already exists. delete() removes the key entirely, + so the guard misses and the adapter re-adds the default. null keeps the key + present (blocking the guard) while toJSON() omits null values from the wire. + */ + const headersToDelete = config.__headersToDelete; + if (headersToDelete && Array.isArray(headersToDelete)) { + headersToDelete.forEach((headerName) => { + const lower = headerName.toLowerCase(); + if (lower === 'host' || lower === 'connection') return; + config.headers.set(headerName, null); + }); + delete config.__headersToDelete; + } + // Add cookies to request if available and not disabled if (!disableCookies) { const cookieString = getCookieStringForUrl(config.url); diff --git a/packages/bruno-electron/src/ipc/network/axios-instance.js b/packages/bruno-electron/src/ipc/network/axios-instance.js index ea178f594..8f404856e 100644 --- a/packages/bruno-electron/src/ipc/network/axios-instance.js +++ b/packages/bruno-electron/src/ipc/network/axios-instance.js @@ -81,7 +81,7 @@ function makeAxiosInstance({ } = {}) { /** @type {axios.AxiosInstance} */ const instance = axios.create({ - transformRequest: function transformRequest(data, headers) { + transformRequest: function (data, headers) { const contentType = headers?.['Content-Type'] || headers?.['content-type'] || ''; const hasJSONContentType = contentType.includes('json'); if (typeof data === 'string' && hasJSONContentType) { @@ -95,11 +95,14 @@ function makeAxiosInstance({ }, proxy: false, maxRedirects: 0, - headers: { - 'User-Agent': `bruno-runtime/${version}` - } + headers: {} }); + // Set User-Agent manually (using transformRequest to delete headers instead) + instance.defaults.headers.common = { + 'User-Agent': `bruno-runtime/${version}` + }; + instance.interceptors.request.use(async (config) => { const url = URL.parse(config.url); config.metadata = config.metadata || {}; @@ -121,29 +124,13 @@ function makeAxiosInstance({ message: `Current time is ${new Date().toISOString()}` }); - // Add request method and headers + // Add request method line timeline.push({ timestamp: new Date(), type: 'request', message: `${config.method.toUpperCase()} ${config.url}` }); - Object.entries(config.headers).forEach(([key, value]) => { - // See https://github.com/usebruno/bruno/issues/1693 - // Axios adds 'Content-Type': 'application/x-www-form-urlencoded for requests with no body - // Bruno sets content-type: false for no body requests so that axios doesn't add the default content-type header - // Hence we skip content-type if it's false - if (key.toLowerCase() === 'content-type' && value === false) { - return; - } - - timeline.push({ - timestamp: new Date(), - type: 'requestHeader', - message: `${key}: ${value}` - }); - }); - // Add request data if available if (config.data) { let requestData = typeof config.data === 'string' ? config.data : JSON.stringify(config.data, null, 2); @@ -170,6 +157,43 @@ function makeAxiosInstance({ config.headers['request-start-time'] = Date.now(); + /** + Apply header deletions requested via req.deleteHeader() in pre-request scripts. + Using set(name, null) rather than delete(): the axios http adapter guards its + own defaults (User-Agent, Accept-Encoding) with set(..., false) which only + skips writing when the key already exists. delete() removes the key entirely, + so the guard misses and the adapter re-adds the default. null keeps the key + present (blocking the guard) while toJSON() omits null values from the wire. + */ + const headersToDelete = config.__headersToDelete; + let deleteConnection = false; + + if (headersToDelete && Array.isArray(headersToDelete)) { + headersToDelete.forEach((headerName) => { + const lower = headerName.toLowerCase(); + if (lower === 'host') return; + if (lower === 'connection') { + // Handled after setupProxyAgents to avoid being overwritten by keepAlive:true. + deleteConnection = true; + return; + } + config.headers.set(headerName, null); + }); + delete config.__headersToDelete; + } + + // Log request headers AFTER deletion so the timeline reflects what is actually sent. + // Skip null values (headers marked for deletion) and false values (e.g. content-type + // suppressed for no-body requests — see https://github.com/usebruno/bruno/issues/1693). + Object.entries(config.headers).forEach(([key, value]) => { + if (value === null || value === false) return; + timeline.push({ + timestamp: new Date(), + type: 'requestHeader', + message: `${key}: ${value}` + }); + }); + const agentOptions = { ...httpsAgentRequestFields, keepAlive: true @@ -195,6 +219,7 @@ function makeAxiosInstance({ message: `Error setting up proxy agents: ${err?.message}` }); } + config.metadata.timeline = timeline; return config; }); diff --git a/packages/bruno-js/src/bruno-request.js b/packages/bruno-js/src/bruno-request.js index 274af3891..b838b7421 100644 --- a/packages/bruno-js/src/bruno-request.js +++ b/packages/bruno-js/src/bruno-request.js @@ -126,10 +126,7 @@ class BrunoRequest { } deleteHeaders(headers) { - headers.forEach((name) => { - delete this.headers[name]; - delete this.req.headers[name]; - }); + headers.forEach((name) => this.deleteHeader(name)); } getHeader(name) { @@ -144,6 +141,18 @@ class BrunoRequest { deleteHeader(name) { delete this.headers[name]; delete this.req.headers[name]; + + /** + Store header name to be applied in the axios request interceptor. + Default headers (user-agent, accept, accept-encoding, etc.) are added after + the pre-request script runs, so we track them here and delete them later. + */ + if (!this.req.__headersToDelete) { + this.req.__headersToDelete = []; + } + if (!this.req.__headersToDelete.includes(name)) { + this.req.__headersToDelete.push(name); + } } hasJSONContentType(headers) { diff --git a/packages/bruno-js/tests/bruno-request-delete-header.spec.js b/packages/bruno-js/tests/bruno-request-delete-header.spec.js new file mode 100644 index 000000000..cb7c65905 --- /dev/null +++ b/packages/bruno-js/tests/bruno-request-delete-header.spec.js @@ -0,0 +1,116 @@ +const { describe, it, expect, beforeEach } = require('@jest/globals'); +const BrunoRequest = require('../src/bruno-request'); + +const makeReq = (overrides = {}) => ({ + url: 'http://localhost:5000/api', + method: 'GET', + headers: { + 'Content-Type': 'application/json', + ...overrides.headers + }, + data: undefined, + ...overrides +}); + +describe('BrunoRequest - header deletion', () => { + describe('deleteHeader()', () => { + it('removes a user-set header from req.headers', () => { + const rawReq = makeReq({ headers: { 'X-Custom': 'value' } }); + const req = new BrunoRequest(rawReq); + + req.deleteHeader('X-Custom'); + + expect(rawReq.headers['X-Custom']).toBeUndefined(); + }); + + it('adds the header name to __headersToDelete on the req object', () => { + const rawReq = makeReq(); + const req = new BrunoRequest(rawReq); + + req.deleteHeader('user-agent'); + + expect(rawReq.__headersToDelete).toEqual(['user-agent']); + }); + + it('tracks multiple deleteHeader calls without duplicates', () => { + const rawReq = makeReq(); + const req = new BrunoRequest(rawReq); + + req.deleteHeader('user-agent'); + req.deleteHeader('accept'); + req.deleteHeader('user-agent'); // duplicate – should not be added again + + expect(rawReq.__headersToDelete).toEqual(['user-agent', 'accept']); + }); + + it('does NOT attach a non-enumerable __headersToDelete to req.headers', () => { + const rawReq = makeReq(); + const req = new BrunoRequest(rawReq); + + req.deleteHeader('accept-encoding'); + + // The non-enumerable approach was removed; __headersToDelete must NOT be on headers + expect(rawReq.headers.__headersToDelete).toBeUndefined(); + // But it must be on the req config object itself + expect(rawReq.__headersToDelete).toContain('accept-encoding'); + }); + }); + + describe('deleteHeaders()', () => { + it('removes multiple user-set headers from req.headers', () => { + const rawReq = makeReq({ headers: { 'X-A': '1', 'X-B': '2', 'X-C': '3' } }); + const req = new BrunoRequest(rawReq); + + req.deleteHeaders(['X-A', 'X-C']); + + expect(rawReq.headers['X-A']).toBeUndefined(); + expect(rawReq.headers['X-C']).toBeUndefined(); + expect(rawReq.headers['X-B']).toBe('2'); + }); + + it('adds all names to __headersToDelete so default headers can be suppressed', () => { + const rawReq = makeReq(); + const req = new BrunoRequest(rawReq); + + req.deleteHeaders(['user-agent', 'accept', 'accept-encoding']); + + expect(rawReq.__headersToDelete).toEqual(['user-agent', 'accept', 'accept-encoding']); + }); + + it('does not duplicate entries when deleteHeaders is called with the same name twice', () => { + const rawReq = makeReq(); + const req = new BrunoRequest(rawReq); + + req.deleteHeaders(['user-agent', 'accept']); + req.deleteHeaders(['user-agent']); // duplicate + + expect(rawReq.__headersToDelete).toEqual(['user-agent', 'accept']); + }); + + it('delegates to deleteHeader so tracking is consistent', () => { + const rawReq = makeReq({ headers: { 'X-Test': 'hello' } }); + const req = new BrunoRequest(rawReq); + + req.deleteHeaders(['X-Test', 'connection']); + + // User-set header removed immediately + expect(rawReq.headers['X-Test']).toBeUndefined(); + // Both tracked for interceptor + expect(rawReq.__headersToDelete).toContain('X-Test'); + expect(rawReq.__headersToDelete).toContain('connection'); + }); + }); + + describe('host header protection', () => { + it('still tracks host in __headersToDelete even though the interceptor will ignore it', () => { + // The protection lives in the axios interceptor, not in BrunoRequest itself. + // BrunoRequest just tracks whatever the user asks to delete. + const rawReq = makeReq(); + const req = new BrunoRequest(rawReq); + + req.deleteHeader('host'); + + expect(rawReq.__headersToDelete).toContain('host'); + }); + }); +}); diff --git a/packages/bruno-requests/src/network/axios-instance.ts b/packages/bruno-requests/src/network/axios-instance.ts index 7e764427f..ba778d9e8 100644 --- a/packages/bruno-requests/src/network/axios-instance.ts +++ b/packages/bruno-requests/src/network/axios-instance.ts @@ -20,6 +20,7 @@ import https from 'node:https'; type ModifiedInternalAxiosRequestConfig = InternalAxiosRequestConfig & { startTime: number; + __headersToDelete?: string[]; }; type ModifiedAxiosResponse = AxiosResponse & { @@ -51,10 +52,28 @@ const makeAxiosInstance = (customRequestConfig?: AxiosRequestConfig) => { customRequestConfig = customRequestConfig || {}; const axiosInstance = axios.create({ ...baseRequestConfig, - ...customRequestConfig + ...customRequestConfig, + headers: {} }); axiosInstance.interceptors.request.use((config: InternalAxiosRequestConfig) => { + // Apply header deletions requested via req.deleteHeader() in pre-request scripts. + const modConfig = config as ModifiedInternalAxiosRequestConfig; + const headersToDelete = modConfig.__headersToDelete; + if (headersToDelete && Array.isArray(headersToDelete)) { + headersToDelete.forEach((headerName: string) => { + const lower = headerName.toLowerCase(); + if (lower === 'host' || lower === 'connection') return; + // Using set(name, null) rather than delete(): the axios http adapter guards its + // own defaults (User-Agent, Accept-Encoding) with set(..., false) which only + // skips writing when the key already exists. delete() removes the key entirely, + // so the guard misses and the adapter re-adds the default. null keeps the key + // present (blocking the guard) while toJSON() omits null values from the wire. + config.headers.set(headerName, null); + }); + delete modConfig.__headersToDelete; + } + const modifiedConfig: ModifiedInternalAxiosRequestConfig = { ...config, startTime: Date.now()