Skip to content

Commit 316f5ce

Browse files
bnfohader
authored andcommitted
[SECURITY] Require step-up authentication for password change
Require a password confirmation for every password change of backend users via FormEngine (applies to own passwords and passwords of other users). Also require a recent user verification (step-up) for any change to backend user details. Resolves: #103252 Releases: main, 13.4, 12.4 Change-Id: I2a74b15dd3d7e7bded8b59b5df5af1097d5e7b59 Security-Bulletin: TYPO3-CORE-SA-2025-013 Security-References: CVE-2025-47938 Reviewed-on: https://review.typo3.org/c/Packages/TYPO3.CMS/+/89467 Tested-by: Oliver Hader <oliver.hader@typo3.org> Reviewed-by: Oliver Hader <oliver.hader@typo3.org>
1 parent eff2a8d commit 316f5ce

File tree

38 files changed

+636
-85
lines changed

38 files changed

+636
-85
lines changed

Build/Sources/TypeScript/backend/ajax-data-handler.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { BroadcastMessage } from '@typo3/backend/broadcast-message';
1515
import AjaxRequest from '@typo3/core/ajax/ajax-request';
1616
import BroadcastService from '@typo3/backend/broadcast-service';
1717
import Notification from './notification';
18+
import { sudoModeInterceptor } from '@typo3/backend/security/sudo-mode-interceptor';
1819
import type { AjaxResponse } from '@typo3/core/ajax/ajax-response';
1920
import type ResponseInterface from './ajax-data-handler/response-interface';
2021

