Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
0086ecb
fix(oauth2): prevent code injection in OAuth2 callback handling
abhishekp-bruno Jun 24, 2026
52d82ae
ADD(test-case):oauth2-test-cases-for-state-vulnerability
abhishekp-bruno Jun 24, 2026
c32e046
Merge branch 'main' of https://github.com/abhishekp-bruno/bruno into …
abhishekp-bruno Jun 24, 2026
3ec9505
ADD OAuth test cases
abhishekp-bruno Jun 29, 2026
b76ac9a
ADD IpcError formating for the error message
abhishekp-bruno Jun 29, 2026
9fd37c8
ADD one more unit test case
abhishekp-bruno Jun 29, 2026
87f6469
ADDED one more test for user supplied state
abhishekp-bruno Jun 29, 2026
23b25b4
Merge branch 'main' of https://github.com/abhishekp-bruno/bruno into …
abhishekp-bruno Jun 29, 2026
515d061
Merge branch 'main' of https://github.com/abhishekp-bruno/bruno into …
abhishekp-bruno Jun 29, 2026
d783e82
fix(oauth2): prevent code injection in OAuth2 callback handling
abhishekp-bruno Jun 24, 2026
30fef00
ADD(test-case):oauth2-test-cases-for-state-vulnerability
abhishekp-bruno Jun 24, 2026
4e9c219
ADD OAuth test cases
abhishekp-bruno Jun 29, 2026
f880abc
ADD IpcError formating for the error message
abhishekp-bruno Jun 29, 2026
3887423
ADD one more unit test case
abhishekp-bruno Jun 29, 2026
900616a
ADDED one more test for user supplied state
abhishekp-bruno Jun 29, 2026
01fd1e8
feat(ai): OpenAI-compatible endpoints support (#8365)
naman-bruno Jun 25, 2026
224d38d
refactor(migration): extract migration modal (#8359)
naman-bruno Jun 25, 2026
d9c0d3b
feat(ai): add AI chat sidebar with code block and diff view component…
naman-bruno Jun 25, 2026
c4ad4ba
feat(environments): split variables and secrets into separate tabs (#…
pooja-bruno Jun 26, 2026
fe12f34
fix(workspace-watcher): update IPC channel names for environment file…
sanish-bruno Jun 26, 2026
4ca83e0
chore: update to latest stable lodash version (#8350)
sid-bruno Jun 26, 2026
766bc04
test: workspace import and validation testcase (TC-969) (#8349)
mohit-bruno Jun 26, 2026
89fe3bf
feat(ai): mode toggle UI and add AI support for folder & collection (…
naman-bruno Jun 26, 2026
149aca3
feat(variables): persist scripted variable changes by default + re-en…
sanish-bruno Jun 26, 2026
5aa20f4
Merge branch 'main' of https://github.com/abhishekp-bruno/bruno into …
abhishekp-bruno Jun 29, 2026
a9595b1
Merge branch 'fix/code-injection-vulnerability' of https://github.com…
abhishekp-bruno Jun 29, 2026
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
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
const { shell } = require('electron');
const { registerOauth2AuthorizationRequest, rejectOauth2AuthorizationRequest } = require('../../utils/oauth2-protocol-handler');

const authorizeUserInSystemBrowser = ({ authorizeUrl, callbackUrl, grantType = 'authorization_code' }) => {
const authorizeUserInSystemBrowser = ({ authorizeUrl, callbackUrl, grantType = 'authorization_code', expectedState = null }) => {
return new Promise((resolve, reject) => {
// Replace callback URL in authorization URL
const authorizationUrlObj = new URL(authorizeUrl);
Expand Down Expand Up @@ -51,7 +51,7 @@ const authorizeUserInSystemBrowser = ({ authorizeUrl, callbackUrl, grantType = '
reject(error);
};

registerOauth2AuthorizationRequest(wrappedResolve, wrappedReject, debugInfo);
registerOauth2AuthorizationRequest(wrappedResolve, wrappedReject, debugInfo, expectedState);

// Open system browser
shell.openExternal(modifiedAuthorizeUrl).catch((error) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ const matchesCallbackUrl = (url, callbackUrl) => {
&& (url.searchParams.has('code') || url.hash.length > 1);
};

const authorizeUserInWindow = ({ authorizeUrl, callbackUrl, session, additionalHeaders = {}, grantType = 'authorization_code' }) => {
const authorizeUserInWindow = ({ authorizeUrl, callbackUrl, session, additionalHeaders = {}, grantType = 'authorization_code', expectedState = null }) => {
return new Promise(async (resolve, reject) => {
let finalUrl = null;
let debugInfo = {
Expand Down Expand Up @@ -202,6 +202,22 @@ const authorizeUserInWindow = ({ authorizeUrl, callbackUrl, session, additionalH

if (finalUrl) {
try {
// Validate the state parameter to protect against CSRF / authorization
// code injection. The returned state must match the cryptographically
// random state issued when the flow was initiated.
if (expectedState) {
const finalUrlObj = new URL(finalUrl);
const returnedState
= finalUrlObj.searchParams.get('state')
|| (finalUrlObj.hash ? new URLSearchParams(finalUrlObj.hash.substring(1)).get('state') : null);

if (returnedState !== expectedState) {
return reject(
new Error('OAuth2 state mismatch: the returned state does not match the issued state. Aborting to prevent authorization code injection.')
);
}
}

// Handle different grant types differently
if (grantType === 'implicit') {
// For implicit flow, tokens are in the URL hash fragment
Expand Down
20 changes: 19 additions & 1 deletion packages/bruno-electron/src/utils/oauth2-protocol-handler.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
let oauth2AuthorizationRequest = null;

const registerOauth2AuthorizationRequest = (resolve, reject, debugInfo = null) => {
const registerOauth2AuthorizationRequest = (resolve, reject, debugInfo = null, expectedState = null) => {
// Cancel any existing pending request
if (oauth2AuthorizationRequest) {
oauth2AuthorizationRequest.reject(new Error('Authorization cancelled: new request started'));
Expand All @@ -10,6 +10,7 @@ const registerOauth2AuthorizationRequest = (resolve, reject, debugInfo = null) =
resolve,
reject,
debugInfo,
expectedState,
timestamp: Date.now()
};
};
Expand Down Expand Up @@ -80,6 +81,23 @@ const handleOauth2ProtocolUrl = (url) => {
return;
}

// Validate the state parameter to protect against CSRF / authorization code
// injection. The returned state must match the cryptographically random state
// issued when the flow was initiated.
const expectedState = oauth2AuthorizationRequest?.expectedState;
if (expectedState) {
const returnedState
= urlObj.searchParams.get('state')
|| (urlObj.hash ? new URLSearchParams(urlObj.hash.substring(1)).get('state') : null);

if (returnedState !== expectedState) {
rejectOauth2AuthorizationRequest(
new Error('OAuth2 state mismatch: the returned state does not match the issued state. Aborting to prevent authorization code injection.')
);
return;
}
}

// Check if this is an implicit grant (tokens in hash fragment)
if (urlObj.hash) {
const hash = urlObj.hash.substring(1); // Remove the leading #
Expand Down
27 changes: 23 additions & 4 deletions packages/bruno-electron/src/utils/oauth2.js
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,10 @@ const getOAuth2AuthorizationCode = (request, codeChallenge, collectionUid) => {
const { callbackUrl, clientId, authorizationUrl, scope, state, pkce, accessTokenUrl, additionalParameters } = oauth2;
const useSystemBrowser = preferencesUtil.shouldUseSystemBrowser();
const effectiveCallbackUrl = callbackUrl && callbackUrl.length ? callbackUrl : BRUNO_OAUTH2_CALLBACK_URL;
// Always append a cryptographically random nonce to the user-configured state
// (or generate a fully random one when none is set). The state is validated when
// the callback is received to prevent authorization code injection / CSRF.
const effectiveState = generateState({ userState: state });

const authorizationUrlWithQueryParams = new URL(authorizationUrl);
authorizationUrlWithQueryParams.searchParams.append('response_type', 'code');
Expand All @@ -324,8 +328,8 @@ const getOAuth2AuthorizationCode = (request, codeChallenge, collectionUid) => {
authorizationUrlWithQueryParams.searchParams.append('code_challenge', codeChallenge);
authorizationUrlWithQueryParams.searchParams.append('code_challenge_method', 'S256');
}
if (state) {
authorizationUrlWithQueryParams.searchParams.append('state', state);
if (effectiveState) {
authorizationUrlWithQueryParams.searchParams.append('state', effectiveState);
}
if (additionalParameters?.authorization?.length) {
additionalParameters.authorization.forEach((param) => {
Expand All @@ -344,6 +348,7 @@ const getOAuth2AuthorizationCode = (request, codeChallenge, collectionUid) => {
authorizeUrl,
callbackUrl: effectiveCallbackUrl,
session: oauth2Store.getSessionIdOfCollection({ collectionUid, url: accessTokenUrl }),
expectedState: effectiveState,
additionalHeaders: getAdditionalHeaders(additionalParameters?.authorization)
});
resolve({ authorizationCode, debugInfo });
Expand Down Expand Up @@ -707,6 +712,16 @@ const generateCodeVerifier = () => {
return crypto.randomBytes(22).toString('hex');
};

// Generate a cryptographically random state value used to protect OAuth2
// authorization flows against CSRF / authorization code injection.
const generateState = ({ userState }) => {
let cryptographicallyRandomString = crypto.randomBytes(16).toString('hex');
if (userState && userState.length) {
return userState + cryptographicallyRandomString;
}
return cryptographicallyRandomString;
};

const generateCodeChallenge = (codeVerifier) => {
const hash = crypto.createHash('sha256');
hash.update(codeVerifier);
Expand Down Expand Up @@ -760,6 +775,9 @@ const getOAuth2TokenUsingImplicitGrant = async ({ request, collectionUid, forceF
} = oauth2;
const useSystemBrowser = preferencesUtil.shouldUseSystemBrowser();
const effectiveCallbackUrl = callbackUrl && callbackUrl.length ? callbackUrl : BRUNO_OAUTH2_CALLBACK_URL;
// Use the user-configured state if present, otherwise generate a cryptographically
// random one. The state is validated when the callback is received to prevent CSRF.
const effectiveState = generateState({ userState: state });

// Validate required fields
if (!authorizationUrl) {
Expand Down Expand Up @@ -844,8 +862,8 @@ const getOAuth2TokenUsingImplicitGrant = async ({ request, collectionUid, forceF
if (scope) {
authorizationUrlWithQueryParams.searchParams.append('scope', scope);
}
if (state) {
authorizationUrlWithQueryParams.searchParams.append('state', state);
if (effectiveState) {
authorizationUrlWithQueryParams.searchParams.append('state', effectiveState);
}
if (additionalParameters?.authorization?.length) {
additionalParameters.authorization.forEach((param) => {
Expand All @@ -866,6 +884,7 @@ const getOAuth2TokenUsingImplicitGrant = async ({ request, collectionUid, forceF
callbackUrl: effectiveCallbackUrl,
session: oauth2Store.getSessionIdOfCollection({ collectionUid, url: authorizationUrl }),
grantType: 'implicit',
expectedState: effectiveState,
additionalHeaders: getAdditionalHeaders(additionalParameters?.authorization)
});

Expand Down
Loading