Skip to content

Commit 94d759a

Browse files
committed
[BUGFIX] remove cross-origin issues in visual editor
Avoid cross-origin problems when the visual editor navigates between configured site origins. Resolve target URLs to the matching `web_edit` backend URL and redirect to the correct backend origin when needed. Expose configured site origins to the frontend so iframe messaging and navigation handling accept valid TYPO3 origins. Open external origins in a new tab instead.
1 parent fd000a4 commit 94d759a

File tree

9 files changed

+225
-25
lines changed

9 files changed

+225
-25
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
/.idea/
12
/var/
23
/vendor/
34
/composer.lock
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace TYPO3\CMS\VisualEditor\Backend\Controller;
6+
7+
use InvalidArgumentException;
8+
use Psr\Http\Message\ResponseInterface;
9+
use Psr\Http\Message\ServerRequestInterface;
10+
use TYPO3\CMS\Backend\Attribute\AsController;
11+
use TYPO3\CMS\Core\Http\JsonResponse;
12+
use TYPO3\CMS\VisualEditor\Service\CrossOriginNavigationResolver;
13+
14+
#[AsController]
15+
final readonly class CrossOriginNavigationController
16+
{
17+
public function __construct(
18+
private CrossOriginNavigationResolver $resolver,
19+
) {
20+
}
21+
22+
public function resolveBackendUrlAction(ServerRequestInterface $request): ResponseInterface
23+
{
24+
$payload = $request->getParsedBody();
25+
if (!is_array($payload)) {
26+
$payload = json_decode((string)$request->getBody(), true);
27+
}
28+
29+
$frontendUrl = is_array($payload) ? (string)($payload['url'] ?? '') : '';
30+
if ($frontendUrl === '') {
31+
return new JsonResponse(['error' => 'Missing url'], 400);
32+
}
33+
34+
try {
35+
return new JsonResponse([
36+
'url' => $this->resolver->resolveBackendUrl($frontendUrl),
37+
]);
38+
} catch (InvalidArgumentException $invalidArgumentException) {
39+
return new JsonResponse(['error' => $invalidArgumentException->getMessage()], 400);
40+
}
41+
}
42+
}

Classes/Backend/Controller/PageEditController.php

Lines changed: 39 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@
3030
use TYPO3\CMS\Core\Database\Query\Restriction\WorkspaceRestriction;
3131
use TYPO3\CMS\Core\Domain\Record;
3232
use TYPO3\CMS\Core\Domain\RecordFactory;
33+
use TYPO3\CMS\Core\Http\HtmlResponse;
34+
use TYPO3\CMS\Core\Http\ImmediateResponseException;
3335
use TYPO3\CMS\Core\Imaging\IconFactory;
3436
use TYPO3\CMS\Core\Imaging\IconSize;
3537
use TYPO3\CMS\Core\Information\Typo3Version;
@@ -61,6 +63,8 @@
6163
use function is_array;
6264
use function sprintf;
6365

66+
use const JSON_UNESCAPED_SLASHES;
67+
6468
/**
6569
* @phpstan-type LanguageRef -1|0|positive-int
6670
*/
@@ -174,19 +178,13 @@ public function __invoke(ServerRequestInterface $request): ResponseInterface
174178
$view->getDocHeaderComponent()->setPageBreadcrumb($this->pageRecord->getRawRecord()->toArray());
175179
}
176180

177-
$iframeUrl = $this->iframeUrl($request);
181+
$siteLanguage = $this->selectedLanguages[0];
182+
$iframeUrl = $this->iframeUrl($request, $siteLanguage);
178183
$view->assignMultiple([
179184
'pageId' => $this->pageRecord->getUid(),
180185
'iframeSrc' => $iframeUrl,
181186
]);
182187

183-
$returnUrl = GeneralUtility::sanitizeLocalUrl(
184-
(string)($request->getQueryParams()['returnUrl'] ?? ''),
185-
) ?: $this->uriBuilder->buildUriFromRoute('web_edit');
186-
187-
// Always add rootPageId as additional field to have a reference for new records
188-
$view->assign('returnUrl', $returnUrl);
189-
190188
$this->makeButtons($view, $request);
191189
$this->makeLanguageMenu($view, $request);
192190

@@ -200,16 +198,40 @@ public function __invoke(ServerRequestInterface $request): ResponseInterface
200198
return $view->renderResponse('PageEdit');
201199
}
202200

