From 058a60774eceeb78f5ce590b6a3ff17e24981186 Mon Sep 17 00:00:00 2001 From: Peter Ringelmann Date: Mon, 23 Mar 2026 17:42:57 +0100 Subject: [PATCH] fix(frontend): add strict password confirmation for sensitive admin actions Register axios password confirmation interceptors in the apps management, admin delegation, admin security, and OAuth2 settings bundles, and pass PwdConfirmationMode.Strict on requests to endpoints protected with #[PasswordConfirmationRequired(strict: true)], so that the user password is verified via Basic auth on the request itself rather than relying on the session timestamp. Signed-off-by: Peter Ringelmann --- apps/oauth2/src/App.vue | 2 + apps/oauth2/src/main.js | 4 + .../AdminDelegation/GroupSelect.vue | 3 +- .../src/components/AdminTwoFactor.vue | 3 +- apps/settings/src/main-admin-delegation.js | 4 + apps/settings/src/main-admin-security.js | 6 +- .../src/main-apps-users-management.ts | 4 + apps/settings/src/store/api.js | 4 +- apps/settings/src/store/apps.js | 131 +++++++++--------- 9 files changed, 90 insertions(+), 71 deletions(-) diff --git a/apps/oauth2/src/App.vue b/apps/oauth2/src/App.vue index a6aca19bbfa..e70d5434183 100644 --- a/apps/oauth2/src/App.vue +++ b/apps/oauth2/src/App.vue @@ -73,6 +73,7 @@ import NcButton from '@nextcloud/vue/dist/Components/NcButton.js' import NcNoteCard from '@nextcloud/vue/dist/Components/NcNoteCard.js' import { loadState } from '@nextcloud/initial-state' import NcTextField from '@nextcloud/vue/dist/Components/NcTextField.js' +import { PwdConfirmationMode } from '@nextcloud/password-confirmation' export default { name: 'App', @@ -123,6 +124,7 @@ export default { name: this.newClient.name, redirectUri: this.newClient.redirectUri, }, + { confirmPassword: PwdConfirmationMode.Strict }, ).then(response => { // eslint-disable-next-line vue/no-mutating-props this.clients.push(response.data) diff --git a/apps/oauth2/src/main.js b/apps/oauth2/src/main.js index 10d537455df..457f32e01b1 100644 --- a/apps/oauth2/src/main.js +++ b/apps/oauth2/src/main.js @@ -3,13 +3,17 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ +import axios from '@nextcloud/axios' import Vue from 'vue' import App from './App.vue' import { loadState } from '@nextcloud/initial-state' +import { addPasswordConfirmationInterceptors } from '@nextcloud/password-confirmation' Vue.prototype.t = t Vue.prototype.OC = OC +addPasswordConfirmationInterceptors(axios) + const clients = loadState('oauth2', 'clients') const View = Vue.extend(App) diff --git a/apps/settings/src/components/AdminDelegation/GroupSelect.vue b/apps/settings/src/components/AdminDelegation/GroupSelect.vue index 203c17aa7f8..5b870784218 100644 --- a/apps/settings/src/components/AdminDelegation/GroupSelect.vue +++ b/apps/settings/src/components/AdminDelegation/GroupSelect.vue @@ -18,6 +18,7 @@ import NcSelect from '@nextcloud/vue/dist/Components/NcSelect.js' import { generateUrl } from '@nextcloud/router' import axios from '@nextcloud/axios' import { showError } from '@nextcloud/dialogs' +import { PwdConfirmationMode } from '@nextcloud/password-confirmation' import logger from '../../logger.ts' export default { @@ -59,7 +60,7 @@ export default { class: this.setting.class, } try { - await axios.post(generateUrl('/apps/settings/') + '/settings/authorizedgroups/saveSettings', data) + await axios.post(generateUrl('/apps/settings/') + '/settings/authorizedgroups/saveSettings', data, { confirmPassword: PwdConfirmationMode.Strict }) } catch (e) { showError(t('settings', 'Unable to modify setting')) logger.error('Unable to modify setting', e) diff --git a/apps/settings/src/components/AdminTwoFactor.vue b/apps/settings/src/components/AdminTwoFactor.vue index 56b9d609b8b..986d095442c 100644 --- a/apps/settings/src/components/AdminTwoFactor.vue +++ b/apps/settings/src/components/AdminTwoFactor.vue @@ -76,6 +76,7 @@ import NcButton from '@nextcloud/vue/dist/Components/NcButton.js' import NcCheckboxRadioSwitch from '@nextcloud/vue/dist/Components/NcCheckboxRadioSwitch.js' import NcSettingsSection from '@nextcloud/vue/dist/Components/NcSettingsSection.js' import { loadState } from '@nextcloud/initial-state' +import { PwdConfirmationMode } from '@nextcloud/password-confirmation' import sortedUniq from 'lodash/sortedUniq.js' import uniq from 'lodash/uniq.js' @@ -156,7 +157,7 @@ export default { enforcedGroups: this.enforcedGroups, excludedGroups: this.excludedGroups, } - axios.put(generateUrl('/settings/api/admin/twofactorauth'), data) + axios.put(generateUrl('/settings/api/admin/twofactorauth'), data, { confirmPassword: PwdConfirmationMode.Strict }) .then(resp => resp.data) .then(state => { this.state = state diff --git a/apps/settings/src/main-admin-delegation.js b/apps/settings/src/main-admin-delegation.js index c6b7ae1e5a2..7f7d2850df7 100644 --- a/apps/settings/src/main-admin-delegation.js +++ b/apps/settings/src/main-admin-delegation.js @@ -3,9 +3,13 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ +import axios from '@nextcloud/axios' +import { addPasswordConfirmationInterceptors } from '@nextcloud/password-confirmation' import Vue from 'vue' import App from './components/AdminDelegating.vue' +addPasswordConfirmationInterceptors(axios) + // bind to window Vue.prototype.OC = OC Vue.prototype.t = t diff --git a/apps/settings/src/main-admin-security.js b/apps/settings/src/main-admin-security.js index 26961dcc13e..a62165b90d5 100644 --- a/apps/settings/src/main-admin-security.js +++ b/apps/settings/src/main-admin-security.js @@ -1,15 +1,19 @@ +import { getCSPNonce } from '@nextcloud/auth' /** * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later */ -import { getCSPNonce } from '@nextcloud/auth' +import axios from '@nextcloud/axios' import { loadState } from '@nextcloud/initial-state' +import { addPasswordConfirmationInterceptors } from '@nextcloud/password-confirmation' import Vue from 'vue' import AdminTwoFactor from './components/AdminTwoFactor.vue' import EncryptionSettings from './components/Encryption/EncryptionSettings.vue' import store from './store/admin-security.js' +addPasswordConfirmationInterceptors(axios) + // eslint-disable-next-line camelcase __webpack_nonce__ = getCSPNonce() diff --git a/apps/settings/src/main-apps-users-management.ts b/apps/settings/src/main-apps-users-management.ts index 62ea009de11..969fd5db1a5 100644 --- a/apps/settings/src/main-apps-users-management.ts +++ b/apps/settings/src/main-apps-users-management.ts @@ -3,6 +3,8 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ +import axios from '@nextcloud/axios' +import { addPasswordConfirmationInterceptors } from '@nextcloud/password-confirmation' import Vue from 'vue' import Vuex from 'vuex' import VTooltipPlugin from 'v-tooltip' @@ -15,6 +17,8 @@ import { useStore } from './store/index.js' import { getCSPNonce } from '@nextcloud/auth' import { PiniaVuePlugin, createPinia } from 'pinia' +addPasswordConfirmationInterceptors(axios) + // CSP config for webpack dynamic chunk loading // eslint-disable-next-line camelcase __webpack_nonce__ = getCSPNonce() diff --git a/apps/settings/src/store/api.js b/apps/settings/src/store/api.js index f36d44cc5c0..e1e69b04679 100644 --- a/apps/settings/src/store/api.js +++ b/apps/settings/src/store/api.js @@ -50,8 +50,8 @@ export default { get(url, options) { return axios.get(sanitize(url), options) }, - post(url, data) { - return axios.post(sanitize(url), data) + post(url, data, options) { + return axios.post(sanitize(url), data, options) }, patch(url, data) { return axios.patch(sanitize(url), data) diff --git a/apps/settings/src/store/apps.js b/apps/settings/src/store/apps.js index 4448e0d5bf7..8579f93664d 100644 --- a/apps/settings/src/store/apps.js +++ b/apps/settings/src/store/apps.js @@ -9,6 +9,7 @@ import axios from '@nextcloud/axios' import { generateUrl } from '@nextcloud/router' import { showError, showInfo } from '@nextcloud/dialogs' import { loadState } from '@nextcloud/initial-state' +import { PwdConfirmationMode } from '@nextcloud/password-confirmation' const state = { apps: [], @@ -180,84 +181,82 @@ const actions = { } else { apps = [appId] } - return api.requireAdmin().then((response) => { - context.commit('startLoading', apps) - context.commit('startLoading', 'install') + context.commit('startLoading', apps) + context.commit('startLoading', 'install') - const previousState = {} - apps.forEach((_appId) => { - const app = context.state.apps.find((app) => app.id === _appId) - if (app) { - previousState[_appId] = { - active: app.active, - groups: [...(app.groups || [])], - } - context.commit('enableApp', { appId: _appId, groups }) + const previousState = {} + apps.forEach((_appId) => { + const app = context.state.apps.find((app) => app.id === _appId) + if (app) { + previousState[_appId] = { + active: app.active, + groups: [...(app.groups || [])], } - }) + context.commit('enableApp', { appId: _appId, groups }) + } + }) - return api.post(generateUrl('settings/apps/enable'), { appIds: apps, groups }) - .then((response) => { - context.commit('stopLoading', apps) - context.commit('stopLoading', 'install') - apps.forEach(_appId => { - context.commit('enableApp', { appId: _appId, groups }) - }) + return api.post(generateUrl('settings/apps/enable'), { appIds: apps, groups }, { confirmPassword: PwdConfirmationMode.Strict }) + .then((response) => { + context.commit('stopLoading', apps) + context.commit('stopLoading', 'install') - // check for server health - return axios.get(generateUrl('apps/files/')) - .then(() => { - if (response.data.update_required) { - showInfo( - t( - 'settings', - 'The app has been enabled but needs to be updated. You will be redirected to the update page in 5 seconds.', - ), - { - onClick: () => window.location.reload(), - close: false, + // check for server health + return axios.get(generateUrl('apps/files/')) + .then(() => { + if (response.data.update_required) { + showInfo( + t( + 'settings', + 'The app has been enabled but needs to be updated. You will be redirected to the update page in 5 seconds.', + ), + { + onClick: () => window.location.reload(), + close: false, - }, - ) - setTimeout(function() { - location.reload() - }, 5000) - } - }) - .catch(() => { - if (!Array.isArray(appId)) { - showError(t('settings', 'Error: This app cannot be enabled because it makes the server unstable')) - context.commit('setError', { - appId: apps, - error: t('settings', 'Error: This app cannot be enabled because it makes the server unstable'), - }) - context.dispatch('disableApp', { appId }) - } - }) - }) - .catch((error) => { - context.commit('stopLoading', apps) - context.commit('stopLoading', 'install') - - apps.forEach((_appId) => { - if (previousState[_appId]) { - context.commit('enableApp', { - appId: _appId, - groups: previousState[_appId].groups, - }) - if (!previousState[_appId].active) { - context.commit('disableApp', _appId) - } + }, + ) + setTimeout(function() { + location.reload() + }, 5000) } }) + .catch(() => { + if (!Array.isArray(appId)) { + showError(t('settings', 'Error: This app cannot be enabled because it makes the server unstable')) + context.commit('setError', { + appId: apps, + error: t('settings', 'Error: This app cannot be enabled because it makes the server unstable'), + }) + context.dispatch('disableApp', { appId }) + } + }) + }) + .catch((error) => { + context.commit('stopLoading', apps) + context.commit('stopLoading', 'install') + apps.forEach((_appId) => { + if (previousState[_appId]) { + context.commit('enableApp', { + appId: _appId, + groups: previousState[_appId].groups, + }) + if (!previousState[_appId].active) { + context.commit('disableApp', _appId) + } + } + }) + + const message = error.response?.data?.data?.message + if (message) { context.commit('setError', { appId: apps, - error: error.response.data.data.message, + error: message, }) context.commit('APPS_API_FAILURE', { appId, error }) - }) - }).catch((error) => context.commit('API_FAILURE', { appId, error })) + } + }) }, forceEnableApp(context, { appId, groups }) { let apps