@@ -39,9 +40,13 @@ class AjaxDataHandler {
3940
* @returns {Promise<ResponseInterface>}
4041
*/
4142
private static call(params: string | object): Promise<ResponseInterface> {
42-
return (new AjaxRequest(TYPO3.settings.ajaxUrls.record_process)).withQueryArguments(params).get().then(async (response: AjaxResponse): Promise<ResponseInterface> => {
43-
return await response.resolve();
44-
});
43+
return (new AjaxRequest(TYPO3.settings.ajaxUrls.record_process))
44+
.addMiddleware(sudoModeInterceptor)
45+
.withQueryArguments(params)
46+
.get()
47+
.then(async (response: AjaxResponse): Promise<ResponseInterface> => {
48+
return await response.resolve();
49+
});
4550
}
4651

4752
/**

Build/Sources/TypeScript/backend/form-engine/element/mfa-info-element.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import Notification from '@typo3/backend/notification';
1818
import Modal from '@typo3/backend/modal';
1919
import { SeverityEnum } from '@typo3/backend/enum/severity';
2020
import { selector } from '@typo3/core/literals';
21+
import { sudoModeInterceptor } from '@typo3/backend/security/sudo-mode-interceptor';
2122
import type { AjaxResponse } from '@typo3/core/ajax/ajax-response';
2223

2324
interface FieldOptions {
@@ -110,7 +111,7 @@ class MfaInfoElement {
110111
if (this.request instanceof AjaxRequest) {
111112
this.request.abort();
112113
}
113-
this.request = (new AjaxRequest(TYPO3.settings.ajaxUrls.mfa));
114+
this.request = (new AjaxRequest(TYPO3.settings.ajaxUrls.mfa)).addMiddleware(sudoModeInterceptor);
114115
this.request.post({
115116
action: 'deactivate',
116117
provider: provider,

Build/Sources/TypeScript/backend/recordlist.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import type { ActionConfiguration, ActionEventDetails } from '@typo3/backend/mul
2121
import Notification from '@typo3/backend/notification';
2222
import AjaxRequest from '@typo3/core/ajax/ajax-request';
2323
import { AjaxResponse } from '@typo3/core/ajax/ajax-response';
24+
import { sudoModeInterceptor } from '@typo3/backend/security/sudo-mode-interceptor';
2425

2526
interface IconIdentifier {
2627
collapse: string;
@@ -226,7 +227,7 @@ class Recordlist {
226227
const isVisible = target.dataset.datahandlerStatus === 'visible';
227228
const targetAction = isVisible ? 'hide' : 'show';
228229

229-
new AjaxRequest(TYPO3.settings.ajaxUrls.record_toggle_visibility).post({
230+
new AjaxRequest(TYPO3.settings.ajaxUrls.record_toggle_visibility).addMiddleware(sudoModeInterceptor).post({
230231
table: table,
231232
uid: uid,
232233
action: targetAction,

Build/Sources/TypeScript/backend/security/element/sudo-mode.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ interface Labels {
4545
abstract class SudoModeProperties extends LitElement {
4646
@property({ type: String }) verifyActionUri: string;
4747
@property({ type: String }) cancelUri: string;
48+
@property({ type: Boolean }) isAjax: boolean;
4849
@property({ type: Boolean, attribute: 'has-fatal-error' }) hasFatalError: boolean;
4950
@property({ type: Boolean, attribute: 'allow-install-tool-password' }) allowInstallToolPassword: boolean;
5051
@property({ type: Object }) labels: Labels;
@@ -115,6 +116,7 @@ export class SudoMode extends SudoModeProperties {
115116
.labels=${this.labels}
116117
.verifyActionUri=${this.verifyActionUri}
117118
.cancelUri=${this.cancelUri}
119+
.isAjax=${this.isAjax}
118120
.hasFatalError=${this.hasFatalError}
119121
.allowInstallToolPassword=${this.allowInstallToolPassword}
120122
@typo3:sudo-mode:verified=${() => this.dispatchEvent(new Event('typo3:sudo-mode:verified'))}
@@ -208,7 +210,7 @@ export class SudoModeForm extends SudoModeProperties {
208210
const responseData: SudoModeResponse = await response.resolve('application/json');
209211
this.dispatchEvent(new Event('typo3:sudo-mode:verified'));
210212
this.closest('typo3-backend-modal').hideModal();
211-
if (responseData.redirect) {
213+
if (!this.isAjax && responseData.redirect) {
212214
Viewport.ContentContainer.setUrl(responseData.redirect.uri);
213215
}
214216
} catch (e: unknown) {
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import type { RequestMiddleware, RequestHandler } from '@typo3/core/ajax/ajax-request-types';
2+
3+
export const sudoModeInterceptor: RequestMiddleware = async (request: Request, next: RequestHandler): Promise<Response> => {
4+
// Requests are not immutable, therefore we clone to be able to re-submit exactly the same request later on
5+
const requestClone = request.clone();
6+
const response = await next(request);
7+
if (response.status === 422) {
8+
const { initiateSudoModeModal } = await import('@typo3/backend/security/element/sudo-mode');
9+
const data = await response.json();
10+
try {
11+
await initiateSudoModeModal(data.sudoModeInitialization);
12+
} catch {
13+
// sudo mode was aborted
14+
return response;
15+
}
16+
return next(requestClone);
17+
}
18+
return response;
19+
};
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
/*
2+
* This file is part of the TYPO3 CMS project.
3+
*
4+
* It is free software; you can redistribute it and/or modify it under
5+
* the terms of the GNU General Public License, either version 2
6+
* of the License, or any later version.
7+
*
8+
* For the full copyright and license information, please read the
9+
* LICENSE.txt file that was distributed with this source code.
10+
*
11+
* The TYPO3 project - inspiring people to share!
12+
*/
13+
14+
export type RequestHandler = (request: Request) => Promise<Response>;
15+
export type RequestMiddleware = (request: Request, next: RequestHandler) => Promise<Response>;

Build/Sources/TypeScript/core/ajax/ajax-request.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
import { AjaxResponse } from '@typo3/core/ajax/ajax-response';
1515
import { InputTransformer, type GenericKeyValue } from './input-transformer';
16+
import type { RequestMiddleware, RequestHandler } from '@typo3/core/ajax/ajax-request-types';
1617

1718
/**
1819
* @example send data as `Content-Type: multipart/form-data` (default)
@@ -36,10 +37,12 @@ class AjaxRequest {
3637

3738
private readonly url: URL;
3839
private readonly abortController: AbortController;
40+
private fetch: RequestHandler;
3941

4042
constructor(url: URL|string) {
4143
this.url = url instanceof URL ? url : new URL(url, window.location.origin + window.location.pathname);
4244
this.abortController = new AbortController();
45+
this.fetch = (request: Request) => fetch(request);
4346
}
4447

4548
/**
@@ -141,6 +144,25 @@ class AjaxRequest {
141144
this.abortController.abort();
142145
}
143146

147+
/**
148+
* Adds an outer middleware around the fetch invocation.
149+
* Previous registered middlewares are handled after
150+
* the one(s) that is/are added here.
151+
*
152+
* @param {RequestMiddleware | RequestMiddleware[]} middleware
153+
* @return {AjaxRequest}
154+
*/
155+
public addMiddleware(middleware: RequestMiddleware | RequestMiddleware[]): AjaxRequest {
156+
if (Array.isArray(middleware)) {
157+
middleware.forEach(middleware => this.addMiddleware(middleware));
158+
return this;
159+
}
160+
161+
const next = this.fetch;
162+
this.fetch = (request: Request) => middleware(request, next);
163+
return this;
164+
}
165+
144166
/**
145167
* Clones the current AjaxRequest object
146168
*
@@ -157,7 +179,7 @@ class AjaxRequest {
157179
* @return {Promise<Response>}
158180
*/
159181
private async send(init: RequestInit = {}): Promise<Response> {
160-
const response = await fetch(this.url, this.getMergedOptions(init));
182+
const response = await this.fetch(new Request(this.url, this.getMergedOptions(init)));
161183
if (!response.ok) {
162184
throw new AjaxResponse(response);
163185
}

Build/Sources/TypeScript/core/tests/ajax/ajax-request-test.ts

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -42,12 +42,12 @@ describe('@typo3/core/ajax/ajax-request', (): void => {
4242

4343
it('sends GET request', (): void => {
4444
(new AjaxRequest('https://example.com')).get();
45-
expect(fetchStub).calledWithMatch(new URL('https://example.com/'), { method: 'GET' });
45+
expect(fetchStub).calledWithMatch(new Request('https://example.com/', { method: 'GET' }));
4646
});
4747

4848
it('sends POST request with empty object', (): void => {
4949
(new AjaxRequest('https://example.com')).post({});
50-
expect(fetchStub).calledWithMatch(new URL('https://example.com/'), { method: 'POST', body: '' });
50+
expect(fetchStub).calledWithMatch(new Request('https://example.com/', { method: 'POST', body: '' }));
5151
});
5252

5353
for (const requestMethod of ['POST', 'PUT', 'DELETE']) {
@@ -92,7 +92,7 @@ describe('@typo3/core/ajax/ajax-request', (): void => {
9292
it(`with ${name}`, (done): void => {
9393
const request: any = (new AjaxRequest('https://example.com'));
9494
request[requestFn](payload, { headers: headers });
95-
expect(fetchStub).calledWithMatch(new URL('https://example.com/'), { method: requestMethod, body: expectedFn() });
95+
expect(fetchStub).calledWithMatch(new Request('https://example.com/', { method: requestMethod, body: expectedFn() }));
9696
done();
9797
});
9898
}
@@ -138,7 +138,7 @@ describe('@typo3/core/ajax/ajax-request', (): void => {
138138

139139
(new AjaxRequest(new URL('https://example.com'))).get().then(async (response: AjaxResponse): Promise<void> => {
140140
const data = await response.resolve();
141-
expect(fetchStub).calledWithMatch(new URL('https://example.com/'), { method: 'GET' });
141+
expect(fetchStub).calledWithMatch(new Request('https://example.com/', { method: 'GET' }));
142142
onfulfill(data, responseText);
143143
done();
144144
});
@@ -196,7 +196,7 @@ describe('@typo3/core/ajax/ajax-request', (): void => {
196196
const [name, input, queryParameter, expected] = providedData;
197197
it('with ' + name, (): void => {
198198
(new AjaxRequest(input)).withQueryArguments(queryParameter).get();
199-
expect(fetchStub).calledWithMatch(expected, { method: 'GET' });
199+
expect(fetchStub).calledWithMatch(new Request(expected.toString(), { method: 'GET' }));
200200
});
201201
}
202202
});
@@ -265,7 +265,7 @@ describe('@typo3/core/ajax/ajax-request', (): void => {
265265
const [name, input, expected] = providedData;
266266
it('with ' + name, (): void => {
267267
(new AjaxRequest('https://example.com/')).withQueryArguments(input).get();
268-
expect(fetchStub).calledWithMatch(expected, { method: 'GET' });
268+
expect(fetchStub).calledWithMatch(new Request(expected.toString(), { method: 'GET' }));
269269
});
270270
}
271271
});
@@ -275,7 +275,7 @@ describe('@typo3/core/ajax/ajax-request', (): void => {
275275
const request = new AjaxRequest(new URL('https://example.com'));
276276
request.get();
277277
request.abort();
278-
expect((fetchStub.firstCall.args[1].signal as AbortSignal).aborted).to.be.true;
278+
expect((fetchStub.firstCall.args[0] as Request).signal.aborted).to.be.true;
279279
});
280280

281281
it('via signal option', (): void => {
@@ -284,7 +284,7 @@ describe('@typo3/core/ajax/ajax-request', (): void => {
284284
request.get({ signal: abortController.signal });
285285
abortController.abort();
286286
expect(abortController.signal.aborted).to.be.true;
287-
expect((fetchStub.firstCall.args[1].signal as AbortSignal).aborted).to.be.true;
287+
expect((fetchStub.firstCall.args[0] as Request).signal.aborted).to.be.true;
288288
});
289289
});
290290
});

typo3/sysext/backend/Classes/Controller/RecordListController.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
use TYPO3\CMS\Backend\Routing\Exception\RouteNotFoundException;
3030
use TYPO3\CMS\Backend\Routing\PreviewUriBuilder;
3131
use TYPO3\CMS\Backend\Routing\UriBuilder;
32+
use TYPO3\CMS\Backend\Security\SudoMode\Exception\VerificationRequiredException;
3233
use TYPO3\CMS\Backend\Template\Components\ButtonBar;
3334
use TYPO3\CMS\Backend\Template\Components\Buttons\DropDown\DropDownItemInterface;
3435
use TYPO3\CMS\Backend\Template\Components\Buttons\DropDown\DropDownToggle;
@@ -313,6 +314,9 @@ public function toggleRecordVisibilityAction(ServerRequestInterface $request): R
313314
$response['hasErrors'] = true;
314315
}
315316
}
317+
} catch (VerificationRequiredException $e) {
318+
// Handled by Middleware/SudoModeInterceptor
319+
throw $e;
316320
} catch (\Throwable $e) {
317321
// @todo: having this explicit handling here sucks
318322
$response = [

typo3/sysext/backend/Classes/Controller/Security/SudoModeController.php

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -90,9 +90,12 @@ public function moduleAction(ServerRequestInterface $request): ResponseInterface
9090
return $this->redirectToErrorAction();
9191
}
9292

93+
$labels = $this->getLanguageService()->getLabelsFromResource('EXT:backend/Resources/Private/Language/SudoMode.xlf');
94+
9395
$view = $this->moduleTemplateFactory->create($request);
9496
$view->assignMultiple([
9597
'verifyActionUri' => $this->buildVerifyActionUriForClaim($claim),
98+
'allowInstallToolPassword' => $this->getBackendUser()->isSystemMaintainer(true),
9699
'labels' => $this->getLanguageService()->getLabelsFromResource('EXT:backend/Resources/Private/Language/SudoMode.xlf'),
97100
]);
98101
return $view->renderResponse('SudoMode/Module');
@@ -140,6 +143,10 @@ public function verifyAction(ServerRequestInterface $request): ResponseInterface
140143

141144
$password = (string)($request->getParsedBody()['password'] ?? '');
142145
$useInstallToolPassword = (bool)($request->getParsedBody()['useInstallToolPassword'] ?? false);
146+
// Only system maintainers are allowed to use the installtool password for sudo mode operations
147+
if (!$GLOBALS['BE_USER']->isSystemMaintainer(true)) {
148+
$useInstallToolPassword = false;
149+
}
143150
$loggerContext = $this->buildLoggerContext($claim);
144151

145152
$redirect = [
@@ -196,8 +203,10 @@ private function resolveClaimFromRequest(ServerRequestInterface $request, string
196203

197204
private function grantClaim(AccessClaim $claim): void
198205
{
199-
$grant = $this->factory->buildGrantForSubject($claim->subject);
200-
$this->storage->addGrant($grant);
206+
foreach ($claim->subjects as $subject) {
207+
$grant = $this->factory->buildGrantForSubject($subject);
208+
$this->storage->addGrant($grant);
209+
}
201210
}
202211

203212
/**

0 commit comments

Comments
 (0)