203-
private function iframeUrl(ServerRequestInterface $request): UriInterface
201+
private function iframeUrl(ServerRequestInterface $request, SiteLanguage $siteLanguage): UriInterface
204202
{
205-
return $this->site->getRouter()->generateUri(
206-
$this->pageRecord->getUid(),
207-
[
208-
...$request->getQueryParams()['params'] ?? [],
209-
'_language' => $this->selectedLanguages[0]->getLanguageId(),
210-
'editMode' => 1,
211-
],
212-
);
203+
$parameters = [
204+
...$request->getQueryParams()['params'] ?? [],
205+
'_language' => $siteLanguage->getLanguageId(),
206+
'editMode' => 1,
207+
];
208+
209+
$uri = $this->site->getRouter()->generateUri($this->pageRecord->getUid(), $parameters);
210+
211+
if (
212+
$uri->getScheme() === $request->getUri()->getScheme()
213+
&& $uri->getHost() === $request->getUri()->getHost()
214+
&& $uri->getPort() === $request->getUri()->getPort()
215+
) {
216+
// if same origin, we can return the Uri
217+
return $uri;
218+
}
219+
220+
// redirect to the correct backend origin:
221+
$backendUrl = $this->uriBuilder
222+
->buildUriFromRoute(
223+
'web_edit',
224+
[
225+
'id' => $this->pageRecord->getUid(),
226+
'languages' => array_map(fn(SiteLanguage $siteLanguage): int => $siteLanguage->getLanguageId(), $this->selectedLanguages),
227+
],
228+
referenceType: UriBuilder::ABSOLUTE_URL,
229+
)
230+
->withScheme($uri->getScheme())
231+
->withHost($uri->getHost())
232+
->withPort($uri->getPort());
233+
$html = '<script>window.top.location.href = ' . json_encode((string)$backendUrl, JSON_UNESCAPED_SLASHES) . ';</script>';
234+
throw new ImmediateResponseException(new HtmlResponse($html, 406), 3234807219);
213235
}
214236

215237
private function makeButtons(ModuleTemplate $view, ServerRequestInterface $request): void
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace TYPO3\CMS\VisualEditor\Service;
6+
7+
use Throwable;
8+
use InvalidArgumentException;
9+
use TYPO3\CMS\Backend\Routing\UriBuilder;
10+
use TYPO3\CMS\Core\Http\ServerRequest;
11+
use TYPO3\CMS\Core\Http\Uri;
12+
use TYPO3\CMS\Core\Routing\PageArguments;
13+
use TYPO3\CMS\Core\Routing\RouteNotFoundException;
14+
use TYPO3\CMS\Core\Routing\SiteMatcher;
15+
use TYPO3\CMS\Core\Routing\SiteRouteResult;
16+
use TYPO3\CMS\Core\Site\Entity\Site;
17+
18+
final readonly class CrossOriginNavigationResolver
19+
{
20+
public function __construct(
21+
private SiteMatcher $siteMatcher,
22+
private UriBuilder $uriBuilder,
23+
) {
24+
}
25+
26+
public function resolveBackendUrl(string $frontendUrl): string
27+
{
28+
$uri = new Uri($frontendUrl);
29+
30+
parse_str($uri->getQuery(), $queryParams);
31+
$frontendRequest = (new ServerRequest($uri))->withQueryParams($queryParams);
32+
try {
33+
/** @var SiteRouteResult $siteMatch */
34+
$siteMatch = $this->siteMatcher->matchRequest($frontendRequest);
35+
} catch (Throwable $throwable) {
36+
throw new InvalidArgumentException('Could not resolve target site', 1742900105, $throwable);
37+
}
38+
39+
$site = $siteMatch->getSite();
40+
if (!($site instanceof Site)) {
41+
throw new InvalidArgumentException('Target URL does not belong to a TYPO3 site', 1742900102);
42+
}
43+
44+
try {
45+
$route = $site->getRouter()->matchRequest($frontendRequest, $siteMatch);
46+
} catch (RouteNotFoundException $routeNotFoundException) {
47+
throw new InvalidArgumentException('Could not resolve target page', 1742900103, $routeNotFoundException);
48+
}
49+
50+
if (!$route instanceof PageArguments || $route->areDirty()) {
51+
throw new InvalidArgumentException('Could not resolve target page arguments', 1742900104);
52+
}
53+
54+
$languageId = $siteMatch->getLanguage()?->getLanguageId();
55+
if ($languageId === null) {
56+
throw new InvalidArgumentException('Could not resolve target language', 1742900106);
57+
}
58+
59+
$backendUrl = $this->uriBuilder
60+
->buildUriFromRoute(
61+
'web_edit',
62+
[
63+
'id' => $route->getPageId(),
64+
'languages' => [$languageId],
65+
'params' => $route->getArguments(),
66+
],
67+
referenceType: UriBuilder::ABSOLUTE_URL,
68+
)
69+
->withScheme($uri->getScheme())
70+
->withHost($uri->getHost())
71+
->withPort($uri->getPort());
72+
73+
return (string)$backendUrl;
74+
}
75+
}

