Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/bruno-electron/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ const menuTemplate = require('./app/menu-template');
const { openCollection } = require('./app/collections');
const registerNetworkIpc = require('./ipc/network');
const registerCollectionsIpc = require('./ipc/collection');
const registerCollectionScriptsIpc = require('./ipc/scripts');
const registerFilesystemIpc = require('./ipc/filesystem');
const registerPreferencesIpc = require('./ipc/preferences');
const registerSystemMonitorIpc = require('./ipc/system-monitor');
Expand Down Expand Up @@ -461,6 +462,7 @@ app.on('ready', async () => {
registerNetworkIpc(mainWindow);
registerGlobalEnvironmentsIpc(mainWindow, globalEnvironmentsManager);
registerCollectionsIpc(mainWindow, collectionWatcher);
registerCollectionScriptsIpc(mainWindow);
registerPreferencesIpc(mainWindow, collectionWatcher);
registerWorkspaceIpc(mainWindow, workspaceWatcher);
registerApiSpecIpc(mainWindow, apiSpecWatcher);
Expand Down
50 changes: 50 additions & 0 deletions packages/bruno-electron/src/ipc/network/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ const { createFormData } = require('../../utils/form-data');
const { findItemInCollectionByPathname, sortFolder, getAllRequestsInFolderRecursively, getEnvVars, getTreePathFromCollectionToItem, mergeVars, sortByNameThenSequence } = require('../../utils/collection');
const { getOAuth2TokenUsingAuthorizationCode, getOAuth2TokenUsingClientCredentials, getOAuth2TokenUsingPasswordCredentials, getOAuth2TokenUsingImplicitGrant, updateCollectionOauth2Credentials, clearOauth2CredentialsByCredentialsId } = require('../../utils/oauth2');
const { preferencesUtil } = require('../../store/preferences');
const CollectionSecurityStore = require('../../store/collection-security');
const { isScriptPathSafe, parseEnvVarsFromOutput, runShellScript } = require('../../utils/collection-scripts');

const collectionSecurityStore = new CollectionSecurityStore();
const { getProcessEnvVars } = require('../../store/process-env');
const { getBrunoConfig } = require('../../store/bruno-config');
const Oauth2Store = require('../../store/oauth2');
Expand Down Expand Up @@ -1324,6 +1328,52 @@ const registerNetworkIpc = (mainWindow) => {
cancelTokenUid
});

// Run beforeCollectionRun scripts before the request loop begins.
// Results are merged into envVars so the runner picks them up immediately.
const securityConfig = collectionSecurityStore.getSecurityConfigForCollection(collectionPath);
if (securityConfig?.jsSandboxMode === 'developer') {
const beforeRunScripts = get(brunoConfig, 'collectionScripts', []).filter(
(s) => s.runOn?.includes('beforeCollectionRun')
);
for (const script of beforeRunScripts) {
if (!isScriptPathSafe(collectionPath, script.file)) continue;
try {
const { exitCode, stdout, stderr } = await runShellScript(collectionPath, script.file);
if (exitCode !== 0) {
const stderrTail = (stderr || '').slice(-500);
console.error(
`Collection script '${script.name}' exited with code ${exitCode}.${stderrTail ? ` stderr: ${stderrTail}` : ''}`
);
mainWindow.webContents.send('main:collection-script-output', {
collectionUid,
scriptUid: script.uid,
stream: 'stderr',
data: `Script '${script.name}' exited with code ${exitCode}.\n${stderrTail}`
});
continue;
}
if (script.outputMode === 'envVars') {
const parsed = parseEnvVarsFromOutput(stdout);
Object.assign(envVars, parsed);
mainWindow.webContents.send('main:script-environment-update', {
envVariables: parsed, runtimeVariables: {}, persistentEnvVariables: parsed, collectionUid
});
mainWindow.webContents.send('main:persistent-env-variables-update', {
persistentEnvVariables: parsed, collectionUid
});
}
} catch (err) {
console.error(`Collection script '${script.name}' failed:`, err.message);
mainWindow.webContents.send('main:collection-script-output', {
collectionUid,
scriptUid: script.uid,
stream: 'stderr',
data: `Script '${script.name}' failed: ${err.message}`
});
}
}
}

try {
let folderRequests = [];

Expand Down
62 changes: 62 additions & 0 deletions packages/bruno-electron/src/ipc/scripts.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
const { ipcMain } = require('electron');
const CollectionSecurityStore = require('../store/collection-security');
const { isScriptPathSafe, parseEnvVarsFromOutput, runShellScript } = require('../utils/collection-scripts');

const collectionSecurityStore = new CollectionSecurityStore();

const registerCollectionScriptsIpc = (mainWindow) => {
ipcMain.handle('renderer:run-collection-script', async (event, payload) => {
const { collectionUid, collectionPath, script } = payload || {};

if (typeof collectionPath !== 'string' || !collectionPath.length) {
return { error: 'Invalid request: collection path is required.' };
}
if (!script || typeof script !== 'object') {
return { error: 'Invalid request: script is required.' };
}
if (typeof script.file !== 'string' || !script.file.length) {
return { error: 'Invalid request: script.file is required.' };
}

const securityConfig = collectionSecurityStore.getSecurityConfigForCollection(collectionPath);
if (securityConfig?.jsSandboxMode !== 'developer') {
return { error: 'Collection scripts require Developer Mode. Enable it in Collection Settings > Security.' };
}

if (!isScriptPathSafe(collectionPath, script.file)) {
return { error: 'Script path must be within the collection directory.' };
}

const sendChunk = (data, stream) => {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('main:collection-script-output', {
collectionUid, scriptUid: script.uid, data, stream
});
}
};

try {
const { exitCode, stdout } = await runShellScript(collectionPath, script.file, {
onStdout: (chunk) => sendChunk(chunk, 'stdout'),
onStderr: (chunk) => sendChunk(chunk, 'stderr')
});

if (script.outputMode === 'envVars' && exitCode === 0 && mainWindow && !mainWindow.isDestroyed()) {
const parsed = parseEnvVarsFromOutput(stdout);
mainWindow.webContents.send('main:script-environment-update', {
envVariables: parsed, runtimeVariables: {}, persistentEnvVariables: parsed, collectionUid
});
mainWindow.webContents.send('main:persistent-env-variables-update', {
persistentEnvVariables: parsed, collectionUid
});
}

return { exitCode };
} catch (err) {
sendChunk(`Error: ${err.message}`, 'stderr');
return { error: err.message, exitCode: 1 };
}
});
};

module.exports = registerCollectionScriptsIpc;
110 changes: 110 additions & 0 deletions packages/bruno-electron/src/utils/collection-scripts.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
const fs = require('fs');
const path = require('path');
const { spawn } = require('child_process');

const DEFAULT_MAX_BUFFER = 1024 * 1024; // 1 MiB per stream
const TRUNCATION_MARKER = '\n[bruno: output truncated — exceeded maxBuffer]\n';

/**
* Returns true only if scriptFile resolves — through symlinks — to a path
* inside collectionPath. Uses fs.realpathSync so a symlink inside the
* collection that points outside it is rejected. Returns false if either
* path cannot be realpath-resolved (e.g. script does not exist), so the
* caller never executes a non-existent or unreadable target.
*/
function isScriptPathSafe(collectionPath, scriptFile) {
const lexicalCollection = path.resolve(collectionPath);
const lexicalScript = path.resolve(collectionPath, scriptFile);

let realCollection;
try {
realCollection = fs.realpathSync(lexicalCollection);
} catch {
// Collection directory does not exist on disk: fall back to lexical-only
// comparison. This branch exists for unit tests that use fictional paths;
// the IPC handler should never call this for a missing collection.
return lexicalScript === lexicalCollection || lexicalScript.startsWith(lexicalCollection + path.sep);
}

let realScript;
try {
realScript = fs.realpathSync(lexicalScript);
} catch {
// Script doesn't exist (or unreadable). Reject — never spawn missing files.
return false;
}

return realScript === realCollection || realScript.startsWith(realCollection + path.sep);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

function parseEnvVarsFromOutput(output) {
const vars = {};
for (const line of output.split('\n')) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const match = trimmed.match(/^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
if (match) {
vars[match[1]] = match[2];
}
}
return vars;
}
Comment on lines +40 to +51

/**
* On Windows, spawning a script path directly cannot interpret shebangs or
* resolve file associations (.cmd/.bat/.ps1/.js); shell:true delegates to
* cmd.exe which does. On POSIX, the kernel honours the shebang itself.
*/
function buildSpawnArgs(resolvedScript, cwd, platform = process.platform) {
const isWindows = platform === 'win32';
return {
command: resolvedScript,
args: [],
options: {
cwd,
env: process.env,
...(isWindows ? { shell: true, windowsHide: true } : {})
}
};
}

function runShellScript(collectionPath, scriptFile, { onStdout, onStderr, maxBuffer = DEFAULT_MAX_BUFFER } = {}) {
return new Promise((resolve, reject) => {
const resolvedScript = path.resolve(collectionPath, scriptFile);
const { command, args, options } = buildSpawnArgs(resolvedScript, collectionPath);
const proc = spawn(command, args, options);

let stdout = '';
let stderr = '';
let truncated = false;

const append = (current, chunk) => {
if (truncated) return current;
const remaining = maxBuffer - current.length;
if (chunk.length <= remaining) return current + chunk;
truncated = true;
return current + chunk.slice(0, Math.max(0, remaining)) + TRUNCATION_MARKER;
};

proc.stdout.on('data', (d) => {
const chunk = d.toString();
stdout = append(stdout, chunk);
onStdout?.(chunk);
});
proc.stderr.on('data', (d) => {
const chunk = d.toString();
stderr = append(stderr, chunk);
onStderr?.(chunk);
});
proc.on('close', (exitCode) => resolve({ exitCode, stdout, stderr, truncated }));
proc.on('error', (err) => {
if (err.code === 'EACCES') {
reject(new Error(`Script is not executable. Run: chmod +x ${scriptFile}`));
} else {
reject(err);
}
});
});
}

module.exports = { isScriptPathSafe, parseEnvVarsFromOutput, runShellScript, buildSpawnArgs };
103 changes: 103 additions & 0 deletions packages/bruno-electron/tests/ipc/scripts.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
// Mocks must be declared before requiring the module under test.
const mockHandle = jest.fn();
jest.mock('electron', () => ({
ipcMain: { handle: (...args) => mockHandle(...args) }
}));

const mockGetSecurityConfig = jest.fn();
jest.mock('../../src/store/collection-security', () =>
jest.fn().mockImplementation(() => ({
getSecurityConfigForCollection: (...args) => mockGetSecurityConfig(...args)
}))
);

const mockRunShellScript = jest.fn();
jest.mock('../../src/utils/collection-scripts', () => ({
isScriptPathSafe: jest.fn(() => true),
parseEnvVarsFromOutput: jest.fn(() => ({})),
runShellScript: (...args) => mockRunShellScript(...args)
}));

const registerCollectionScriptsIpc = require('../../src/ipc/scripts');

describe('renderer:run-collection-script — payload validation', () => {
let handler;
let mainWindow;

beforeEach(() => {
jest.clearAllMocks();
mainWindow = {
isDestroyed: () => false,
webContents: { send: jest.fn() }
};
registerCollectionScriptsIpc(mainWindow);
// The handler is the second arg to the most recent ipcMain.handle call.
const call = mockHandle.mock.calls.find((c) => c[0] === 'renderer:run-collection-script');
handler = call[1];
mockGetSecurityConfig.mockReturnValue({ jsSandboxMode: 'developer' });
});

test('rejects when collectionPath is missing', async () => {
const result = await handler({}, { collectionUid: 'c1', script: { uid: 's', file: 'run.sh' } });
expect(result.error).toMatch(/collection path/i);
expect(mockRunShellScript).not.toHaveBeenCalled();
});

test('rejects when collectionPath is not a string', async () => {
const result = await handler({}, {
collectionUid: 'c1',
collectionPath: 123,
script: { uid: 's', file: 'run.sh' }
});
expect(result.error).toMatch(/collection path/i);
expect(mockRunShellScript).not.toHaveBeenCalled();
});

test('rejects when script is missing', async () => {
const result = await handler({}, { collectionUid: 'c1', collectionPath: '/c' });
expect(result.error).toMatch(/script/i);
expect(mockRunShellScript).not.toHaveBeenCalled();
});

test('rejects when script.file is missing', async () => {
const result = await handler({}, {
collectionUid: 'c1',
collectionPath: '/c',
script: { uid: 's' }
});
expect(result.error).toMatch(/script\.file|script file/i);
expect(mockRunShellScript).not.toHaveBeenCalled();
});

test('rejects when script.file is not a string', async () => {
const result = await handler({}, {
collectionUid: 'c1',
collectionPath: '/c',
script: { uid: 's', file: 42 }
});
expect(result.error).toMatch(/script\.file|script file/i);
expect(mockRunShellScript).not.toHaveBeenCalled();
});

test('proceeds with a fully-formed payload', async () => {
mockRunShellScript.mockResolvedValue({ exitCode: 0, stdout: '', stderr: '' });
const result = await handler({}, {
collectionUid: 'c1',
collectionPath: '/c',
script: { uid: 's', file: 'run.sh' }
});
expect(result.exitCode).toBe(0);
expect(mockRunShellScript).toHaveBeenCalledTimes(1);
});

test('blocks execution when sandbox mode is not developer', async () => {
mockGetSecurityConfig.mockReturnValue({ jsSandboxMode: 'safe' });
const result = await handler({}, {
collectionUid: 'c1',
collectionPath: '/c',
script: { uid: 's', file: 'run.sh' }
});
expect(result.error).toMatch(/developer mode/i);
expect(mockRunShellScript).not.toHaveBeenCalled();
});
});
Loading