mirror of
https://github.com/usebruno/bruno.git
synced 2026-06-11 09:51:30 +00:00
fix: preserve user-defined boundary in multipart/mixed Content-Type header (#7531)
* fix: preserve user-defined boundary in multipart/mixed Content-Type header When users specify a boundary parameter in their Content-Type header for multipart/mixed requests with TEXT body mode, Bruno now preserves the user-defined boundary instead of generating a new one. Fixes: https://github.com/usebruno/bruno/issues/7523 * updated the test to use local server and changed the request method to GET * fix: handle quoted boundary values in Content-Type header extraction --------- Co-authored-by: Chirag Chandrashekhar <cchirag85@gmail.com>
This commit is contained in:
committed by
GitHub
parent
53b75d083f
commit
bbf3cb8dd3
@@ -26,7 +26,7 @@ const { addDigestInterceptor, getHttpHttpsAgents, makeAxiosInstance: makeAxiosIn
|
||||
const { getCACertificates, transformProxyConfig, getOrCreateHttpsAgent, getOrCreateHttpAgent } = require('@usebruno/requests');
|
||||
const { getOAuth2Token, getFormattedOauth2Credentials } = require('../utils/oauth2');
|
||||
const tokenStore = require('../store/tokenStore');
|
||||
const { encodeUrl, buildFormUrlEncodedPayload, extractPromptVariables, isFormData } = require('@usebruno/common').utils;
|
||||
const { encodeUrl, buildFormUrlEncodedPayload, extractPromptVariables, isFormData, extractBoundaryFromContentType } = require('@usebruno/common').utils;
|
||||
|
||||
const onConsoleLog = (type, args) => {
|
||||
console[type](...args);
|
||||
@@ -570,7 +570,12 @@ const runSingleRequest = async function (
|
||||
if (contentType !== 'multipart/form-data') {
|
||||
// Patch: Axios leverages getHeaders method to get the headers so FormData should be monkey patched
|
||||
const formHeaders = form.getHeaders();
|
||||
formHeaders['content-type'] = `${contentType}; boundary=${form.getBoundary()}`;
|
||||
const existingBoundary = extractBoundaryFromContentType(contentType);
|
||||
if (existingBoundary) {
|
||||
formHeaders['content-type'] = contentType;
|
||||
} else {
|
||||
formHeaders['content-type'] = `${contentType}; boundary=${form.getBoundary()}`;
|
||||
}
|
||||
form.getHeaders = function () {
|
||||
return formHeaders;
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from '@jest/globals';
|
||||
import { buildFormUrlEncodedPayload, isFormData } from './form-data';
|
||||
import { buildFormUrlEncodedPayload, isFormData, extractBoundaryFromContentType } from './form-data';
|
||||
import FormData from 'form-data';
|
||||
|
||||
describe('buildFormUrlEncodedPayload', () => {
|
||||
@@ -161,3 +161,51 @@ describe('isFormData', () => {
|
||||
expect(isFormData(formData)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractBoundaryFromContentType', () => {
|
||||
it('should extract boundary from Content-Type header', () => {
|
||||
expect(extractBoundaryFromContentType('multipart/mixed; boundary=my-boundary')).toBe('my-boundary');
|
||||
});
|
||||
|
||||
it('should extract boundary with dashes', () => {
|
||||
expect(extractBoundaryFromContentType('multipart/mixed; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW')).toBe('----WebKitFormBoundary7MA4YWxkTrZu0gW');
|
||||
});
|
||||
|
||||
it('should extract boundary case-insensitively', () => {
|
||||
expect(extractBoundaryFromContentType('multipart/mixed; BOUNDARY=my-boundary')).toBe('my-boundary');
|
||||
expect(extractBoundaryFromContentType('multipart/mixed; Boundary=my-boundary')).toBe('my-boundary');
|
||||
});
|
||||
|
||||
it('should extract boundary when other params exist', () => {
|
||||
expect(extractBoundaryFromContentType('multipart/mixed; charset=utf-8; boundary=my-boundary')).toBe('my-boundary');
|
||||
expect(extractBoundaryFromContentType('multipart/mixed; boundary=my-boundary; charset=utf-8')).toBe('my-boundary');
|
||||
});
|
||||
|
||||
it('should return null when no boundary exists', () => {
|
||||
expect(extractBoundaryFromContentType('multipart/mixed')).toBeNull();
|
||||
expect(extractBoundaryFromContentType('application/json')).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null for non-string input', () => {
|
||||
expect(extractBoundaryFromContentType(null)).toBeNull();
|
||||
expect(extractBoundaryFromContentType(undefined)).toBeNull();
|
||||
expect(extractBoundaryFromContentType(123)).toBeNull();
|
||||
expect(extractBoundaryFromContentType({})).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle empty string', () => {
|
||||
expect(extractBoundaryFromContentType('')).toBeNull();
|
||||
});
|
||||
|
||||
it('should extract boundary from quoted value', () => {
|
||||
expect(extractBoundaryFromContentType('multipart/mixed; boundary="my-boundary"')).toBe('my-boundary');
|
||||
});
|
||||
|
||||
it('should extract quoted boundary with spaces', () => {
|
||||
expect(extractBoundaryFromContentType('multipart/mixed; boundary="my boundary value"')).toBe('my boundary value');
|
||||
});
|
||||
|
||||
it('should extract quoted boundary when other params exist', () => {
|
||||
expect(extractBoundaryFromContentType('multipart/mixed; charset=utf-8; boundary="my-boundary"')).toBe('my-boundary');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -43,3 +43,16 @@ export const isFormData = (obj: unknown): boolean => {
|
||||
// todo: checking constructor.name can produce false positives for objects that have a constructor.name property set to 'FormData', but this is rare.
|
||||
return obj?.constructor?.name === 'FormData';
|
||||
};
|
||||
|
||||
/**
|
||||
* Extracts boundary parameter from a Content-Type header value.
|
||||
* @param contentType - The Content-Type header value (e.g., "multipart/mixed; boundary=my-boundary")
|
||||
* @returns The boundary value if found, or null if not present
|
||||
*/
|
||||
export const extractBoundaryFromContentType = (contentType: unknown): string | null => {
|
||||
if (typeof contentType !== 'string') {
|
||||
return null;
|
||||
}
|
||||
const match = contentType.match(/boundary="([^"]+)"|boundary=([^;\s]+)/i);
|
||||
return match ? (match[1] || match[2]) : null;
|
||||
};
|
||||
|
||||
@@ -7,7 +7,8 @@ export {
|
||||
|
||||
export {
|
||||
buildFormUrlEncodedPayload,
|
||||
isFormData
|
||||
isFormData,
|
||||
extractBoundaryFromContentType
|
||||
} from './form-data';
|
||||
|
||||
export {
|
||||
|
||||
@@ -35,7 +35,7 @@ const { cookiesStore } = require('../../store/cookies');
|
||||
const registerGrpcEventHandlers = require('./grpc-event-handlers');
|
||||
const { registerWsEventHandlers } = require('./ws-event-handlers');
|
||||
const { getCertsAndProxyConfig, buildCertsAndProxyConfig } = require('./cert-utils');
|
||||
const { buildFormUrlEncodedPayload, isFormData } = require('@usebruno/common').utils;
|
||||
const { buildFormUrlEncodedPayload, isFormData, extractBoundaryFromContentType } = require('@usebruno/common').utils;
|
||||
|
||||
const ERROR_OCCURRED_WHILE_EXECUTING_REQUEST = 'Error occurred while executing the request!';
|
||||
|
||||
@@ -609,7 +609,12 @@ const registerNetworkIpc = (mainWindow) => {
|
||||
if (contentType !== 'multipart/form-data') {
|
||||
// Patch: Axios leverages getHeaders method to get the headers so FormData should be monkey patched
|
||||
const formHeaders = form.getHeaders();
|
||||
formHeaders['content-type'] = `${contentType}; boundary=${form.getBoundary()}`;
|
||||
const existingBoundary = extractBoundaryFromContentType(contentType);
|
||||
if (existingBoundary) {
|
||||
formHeaders['content-type'] = contentType;
|
||||
} else {
|
||||
formHeaders['content-type'] = `${contentType}; boundary=${form.getBoundary()}`;
|
||||
}
|
||||
form.getHeaders = function () {
|
||||
return formHeaders;
|
||||
};
|
||||
|
||||
@@ -5,6 +5,15 @@ router.get('/path/*', (req, res) => {
|
||||
return res.json({ url: req.url });
|
||||
});
|
||||
|
||||
// Echo back request headers - useful for testing header manipulation
|
||||
router.all('/headers', (req, res) => {
|
||||
return res.json({
|
||||
method: req.method,
|
||||
headers: req.headers,
|
||||
url: req.url
|
||||
});
|
||||
});
|
||||
|
||||
router.post('/json', (req, res) => {
|
||||
return res.json(req.body);
|
||||
});
|
||||
|
||||
177
tests/request/multipart-boundary/multipart-boundary.spec.ts
Normal file
177
tests/request/multipart-boundary/multipart-boundary.spec.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
import { test, expect } from '../../../playwright';
|
||||
import {
|
||||
createCollection,
|
||||
createRequest,
|
||||
openRequest,
|
||||
closeAllCollections,
|
||||
selectRequestPaneTab,
|
||||
sendRequest
|
||||
} from '../../utils/page';
|
||||
import { buildCommonLocators, getTableCell } from '../../utils/page/locators';
|
||||
|
||||
const selectAllShortcut = process.platform === 'darwin' ? 'Meta+a' : 'Control+a';
|
||||
|
||||
/**
|
||||
* E2E test for multipart/mixed boundary preservation
|
||||
* Regression test for: https://github.com/usebruno/bruno/issues/7523
|
||||
*
|
||||
* When a user specifies a boundary parameter in their Content-Type header
|
||||
* for multipart/mixed requests with TEXT body mode, Bruno should preserve
|
||||
* the user-defined boundary instead of generating a new one.
|
||||
*/
|
||||
test.describe.serial('Multipart boundary preservation', () => {
|
||||
const collectionName = 'multipart-boundary-test';
|
||||
const requestName = 'Boundary Test';
|
||||
const testServerUrl = 'http://localhost:8081/headers';
|
||||
const customBoundary = 'my-custom-boundary-12345';
|
||||
|
||||
test.beforeAll(async ({ page, createTmpDir }) => {
|
||||
const collectionPath = await createTmpDir('multipart-boundary-collection');
|
||||
await createCollection(page, collectionName, collectionPath);
|
||||
await createRequest(page, requestName, collectionName, {
|
||||
url: testServerUrl,
|
||||
method: 'GET'
|
||||
});
|
||||
});
|
||||
|
||||
test.afterAll(async ({ page }) => {
|
||||
await closeAllCollections(page);
|
||||
});
|
||||
|
||||
test('should preserve user-defined boundary in multipart/mixed Content-Type header', async ({ page }) => {
|
||||
const locators = buildCommonLocators(page);
|
||||
|
||||
await test.step('Open request and configure headers', async () => {
|
||||
await openRequest(page, collectionName, requestName);
|
||||
|
||||
// Go to Headers tab and add Content-Type header with custom boundary
|
||||
await selectRequestPaneTab(page, 'Headers');
|
||||
|
||||
// Find the first row in headers table (empty row for adding new headers)
|
||||
const headerRow = page.locator('table tbody tr').first();
|
||||
await headerRow.waitFor({ state: 'visible' });
|
||||
|
||||
// Get the name cell (first column after checkbox) and enter header name
|
||||
const nameCell = getTableCell(headerRow, 0);
|
||||
await nameCell.locator('.CodeMirror').click();
|
||||
await nameCell.locator('textarea').fill('Content-Type');
|
||||
|
||||
// Get the value cell (second column) and enter header value with custom boundary
|
||||
const valueCell = getTableCell(headerRow, 1);
|
||||
await valueCell.locator('.CodeMirror').click();
|
||||
await valueCell.locator('textarea').fill(`multipart/mixed; boundary=${customBoundary}`);
|
||||
});
|
||||
|
||||
await test.step('Set body to TEXT mode with multipart content', async () => {
|
||||
await selectRequestPaneTab(page, 'Body');
|
||||
|
||||
// Select Text body mode
|
||||
await locators.request.bodyModeSelector().click();
|
||||
await locators.dropdown.item('Text').click();
|
||||
|
||||
// Enter multipart body content using the custom boundary
|
||||
const bodyCodeMirror = locators.request.bodyEditor().locator('.CodeMirror');
|
||||
await bodyCodeMirror.click();
|
||||
await page.keyboard.press(selectAllShortcut);
|
||||
|
||||
const multipartBody = `--${customBoundary}\r
|
||||
Content-Disposition: form-data; name="field1"\r
|
||||
\r
|
||||
value1\r
|
||||
--${customBoundary}--`;
|
||||
|
||||
await page.keyboard.type(multipartBody);
|
||||
});
|
||||
|
||||
await test.step('Send request and verify boundary is preserved', async () => {
|
||||
// Use longer timeout for external service (httpbin.org)
|
||||
await sendRequest(page, 200, 30000);
|
||||
|
||||
// httpbin.org/post returns request headers in the response JSON
|
||||
// We need to verify the Content-Type header contains our custom boundary
|
||||
// and NOT a duplicate auto-generated boundary
|
||||
const responseBody = locators.response.previewContainer();
|
||||
|
||||
// Verify the response contains our custom boundary
|
||||
await expect(responseBody).toContainText(customBoundary, { timeout: 10000 });
|
||||
|
||||
// Verify there's only one boundary parameter (not duplicated)
|
||||
// The response should show: "Content-Type": "multipart/mixed; boundary=my-custom-boundary-12345"
|
||||
const responseText = await responseBody.innerText();
|
||||
|
||||
// Count occurrences of "boundary=" - should be exactly 1 (not duplicated)
|
||||
const boundaryMatches = responseText.match(/boundary=/gi);
|
||||
expect(boundaryMatches).not.toBeNull();
|
||||
expect(boundaryMatches?.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
test('should auto-generate boundary when none is specified in Content-Type header', async ({ page }) => {
|
||||
const locators = buildCommonLocators(page);
|
||||
const requestNameNoBoundary = 'No Boundary Test';
|
||||
|
||||
await test.step('Create a new request without boundary', async () => {
|
||||
await createRequest(page, requestNameNoBoundary, collectionName, {
|
||||
url: testServerUrl,
|
||||
method: 'GET'
|
||||
});
|
||||
});
|
||||
|
||||
await test.step('Open request and configure headers without boundary', async () => {
|
||||
await openRequest(page, collectionName, requestNameNoBoundary);
|
||||
|
||||
// Go to Headers tab and add Content-Type header WITHOUT boundary
|
||||
await selectRequestPaneTab(page, 'Headers');
|
||||
|
||||
const headerRow = page.locator('table tbody tr').first();
|
||||
await headerRow.waitFor({ state: 'visible' });
|
||||
|
||||
const nameCell = getTableCell(headerRow, 0);
|
||||
await nameCell.locator('.CodeMirror').click();
|
||||
await nameCell.locator('textarea').fill('Content-Type');
|
||||
|
||||
// Set Content-Type to multipart/mixed WITHOUT specifying a boundary
|
||||
const valueCell = getTableCell(headerRow, 1);
|
||||
await valueCell.locator('.CodeMirror').click();
|
||||
await valueCell.locator('textarea').fill('multipart/mixed');
|
||||
});
|
||||
|
||||
await test.step('Set body to Multipart Form mode and add a field', async () => {
|
||||
await selectRequestPaneTab(page, 'Body');
|
||||
|
||||
// Select Multipart Form body mode so Bruno has data to create FormData from
|
||||
await locators.request.bodyModeSelector().click();
|
||||
await locators.dropdown.item('Multipart Form').click();
|
||||
|
||||
// Wait for the body editor to switch to multipart form mode
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// The multipart form has an editable table - find and fill the first row
|
||||
// The name column has placeholder "Key" (defined in MultipartFormParams columns)
|
||||
const nameInput = page.locator('[data-testid="editable-table"] input[placeholder="Key"]').first();
|
||||
await nameInput.waitFor({ state: 'visible', timeout: 5000 });
|
||||
await nameInput.click();
|
||||
await nameInput.fill('testField');
|
||||
|
||||
// Tab to value and fill it
|
||||
await page.keyboard.press('Tab');
|
||||
await page.keyboard.type('testValue');
|
||||
});
|
||||
|
||||
await test.step('Send request and verify boundary was auto-generated', async () => {
|
||||
await sendRequest(page, 200, 30000);
|
||||
|
||||
const responseBody = locators.response.previewContainer();
|
||||
const responseText = await responseBody.innerText();
|
||||
|
||||
// Verify that a boundary parameter exists (was auto-generated)
|
||||
const boundaryMatches = responseText.match(/boundary=/gi);
|
||||
expect(boundaryMatches).not.toBeNull();
|
||||
expect(boundaryMatches?.length).toBe(1);
|
||||
|
||||
// Verify the Content-Type contains multipart/mixed with a boundary
|
||||
await expect(responseBody).toContainText('multipart/mixed', { timeout: 5000 });
|
||||
await expect(responseBody).toContainText('boundary=', { timeout: 5000 });
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user