Classes/Service/EditModeService.php

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,7 @@ public function init(ServerRequestInterface $request): void
125125
'allowNewContent' => $this->languageModeService->getAllowNewContent($pageInformation, $siteLanguage, $request),
126126
'token' => $this->formProtectionFactory->createForType('backend')->generateToken('visual_editor', 'save'),
127127
'routeArguments' => (object)$this->flattenBracketKeys(['params' => $routing->getRouteArguments()]),
128-
'allowedReferrer' => $this->getAllowedReferrer(),
128+
'allowedOrigins' => $this->getAllowedOrigins(),
129129
];
130130
$this->assetCollector->addInlineJavaScript(
131131
'veLangInfo',
@@ -228,19 +228,19 @@ private function loadLanguageLabelsInline(): void
228228
* returns the origins of all configured sites and languages
229229
* @return list<string>
230230
*/
231-
private function getAllowedReferrer(): array
231+
public function getAllowedOrigins(): array
232232
{
233-
$allowedReferrers = [];
233+
$allowed = [];
234234
$sites = $this->siteFinder->getAllSites();
235235
foreach ($sites as $site) {
236236
$origin = $site->getBase()->withQuery('')->withPath('')->withUserInfo('')->withFragment('');
237-
$allowedReferrers[(string)$origin] = true;
237+
$allowed[(string)$origin] = true;
238238
foreach ($site->getLanguages() as $language) {
239239
$origin = $language->getBase()->withQuery('')->withPath('')->withUserInfo('')->withFragment('');
240-
$allowedReferrers[(string)$origin] = true;
240+
$allowed[(string)$origin] = true;
241241
}
242242
}
243243

244-
return array_keys($allowedReferrers);
244+
return array_keys($allowed);
245245
}
246246
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<?php
2+
3+
use TYPO3\CMS\VisualEditor\Backend\Controller\CrossOriginNavigationController;
4+
5+
return [
6+
'visual_editor_resolve_cross_origin_backend_url' => [
7+
'path' => '/visual-editor/resolve-cross-origin-backend-url',
8+
'target' => CrossOriginNavigationController::class . '::resolveBackendUrlAction',
9+
'methods' => ['POST'],
10+
'inheritAccessFromModule' => 'web_edit',
11+
],
12+
];

Resources/Public/JavaScript/Frontend/index.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {sendMessage} from '@typo3/visual-editor/Shared/iframe-messaging';
1313
import {highlight, reset} from '@typo3/visual-editor/Frontend/spotlight-overlay';
1414
import {spotlightActive} from '@typo3/visual-editor/Shared/local-stores';
1515
import {initSaveScrollPosition} from '@typo3/visual-editor/Frontend/init-save-scroll-position';
16+
import {initializeCrossOriginNavigations} from '@typo3/visual-editor/Frontend/initializeCrossOriginNavigations';
1617

1718
if (window.location.hash === '#ve-close') {
1819
sendMessage('closeModal');
@@ -23,7 +24,7 @@ if (window.location.hash === '#ve-close') {
2324
const element = document.createElement('ve-save-button');
2425
document.body.appendChild(element);
2526

26-
(function spotlight() {
27+
(function () {
2728
const setSpotlight = () => {
2829
if (spotlightActive.get()) {
2930
highlight('ve-editable-text, ve-editable-rich-text, .ck-editor__top');
@@ -41,3 +42,4 @@ if (window.veInfo) {
4142
}
4243

4344
initSaveScrollPosition();
45+
initializeCrossOriginNavigations();
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
async function resolveCrossOriginBackendUrl(url) {
2+
const response = await fetch(TYPO3.settings.ajaxUrls.visual_editor_resolve_cross_origin_backend_url, {
3+
method: 'POST',
4+
credentials: 'same-origin',
5+
headers: {
6+
'Content-Type': 'application/json',
7+
},
8+
body: JSON.stringify({url}),
9+
});
10+
11+
if (!response.ok) {
12+
throw new Error(`Could not resolve cross-origin backend URL: ${response.status}`);
13+
}
14+
15+
const data = await response.json();
16+
if (!data.url) {
17+
throw new Error('Missing backend URL in resolver response');
18+
}
19+
20+
return data.url;
21+
}
22+
23+
export function initializeCrossOriginNavigations() {
24+
25+
navigation.addEventListener("navigate", (event) => {
26+
const newUrl = new URL(event.destination.url);
27+
if (newUrl.origin !== window.location.origin) {
28+
event.preventDefault();
29+
30+
if (window.veInfo.allowedOrigins.includes(newUrl.origin)) {
31+
resolveCrossOriginBackendUrl(event.destination.url)
32+
.then((backendUrl) => {
33+
window.top.location = backendUrl;
34+
})
35+
.catch((error) => {
36+
console.error(error);
37+
window.open(event.destination.url, '_blank').focus();
38+
});
39+
return;
40+
}
41+
42+
// open in new Tab and force switch to
43+
window.open(event.destination.url, '_blank').focus();
44+
}
45+
});
46+
}

Resources/Public/JavaScript/Shared/iframe-messaging.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ function getPeerOrigin() {
3131

3232
if (document.referrer) {
3333
const origin = new URL(document.referrer).origin;
34-
if (window.veInfo.allowedReferrer.includes(origin)) {
34+
if (window.veInfo.allowedOrigins.includes(origin)) {
3535
return origin;
3636
}
3737
}

0 commit comments

Comments
 (0)