Skip to content
Closed
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
16 changes: 11 additions & 5 deletions runtime/src/js/pgadmin.js
Original file line number Diff line number Diff line change
Expand Up @@ -247,12 +247,18 @@ function startDesktopMode() {
misc.writeServerLog('pgAdmin Command: "' + command + '"');
misc.writeServerLog('Environment: ');
Object.keys(process.env).forEach(function (key) {
// Below code is included only for Mac OS as default path for azure CLI
// installation path is not included in PATH variable while spawning
// runtime environment.
// On macOS, apps launched from the Finder or Dock get a minimal PATH that
// does not include the directories Homebrew installs into (/usr/local/bin
// on Intel, /opt/homebrew/bin on Apple Silicon), so tools such as the
// Azure CLI would not be found.
if (process.platform === 'darwin' && key === 'PATH') {
let updated_path = process.env[key] + ':/usr/local/bin';
process.env[key] = updated_path;
let pathDirs = process.env[key].split(':');
['/usr/local/bin', '/opt/homebrew/bin'].forEach((dir) => {
if (!pathDirs.includes(dir)) {
pathDirs.push(dir);
}
});
process.env[key] = pathDirs.join(':');
}

if (process.platform === 'win32' && key.toUpperCase() === 'PATH') {
Expand Down
3 changes: 3 additions & 0 deletions web/pgadmin/misc/cloud/azure/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,9 @@ def verify_credentials():
if cached_state is None or auth_type_changed or tenant_changed:
# Drop any stale state — these creds don't match the cached ones.
session['azure'].pop('state', None)
# Also drop the device code from any earlier attempt, so the wizard
# does not show it in place of the one for this attempt.
session['azure'].pop('azure_auth_code', None)
azure = Azure(
interactive_browser_credential=interactive_browser_credential,
tenant_id=tenant_id,
Expand Down
69 changes: 69 additions & 0 deletions web/pgadmin/misc/cloud/azure/tests/test_azure_stale_auth_code.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
##########################################################################
#
# pgAdmin 4 - PostgreSQL Tools
#
# Copyright (C) 2013 - 2026, The pgAdmin Development Team
# This software is released under the PostgreSQL Licence
#
##########################################################################

"""
Check that starting a new Azure authentication discards the device code
from an earlier attempt (#9921), so the wizard does not poll it back and
show the user a code that is no longer valid.
"""

import json
import unittest
from unittest.mock import MagicMock, patch

from flask import Flask, session

from pgadmin.utils.route import BaseTestGenerator


class TestAzureVerifyCredentialsDropsStaleAuthCode(BaseTestGenerator):
"""A new authentication attempt removes the previous device code."""

scenarios = [('default', dict())]

def setUp(self):
# Pure logic test; skip BaseTestGenerator's server connection.
unittest.TestCase.setUp(self)

def runTest(self):
import pgadmin.misc.cloud.azure as azure_mod

seen_codes = []
instance = MagicMock()
instance.to_state.return_value = {'tenant_id': 'new-tenant'}

def _validate():
# Record what get_azure_verification_codes() would return
# whilst this attempt is waiting for the user.
seen_codes.append(session['azure'].get('azure_auth_code'))
return True, ''

instance.validate_azure_credentials.side_effect = _validate
azure_cls = MagicMock(return_value=instance)

app = Flask(__name__)
app.secret_key = 'test'
data = {'secret': {'auth_type': 'interactive_browser_credential',
'azure_tenant_id': 'new-tenant'}}
with app.test_request_context(
'/azure/verify_credentials/', method='POST',
data=json.dumps(data), content_type='application/json'), \
patch.object(azure_mod, 'Azure', azure_cls):
session['azure'] = {
'auth_type': 'interactive_browser_credential',
'azure_tenant_id': 'old-tenant',
'state': {'tenant_id': 'old-tenant'},
'azure_auth_code': {'user_code': 'OLDCODE1'},
}

azure_mod.verify_credentials.__wrapped__()

self.assertEqual(seen_codes, [None])
self.assertEqual(session['azure']['azure_tenant_id'],
'new-tenant')
20 changes: 14 additions & 6 deletions web/pgadmin/misc/cloud/static/js/azure.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,20 +25,25 @@ export function AzureCredentials(props) {

let _eventBus = React.useContext(CloudWizardEventsContext);
React.useMemo(() => {
// Polls for the device code whilst an interactive authentication is in
// progress, and is stopped once that authentication has finished.
let authCodeInterval = null;
const stopAuthCodePolling = () => {
clearInterval(authCodeInterval);
authCodeInterval = null;
};
const azureCloudDBCredSchema = new AzureCredSchema(_eventBus, {
authenticateAzure:(auth_type, azure_tenant_id) => {
let loading_icon_url = url_for(
'static', { 'filename': 'img/loading.gif'}
);
const axiosApi = getApiInstance();
_eventBus.fireEvent('SET_ERROR_MESSAGE_FOR_CLOUD_WIZARD', [MESSAGE_TYPE.INFO, 'Microsoft Azure authentication process is in progress. <img src="' + loading_icon_url + '" alt="' + gettext('Loading...') + '">']);
_eventBus.fireEvent('SET_ERROR_MESSAGE_FOR_CLOUD_WIZARD', [MESSAGE_TYPE.INFO, gettext('Microsoft Azure authentication process is in progress. Please wait...')]);
let _url = url_for('azure.verify_credentials');
const post_data = {
cloud: 'azure',
secret: {'auth_type':auth_type, 'azure_tenant_id':azure_tenant_id}
};
return new Promise((resolve, reject)=>{axiosApi.post(_url, post_data)
.then((res) => {
stopAuthCodePolling();
if (res.data && res.data.success == 1 ) {
_eventBus.fireEvent('SET_ERROR_MESSAGE_FOR_CLOUD_WIZARD',[MESSAGE_TYPE.SUCCESS, gettext('Authentication completed successfully. Click the Next button to proceed.')]);
_eventBus.fireEvent('SET_CRED_VERIFICATION_INITIATED',true);
Expand All @@ -51,6 +56,7 @@ export function AzureCredentials(props) {
}
})
.catch((error) => {
stopAuthCodePolling();
_eventBus.fireEvent('SET_ERROR_MESSAGE_FOR_CLOUD_WIZARD',[MESSAGE_TYPE.ERROR, gettext(`Error while verifying Microsoft Azure: ${error}`)]);
reject(new Error(gettext(error)));
});
Expand All @@ -59,12 +65,13 @@ export function AzureCredentials(props) {
getAuthCode:()=>{
let _url_get_azure_verification_codes = url_for('azure.get_azure_verification_codes');
const axiosApi = getApiInstance();
stopAuthCodePolling();
return new Promise((resolve, reject)=>{
const interval = setInterval(()=>{
axiosApi.get(_url_get_azure_verification_codes)
.then((res)=>{
if (res.data.success){
clearInterval(interval);
if (res.data.success && authCodeInterval === interval){
stopAuthCodePolling();
let params = 'scrollbars=no,resizable=no,status=no,location=no,toolbar=no,menubar=no, width=550,height=650,left=920,top=150';
window.open(res.data.data.verification_uri, 'azure_authentication', params);
resolve(res);
Expand All @@ -75,6 +82,7 @@ export function AzureCredentials(props) {
reject(error instanceof Error ? error : Error(gettext('Something went wrong')));
});
}, 1000);
authCodeInterval = interval;
});
}
}, {});
Expand Down
74 changes: 46 additions & 28 deletions web/pgadmin/misc/cloud/static/js/azure_schema.ui.js
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,14 @@ class AzureCredSchema extends BaseUISchema {
value: 'azure_cli_credential',
},
],
disabled: pgAdmin.server_mode == 'True',
// Neither credential may change whilst an attempt is pending, as
// the server would still complete it for the old values and its
// late success would then be taken as authentication for the new
// ones. The server cannot cancel an attempt that is waiting for the
// user, so locking the fields is simpler than ignoring its result.
disabled: (state) => {
return pgAdmin.server_mode == 'True' || state.is_authenticating;
},
helpMessage: gettext(
'Azure CLI will use the currently logged in identity through the Azure CLI on the local machine. Interactive Browser will open a browser window to authenticate a user interactively.'
),
Expand All @@ -82,14 +89,19 @@ class AzureCredSchema extends BaseUISchema {
'Enter the Azure tenant ID against which the user is authenticated.'
),
disabled: (state) => {
return state.auth_type !== 'interactive_browser_credential';
return state.auth_type !== 'interactive_browser_credential' ||
state.is_authenticating;
},
depChange: (state) => {
if (state.auth_type == 'azure_cli_credential') {
state.azure_tenant_id = '';
}
this.eventBus.fireEvent('SET_CRED_VERIFICATION_INITIATED', false);
this.eventBus.fireEvent('SET_ERROR_MESSAGE_FOR_CLOUD_WIZARD',['', '']);
// This runs when the tenant id itself changes as well as when
// auth_type does; changed credentials need to be authenticated
// again.
return {is_authenticated: false, auth_code: ''};
},
},
{
Expand All @@ -106,27 +118,52 @@ class AzureCredSchema extends BaseUISchema {
if (!schemaState) return;

const state = schemaState.data;
// Go through the schema state so that the fields that depend on
// these values (e.g. the device code note) are re-evaluated.
const setSchemaData = (data) => {
schemaState.data = {...schemaState.data, ...data};
Object.entries(data).forEach(([key, value]) => {
schemaState.setUnpreparedData([key], value);
});
};

const logError = (err) => {
console.error(
err instanceof Error ?
err : Error(gettext('Something went wrong'))
);
};

setTimeout(() => {
setSchemaData({is_authenticating: true});

obj.fieldOptions.authenticateAzure(
state.auth_type, state.azure_tenant_id
).then(() => {
).then((authenticated) => {
// Leave the button enabled after a failure so the user can
// try again.
setSchemaData({
is_authenticated: true,
is_authenticated: Boolean(authenticated),
is_authenticating: false,
auth_code: ''
});
}).catch((err) => {
console.error(
err instanceof Error ?
err : Error(gettext('Something went wrong'))
);
setSchemaData({is_authenticating: false, auth_code: ''});
logError(err);
});

Comment thread
coderabbitai[bot] marked this conversation as resolved.
// The interactive (device code) flow only completes once the
// user has entered the code from the server, so fetch it and
// show it whilst the request above is still waiting. The button
// stays disabled until that request settles (its handlers reset
// is_authenticating), as a second click would start a second
// attempt on the server whose result could be overwritten by the
// late settlement of the first.
if (state.auth_type == 'interactive_browser_credential') {
obj.fieldOptions.getAuthCode()
.then((res) => {
setSchemaData({auth_code: res.data.data.user_code});
}).catch(logError);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}, 0);
},
disabled: (state)=> {
Expand All @@ -140,25 +177,6 @@ class AzureCredSchema extends BaseUISchema {
id: 'is_authenticating',
visible: false,
type: '',
deps:['auth_btn'],
deferredDepChange: (state, source)=>{
return new Promise((resolve, reject)=>{
if(source == 'auth_btn' && state.auth_type == 'interactive_browser_credential' && state.is_authenticating ) {
obj.fieldOptions.getAuthCode()
.then((res)=>{
resolve(()=>{
return {
is_authenticating: false,
auth_code: res.data.data.user_code,
};
});
})
.catch((err)=>{
reject(err instanceof Error ? err : Error(gettext('Something went wrong')));
});
}
});
},
},
{
id: 'auth_code',
Expand Down
5 changes: 1 addition & 4 deletions web/pgadmin/misc/cloud/static/js/google.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,8 @@ export function GoogleCredentials(props) {
React.useMemo(() => {
const googleCredSchema = new GoogleCredSchema({
authenticateGoogle:(client_secret_file) => {
let loading_icon_url = url_for(
'static', { 'filename': 'img/loading.gif'}
);
const axiosApi = getApiInstance();
_eventBus.fireEvent('SET_ERROR_MESSAGE_FOR_CLOUD_WIZARD', [MESSAGE_TYPE.INFO, 'Google authentication process is in progress. <img src="' + loading_icon_url + '" alt="' + gettext('Loading...') + '">']);
_eventBus.fireEvent('SET_ERROR_MESSAGE_FOR_CLOUD_WIZARD', [MESSAGE_TYPE.INFO, gettext('Google authentication process is in progress. Please wait...')]);
let _url = url_for('google.verify_credentials');
const post_data = {
cloud: 'google',
Expand Down
2 changes: 1 addition & 1 deletion web/pgadmin/misc/cloud/static/js/google_schema.ui.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ class GoogleCredSchema extends BaseUISchema{
id: 'client_secret_file',
label: gettext('Client secret file'),
type: 'file',
helpMessage: gettext('Select a client secrets file containing the client ID, client secret, and other OAuth 2.0 parameters for google authentication. Refer <a href="https://support.google.com/cloud/answer/6158849?hl=en#userconsent&zippy=%2Cuser-consent%2Cpublic-and-internal-applications" target="_blank">link</a> for creating client secret.'),
helpMessage: gettext('Select a client secrets file containing the client ID, client secret, and other OAuth 2.0 parameters for Google authentication. See <a href="https://support.google.com/cloud/answer/15549257?hl=en">Manage OAuth Clients</a> in the Google Cloud documentation for how to create one.'),
controlProps: {
dialogType: 'select_file',
supportedTypes: ['json'],
Expand Down
23 changes: 20 additions & 3 deletions web/pgadmin/static/js/components/FormComponents.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,23 @@ const StyledGrid = styled(Grid)(({theme}) => ({
},
}));

/*
* Render the HTML in help messages and notes. DOMPurify drops the target
* attribute, so a link would otherwise navigate the pgAdmin page itself
* away (which, in the desktop app, prompts to quit). Open links in a new
* window instead.
*/
function parseHelpHTML(html) {
return HTMLReactParse(DOMPurify.sanitize(html || ''), {
replace: (node) => {
if (node.type === 'tag' && node.name === 'a' && node.attribs?.href) {
node.attribs.target = '_blank';
node.attribs.rel = 'noopener noreferrer';
}
},
});
}

/* Wrapper on any form component to add label, error indicator and help message */
export function FormInput({ children, error, className, label, helpMessage, required, testcid, lid, withContainer=true, labelGridBasis=3, controlGridBasis=9, labelTooltip='', errorMessage='' }) {

Expand All @@ -140,7 +157,7 @@ export function FormInput({ children, error, className, label, helpMessage, requ
<FormControl error={Boolean(error)} fullWidth>
{React.cloneElement(children, { cid, helpid })}
</FormControl>
<FormHelperText id={helpid} variant="outlined">{HTMLReactParse(DOMPurify.sanitize(helpMessage || ''))}</FormHelperText>
<FormHelperText id={helpid} variant="outlined">{parseHelpHTML(helpMessage)}</FormHelperText>
</StyledGrid>
</>)
);
Expand All @@ -164,7 +181,7 @@ export function FormInput({ children, error, className, label, helpMessage, requ
<FormControl error={Boolean(error)} fullWidth>
{React.cloneElement(children, { cid, helpid })}
</FormControl>
<FormHelperText id={helpid} variant="outlined">{HTMLReactParse(DOMPurify.sanitize(helpMessage || ''))}</FormHelperText>
<FormHelperText id={helpid} variant="outlined">{parseHelpHTML(helpMessage)}</FormHelperText>
{errorMessage && <FormHelperText variant="outlined" error={true} style={{ wordBreak: 'break-word', overflowWrap: 'anywhere' }}><SafeMessage text={errorMessage} /></FormHelperText> }
</Grid>
</StyledGrid>
Expand Down Expand Up @@ -1204,7 +1221,7 @@ export function FormNote({ text, className, controlProps }) {
<Box className={className}>
<Paper elevation={0} className={controlProps?.raw ? '' : 'Form-noteRoot'}>
{!controlProps?.raw && <Box paddingRight="0.25rem"><DescriptionIcon fontSize="small" /></Box>}
<Box>{HTMLReactParse(DOMPurify.sanitize(text || ''))}</Box>
<Box>{parseHelpHTML(text)}</Box>
</Paper>
</Box>
</Root>
Expand Down
23 changes: 23 additions & 0 deletions web/regression/javascript/components/FormComponents.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -738,6 +738,29 @@ describe('FormComponents', ()=>{
expect(ctrl.container.querySelector('script')).toBeNull();
});

it('opens help message links in a new window', ()=>{
const ctrl = render(
<ThemedFormInput label="Field"
helpMessage={'See <a href="https://example.com/docs">the docs</a>.'}>
<input />
</ThemedFormInput>);
const link = ctrl.container.querySelector('a');
expect(link.getAttribute('href')).toBe('https://example.com/docs');
expect(link.getAttribute('target')).toBe('_blank');
expect(link.getAttribute('rel')).toBe('noopener noreferrer');
});

it('still strips unsafe links from help messages', ()=>{
const ctrl = render(
<ThemedFormInput label="Field"
helpMessage={'<a href="javascript:window.__xss=true">bad</a>'}>
<input />
</ThemedFormInput>);
const link = ctrl.container.querySelector('a');
expect(link.getAttribute('href')).toBeNull();
expect(link.getAttribute('target')).toBeNull();
});

it('preserves safe HTML formatting', ()=>{
const ctrl = render(
<ThemedFormFooterMessage
Expand Down
Loading
Loading