From be313514d883d39b8223a829ec0a3f533911c860 Mon Sep 17 00:00:00 2001 From: Raphael Thanner Date: Fri, 26 Sep 2025 22:35:27 +0200 Subject: [PATCH 1/5] Add CORS preflight support with dynamic origin validation --- .gitignore | 3 +- Classes/Middleware/AbstractMiddleware.php | 13 +++- Classes/Middleware/CacheControl.php | 24 +++++++ Classes/Middleware/CorsHeaders.php | 63 +++++++++++++++++++ Classes/Middleware/Request.php | 31 --------- Classes/Middleware/UserTsConfig.php | 2 +- Configuration/ContentSecurityPolicies.php | 26 ++++++++ Configuration/RequestMiddlewares.php | 10 ++- .../JavaScript/Backend/Semantilizer.ts | 1 + .../Public/JavaScript/Backend/Semantilizer.js | 2 +- 10 files changed, 137 insertions(+), 38 deletions(-) create mode 100644 Classes/Middleware/CacheControl.php create mode 100644 Classes/Middleware/CorsHeaders.php delete mode 100644 Classes/Middleware/Request.php create mode 100644 Configuration/ContentSecurityPolicies.php diff --git a/.gitignore b/.gitignore index 68a2aac..1dfed6c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ -/node_modules .php_cs.cache +.idea/ +/node_modules /composer.lock /Resources/Private/JavaScript/Backend/TYPO3 diff --git a/Classes/Middleware/AbstractMiddleware.php b/Classes/Middleware/AbstractMiddleware.php index d0e1b30..9b4870a 100644 --- a/Classes/Middleware/AbstractMiddleware.php +++ b/Classes/Middleware/AbstractMiddleware.php @@ -8,12 +8,21 @@ use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\MiddlewareInterface; use Psr\Http\Server\RequestHandlerInterface; +use TYPO3\CMS\Core\Context\Context; +use TYPO3\CMS\Core\Context\Exception\AspectNotFoundException; +use TYPO3\CMS\Core\Utility\GeneralUtility; abstract class AbstractMiddleware implements MiddlewareInterface { - protected function isSemantilizerRequest(ServerRequestInterface $request): bool + protected function isSemantilizerRequest(ServerRequestInterface $request, bool $requireLogin = null): bool { - return !empty($request->getHeader('X-Semantilizer')); + $headerExists = !empty($request->getHeader('X-Semantilizer')); + + try { + return $headerExists && (!$requireLogin || GeneralUtility::makeInstance(Context::class)->getPropertyFromAspect('backend.user', 'isLoggedIn', false)); + } catch (AspectNotFoundException) { + return false; + } } abstract public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface; diff --git a/Classes/Middleware/CacheControl.php b/Classes/Middleware/CacheControl.php new file mode 100644 index 0000000..fe7ce2d --- /dev/null +++ b/Classes/Middleware/CacheControl.php @@ -0,0 +1,24 @@ +isSemantilizerRequest($request, true) + && $GLOBALS['TSFE'] instanceof TypoScriptFrontendController + && $GLOBALS['TSFE']->set_no_cache(sprintf('Semantilizer frontend request (%s, line %d)', self::class, __LINE__)); + + // Go your way … + return $handler->handle($request); + } +} diff --git a/Classes/Middleware/CorsHeaders.php b/Classes/Middleware/CorsHeaders.php new file mode 100644 index 0000000..8bdee94 --- /dev/null +++ b/Classes/Middleware/CorsHeaders.php @@ -0,0 +1,63 @@ + $this->urlToDomain((string)$site->getBase()), + GeneralUtility::makeInstance(SiteFinder::class)?->getAllSites() ?? [])); + + if (in_array($origin, $siteUrls)) { + return true; + } + + // 2. Check trustedHostsPattern + $trustedHostsPattern = $GLOBALS['TYPO3_CONF_VARS']['SYS']['trustedHostsPattern'] ?? 'SERVER_NAME'; + $verifier = new VerifyHostHeader($trustedHostsPattern); + + return $verifier->isAllowedHostHeaderValue($origin, $serverParams); + } + + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface + { + $isPreflight = $request->getMethod() === 'OPTIONS'; + + if ($isPreflight || $this->isSemantilizerRequest($request)) { + $origin = $this->urlToDomain($request->getHeaderLine('Origin')); + + if ($origin && $this->isOriginAllowed($origin, $request->getServerParams())) { + return ($isPreflight ? new Response(null, 204) : $handler->handle($request)) + ->withHeader('Access-Control-Allow-Origin', $origin) + ->withHeader('Access-Control-Allow-Methods', 'GET, OPTIONS') + ->withHeader('Access-Control-Allow-Headers', 'X-Semantilizer') + ->withHeader('Access-Control-Allow-Credentials', 'true'); + } + } + + // Go your way … + return $handler->handle($request); + } +} diff --git a/Classes/Middleware/Request.php b/Classes/Middleware/Request.php deleted file mode 100644 index 8004942..0000000 --- a/Classes/Middleware/Request.php +++ /dev/null @@ -1,31 +0,0 @@ -isSemantilizerRequest($request) - && GeneralUtility::makeInstance(Context::class)->getPropertyFromAspect('backend.user', 'isLoggedIn', false) - && $GLOBALS['TSFE'] instanceof TypoScriptFrontendController - && $GLOBALS['TSFE']->set_no_cache(sprintf('Semantilizer frontend request (%s, line %d)', self::class, __LINE__)); - } catch (AspectNotFoundException $e) { - } - - // Go your way … - return $handler->handle($request); - } -} diff --git a/Classes/Middleware/UserTsConfig.php b/Classes/Middleware/UserTsConfig.php index 0dedf2e..2b2bc08 100644 --- a/Classes/Middleware/UserTsConfig.php +++ b/Classes/Middleware/UserTsConfig.php @@ -14,7 +14,7 @@ class UserTsConfig extends AbstractMiddleware public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface { // Disable the admin panel - $this->isSemantilizerRequest($request) + $this->isSemantilizerRequest($request, true) && ExtensionManagementUtility::isLoaded('adminpanel') && ExtensionManagementUtility::addUserTSConfig('admPanel.hide = 1'); diff --git a/Configuration/ContentSecurityPolicies.php b/Configuration/ContentSecurityPolicies.php new file mode 100644 index 0000000..4429af5 --- /dev/null +++ b/Configuration/ContentSecurityPolicies.php @@ -0,0 +1,26 @@ +getBase(), '/')), + ); + }, GeneralUtility::makeInstance(SiteFinder::class)?->getAllSites() ?? []) + ) +]); diff --git a/Configuration/RequestMiddlewares.php b/Configuration/RequestMiddlewares.php index 55bd69b..99b5864 100644 --- a/Configuration/RequestMiddlewares.php +++ b/Configuration/RequestMiddlewares.php @@ -2,12 +2,18 @@ return [ 'frontend' => [ - 'zeroseven/z7_semantilizer/request' => [ - 'target' => \Zeroseven\Semantilizer\Middleware\Request::class, + 'zeroseven/z7_semantilizer/cache-control' => [ + 'target' => \Zeroseven\Semantilizer\Middleware\CacheControl::class, 'after' => [ 'typo3/cms-frontend/tsfe' ] ], + 'zeroseven/z7_semantilizer/cors-headers' => [ + 'target' => \Zeroseven\Semantilizer\Middleware\CorsHeaders::class, + 'after' => [ + 'typo3/cms-frontend/csp-headers' + ] + ], 'zeroseven/z7_semantilizer/user-ts-config' => [ 'target' => \Zeroseven\Semantilizer\Middleware\UserTsConfig::class, 'before' => [ diff --git a/Resources/Private/JavaScript/Backend/Semantilizer.ts b/Resources/Private/JavaScript/Backend/Semantilizer.ts index 6e5fc12..0de061f 100644 --- a/Resources/Private/JavaScript/Backend/Semantilizer.ts +++ b/Resources/Private/JavaScript/Backend/Semantilizer.ts @@ -64,6 +64,7 @@ export class Semantilizer { }; request.open('GET', (this.url.indexOf('#') < 0 ? this.url : this.url.substr(0, this.url.indexOf('#'))) + '#' + Math.random().toString(36).slice(2), true); + request.withCredentials = true; request.setRequestHeader('X-Semantilizer', 'true'); request.send(); } diff --git a/Resources/Public/JavaScript/Backend/Semantilizer.js b/Resources/Public/JavaScript/Backend/Semantilizer.js index a42efcc..99b56ef 100644 --- a/Resources/Public/JavaScript/Backend/Semantilizer.js +++ b/Resources/Public/JavaScript/Backend/Semantilizer.js @@ -1 +1 @@ -import{Headline}from"@zeroseven/semantilizer/Headline.js";import{Module}from"@zeroseven/semantilizer/Module.js";import{Notification}from"@zeroseven/semantilizer/Notification.js";import{Cast}from"@zeroseven/semantilizer/Cast.js";import{Issue}from"@zeroseven/semantilizer/Issue.js";import AjaxDataHandler from"@typo3/backend/ajax-data-handler.js";class Semantilizer{constructor(e,t,...i){this.url=e,this.contentSelectors=Cast.array(i||"body"),this.module=new Module(document.getElementById(t),this),this.notification=new Notification(this),this.headlines=[],this.issues=[],this.validate=this.validate.bind(this),this.init()}collect(e){let i=new XMLHttpRequest;this.headlines.length=0,i.onreadystatechange=()=>{if(4===i.readyState){if(200===i.status){const t=(new DOMParser).parseFromString(i.responseText,"text/html");this.contentSelectors.map(e=>t.querySelector(e)).filter(e=>e).forEach(e=>{this.headlines.push(...Cast.array((e||t).querySelectorAll("h1, h2, h3, h4, h5, h6")).map(e=>new Headline(e,this)))})}"function"==typeof e&&e(i)}},i.open("GET",(this.url.indexOf("#")<0?this.url:this.url.substr(0,this.url.indexOf("#")))+"#"+Math.random().toString(36).slice(2),!0),i.setRequestHeader("X-Semantilizer","true"),i.send()}validateStructure(){this.issues.length=0;var e,t,i=()=>{this.headlines.forEach((e,t)=>{t&&e.type>this.headlines[t-1].type+1&&this.issues.push(Issue.headingStructure(e))})};this.headlines.length&&(e=this.headlines[0],0===(t=this.headlines.filter(e=>1===e.type)).length&&this.issues.push(Issue.mainHeadingRequired(e,1)),1{1===e.type&&this.issues.push(Issue.mainHeadingNumber(e,t?2:null))}),1===t.length&&1!==e.type&&(this.issues.push(Issue.mainHeadingPosition(e,1)),this.headlines.forEach((e,t)=>{t&&1===e.type&&this.issues.push(Issue.mainHeadingPosition(e,2))})),i())}validate(){this.validateStructure(),this.module.drawStructure(),this.notification.hideAll(),this.notification.autoload.enabled()&&this.notification.showIssues()}refresh(e){!0===e&&this.module.lockStructure(),this.collect(e=>{200===e.status?this.validate():(this.notification.hideAll(),this.module.drawError())})}revalidate(e){!0===e?(this.module.drawStructure(),this.notification.hideAll(),this.refresh(!0)):this.validate()}update(t){const i=this.headlines.filter(e=>e.isModified()&&e.isEditableType()),a={data:{}};let r=!1;i.forEach(e=>{var t=e.edit.table,i=e.edit.uid,s=e.edit.field;a.data[t]=a.data[t]||{},a.data[t][i]={},a.data[t][i][s]=e.type,e.hasRelations()&&(r=!0)}),Object.keys(a).length&&AjaxDataHandler.process(a).then(e=>{e.hasErrors||this.revalidate(r),i.forEach(e=>e.hasStored()),"function"==typeof t&&t(e)})}init(){this.refresh(),this.module.loader(),document.addEventListener("drop",e=>{e.target.classList.contains("t3js-page-ce-dropzone-available")&&this.refresh(!0)})}}export{Semantilizer}; \ No newline at end of file +import{Headline}from"@zeroseven/semantilizer/Headline.js";import{Module}from"@zeroseven/semantilizer/Module.js";import{Notification}from"@zeroseven/semantilizer/Notification.js";import{Cast}from"@zeroseven/semantilizer/Cast.js";import{Issue}from"@zeroseven/semantilizer/Issue.js";import AjaxDataHandler from"@typo3/backend/ajax-data-handler.js";class Semantilizer{constructor(e,t,...i){this.url=e,this.contentSelectors=Cast.array(i||"body"),this.module=new Module(document.getElementById(t),this),this.notification=new Notification(this),this.headlines=[],this.issues=[],this.validate=this.validate.bind(this),this.init()}collect(e){let i=new XMLHttpRequest;this.headlines.length=0,i.onreadystatechange=()=>{if(4===i.readyState){if(200===i.status){const t=(new DOMParser).parseFromString(i.responseText,"text/html");this.contentSelectors.map(e=>t.querySelector(e)).filter(e=>e).forEach(e=>{this.headlines.push(...Cast.array((e||t).querySelectorAll("h1, h2, h3, h4, h5, h6")).map(e=>new Headline(e,this)))})}"function"==typeof e&&e(i)}},i.open("GET",(this.url.indexOf("#")<0?this.url:this.url.substr(0,this.url.indexOf("#")))+"#"+Math.random().toString(36).slice(2),!0),i.withCredentials=!0,i.setRequestHeader("X-Semantilizer","true"),i.send()}validateStructure(){this.issues.length=0;var e,t,i=()=>{this.headlines.forEach((e,t)=>{t&&e.type>this.headlines[t-1].type+1&&this.issues.push(Issue.headingStructure(e))})};this.headlines.length&&(e=this.headlines[0],0===(t=this.headlines.filter(e=>1===e.type)).length&&this.issues.push(Issue.mainHeadingRequired(e,1)),1{1===e.type&&this.issues.push(Issue.mainHeadingNumber(e,t?2:null))}),1===t.length&&1!==e.type&&(this.issues.push(Issue.mainHeadingPosition(e,1)),this.headlines.forEach((e,t)=>{t&&1===e.type&&this.issues.push(Issue.mainHeadingPosition(e,2))})),i())}validate(){this.validateStructure(),this.module.drawStructure(),this.notification.hideAll(),this.notification.autoload.enabled()&&this.notification.showIssues()}refresh(e){!0===e&&this.module.lockStructure(),this.collect(e=>{200===e.status?this.validate():(this.notification.hideAll(),this.module.drawError())})}revalidate(e){!0===e?(this.module.drawStructure(),this.notification.hideAll(),this.refresh(!0)):this.validate()}update(t){const i=this.headlines.filter(e=>e.isModified()&&e.isEditableType()),a={data:{}};let r=!1;i.forEach(e=>{var t=e.edit.table,i=e.edit.uid,s=e.edit.field;a.data[t]=a.data[t]||{},a.data[t][i]={},a.data[t][i][s]=e.type,e.hasRelations()&&(r=!0)}),Object.keys(a).length&&AjaxDataHandler.process(a).then(e=>{e.hasErrors||this.revalidate(r),i.forEach(e=>e.hasStored()),"function"==typeof t&&t(e)})}init(){this.refresh(),this.module.loader(),document.addEventListener("drop",e=>{e.target.classList.contains("t3js-page-ce-dropzone-available")&&this.refresh(!0)})}}export{Semantilizer}; \ No newline at end of file From 26cb45462569ad04700afe6cd42750a225492fba Mon Sep 17 00:00:00 2001 From: Raphael Thanner Date: Fri, 26 Sep 2025 22:54:37 +0200 Subject: [PATCH 2/5] Run php-cs-fixer --- .php-cs-fixer.cache | 1 + Classes/Middleware/CorsHeaders.php | 6 ++++-- Configuration/ContentSecurityPolicies.php | 1 + 3 files changed, 6 insertions(+), 2 deletions(-) create mode 100644 .php-cs-fixer.cache diff --git a/.php-cs-fixer.cache b/.php-cs-fixer.cache new file mode 100644 index 0000000..7f07983 --- /dev/null +++ b/.php-cs-fixer.cache @@ -0,0 +1 @@ +{"php":"8.3.21","version":"3.87.2:v3.87.2#da5f0a7858c79b56fc0b8c36d3efcfe5f37f0992","indent":" ","lineEnding":"\n","rules":{"doctrine_annotation_array_assignment":{"operator":":"},"doctrine_annotation_braces":true,"doctrine_annotation_indentation":true,"doctrine_annotation_spaces":{"before_array_assignments_colon":false},"blank_line_after_namespace":true,"braces_position":true,"class_definition":true,"constant_case":true,"control_structure_braces":true,"control_structure_continuation_position":true,"elseif":true,"function_declaration":true,"indentation_type":true,"line_ending":true,"lowercase_keywords":true,"method_argument_space":{"on_multiline":"ensure_fully_multiline"},"no_break_comment":true,"no_closing_tag":true,"no_multiple_statements_per_line":true,"no_space_around_double_colon":true,"no_spaces_after_function_name":true,"no_trailing_whitespace":true,"no_trailing_whitespace_in_comment":true,"single_blank_line_at_eof":true,"single_class_element_per_statement":{"elements":["property"]},"single_import_per_statement":true,"single_line_after_imports":true,"single_space_around_construct":{"constructs_followed_by_a_single_space":["abstract","as","case","catch","class","do","else","elseif","final","for","foreach","function","if","interface","namespace","private","protected","public","static","switch","trait","try","use_lambda","while"],"constructs_preceded_by_a_single_space":["as","else","elseif","use_lambda"]},"spaces_inside_parentheses":true,"statement_indentation":true,"switch_case_semicolon_to_colon":true,"switch_case_space":true,"visibility_required":{"elements":["method","property"]},"encoding":true,"full_opening_tag":true,"array_syntax":{"syntax":"short"},"blank_line_after_opening_tag":true,"braces":{"allow_single_line_closure":true},"cast_spaces":{"space":"none"},"compact_nullable_typehint":true,"concat_space":{"spacing":"one"},"declare_equal_normalize":{"space":"none"},"dir_constant":true,"function_typehint_space":true,"general_phpdoc_annotation_remove":{"annotations":["author"]},"single_line_comment_style":true,"lowercase_cast":true,"modernize_types_casting":true,"native_function_casing":true,"new_with_braces":true,"no_alias_functions":true,"no_blank_lines_after_phpdoc":true,"no_empty_phpdoc":true,"no_empty_statement":true,"no_extra_blank_lines":true,"no_leading_import_slash":true,"no_leading_namespace_whitespace":true,"no_null_property_initialization":true,"no_short_bool_cast":true,"no_singleline_whitespace_before_semicolons":true,"no_superfluous_elseif":true,"no_trailing_comma_in_singleline_array":true,"no_unneeded_control_parentheses":true,"no_unused_imports":true,"no_useless_else":true,"no_whitespace_in_blank_line":true,"ordered_imports":true,"php_unit_construct":{"assertions":["assertEquals","assertSame","assertNotEquals","assertNotSame"]},"php_unit_mock_short_will_return":true,"php_unit_test_case_static_method_calls":{"call_type":"self"},"phpdoc_no_access":true,"phpdoc_no_empty_return":true,"phpdoc_no_package":true,"phpdoc_scalar":true,"phpdoc_trim":true,"phpdoc_types":true,"phpdoc_types_order":{"null_adjustment":"always_last","sort_algorithm":"none"},"return_type_declaration":{"space_before":"none"},"single_quote":true,"single_trait_insert_per_statement":true,"whitespace_after_comma_in_array":true},"hashes":{"Configuration\/TCA\/Overrides\/sys_template.php":"92e86f39f93cb72d0eb8c38894ca215d","Configuration\/TCA\/Overrides\/tt_content.php":"c6eb2424644c240eb7a09dabda4bdc05","Configuration\/RequestMiddlewares.php":"1212fa6ec0b5259677a7f8bf292db94e","Configuration\/JavaScriptModules.php":"844ae18e2a70c826c073ad0f95ea2834","Configuration\/ContentSecurityPolicies.php":"dec36396a43443c5577ab67176ca0205","ext_emconf.php":"c7aa7dbfe9caeb14a0a510fa4501345b","Classes\/Middleware\/UserTsConfig.php":"d1710f31b15b6716dd7f55547df0c05b","Classes\/Middleware\/CacheControl.php":"4c9cbc94a387c86ccc4db77cff0fc151","Classes\/Middleware\/CorsHeaders.php":"440dcdcbde878904df548a074649a632","Classes\/Middleware\/AbstractMiddleware.php":"ded78181e1024e9090c1d0bafb5a2af9","Classes\/Events\/ValidationEvent.php":"30ebad3dd4ea76295406e1905524ba7f","Classes\/ViewHelpers\/Headline\/AbstractRelationViewHelper.php":"7732010d930b8ff5076c8964e3442798","Classes\/ViewHelpers\/Headline\/SiblingViewHelper.php":"14ca8f7432172f8a0d799358cad8bd95","Classes\/ViewHelpers\/Headline\/ChildViewHelper.php":"27250a8b1a2c1832aaa2f0952e79166a","Classes\/ViewHelpers\/AbstractHeadlineViewHelper.php":"272d0840cd50a94ff1212b78f2918b47","Classes\/ViewHelpers\/HeadlineViewHelper.php":"25a2d5be64119a8b12716404ccad2d2c"}} \ No newline at end of file diff --git a/Classes/Middleware/CorsHeaders.php b/Classes/Middleware/CorsHeaders.php index 8bdee94..d3905c7 100644 --- a/Classes/Middleware/CorsHeaders.php +++ b/Classes/Middleware/CorsHeaders.php @@ -27,8 +27,10 @@ protected function urlToDomain(string $url): ?string private function isOriginAllowed(string $origin, array $serverParams): bool { // 1. Check urls of site config. NOTE: Base must be a full url definition! - $siteUrls = array_filter(array_map(fn(Site $site) => $this->urlToDomain((string)$site->getBase()), - GeneralUtility::makeInstance(SiteFinder::class)?->getAllSites() ?? [])); + $siteUrls = array_filter(array_map( + fn (Site $site) => $this->urlToDomain((string)$site->getBase()), + GeneralUtility::makeInstance(SiteFinder::class)?->getAllSites() ?? [] + )); if (in_array($origin, $siteUrls)) { return true; diff --git a/Configuration/ContentSecurityPolicies.php b/Configuration/ContentSecurityPolicies.php index 4429af5..49b8858 100644 --- a/Configuration/ContentSecurityPolicies.php +++ b/Configuration/ContentSecurityPolicies.php @@ -1,4 +1,5 @@ Date: Fri, 26 Sep 2025 22:59:51 +0200 Subject: [PATCH 3/5] Remove file .php-cs-fixer.cache --- .php-cs-fixer.cache | 1 - 1 file changed, 1 deletion(-) delete mode 100644 .php-cs-fixer.cache diff --git a/.php-cs-fixer.cache b/.php-cs-fixer.cache deleted file mode 100644 index 7f07983..0000000 --- a/.php-cs-fixer.cache +++ /dev/null @@ -1 +0,0 @@ -{"php":"8.3.21","version":"3.87.2:v3.87.2#da5f0a7858c79b56fc0b8c36d3efcfe5f37f0992","indent":" ","lineEnding":"\n","rules":{"doctrine_annotation_array_assignment":{"operator":":"},"doctrine_annotation_braces":true,"doctrine_annotation_indentation":true,"doctrine_annotation_spaces":{"before_array_assignments_colon":false},"blank_line_after_namespace":true,"braces_position":true,"class_definition":true,"constant_case":true,"control_structure_braces":true,"control_structure_continuation_position":true,"elseif":true,"function_declaration":true,"indentation_type":true,"line_ending":true,"lowercase_keywords":true,"method_argument_space":{"on_multiline":"ensure_fully_multiline"},"no_break_comment":true,"no_closing_tag":true,"no_multiple_statements_per_line":true,"no_space_around_double_colon":true,"no_spaces_after_function_name":true,"no_trailing_whitespace":true,"no_trailing_whitespace_in_comment":true,"single_blank_line_at_eof":true,"single_class_element_per_statement":{"elements":["property"]},"single_import_per_statement":true,"single_line_after_imports":true,"single_space_around_construct":{"constructs_followed_by_a_single_space":["abstract","as","case","catch","class","do","else","elseif","final","for","foreach","function","if","interface","namespace","private","protected","public","static","switch","trait","try","use_lambda","while"],"constructs_preceded_by_a_single_space":["as","else","elseif","use_lambda"]},"spaces_inside_parentheses":true,"statement_indentation":true,"switch_case_semicolon_to_colon":true,"switch_case_space":true,"visibility_required":{"elements":["method","property"]},"encoding":true,"full_opening_tag":true,"array_syntax":{"syntax":"short"},"blank_line_after_opening_tag":true,"braces":{"allow_single_line_closure":true},"cast_spaces":{"space":"none"},"compact_nullable_typehint":true,"concat_space":{"spacing":"one"},"declare_equal_normalize":{"space":"none"},"dir_constant":true,"function_typehint_space":true,"general_phpdoc_annotation_remove":{"annotations":["author"]},"single_line_comment_style":true,"lowercase_cast":true,"modernize_types_casting":true,"native_function_casing":true,"new_with_braces":true,"no_alias_functions":true,"no_blank_lines_after_phpdoc":true,"no_empty_phpdoc":true,"no_empty_statement":true,"no_extra_blank_lines":true,"no_leading_import_slash":true,"no_leading_namespace_whitespace":true,"no_null_property_initialization":true,"no_short_bool_cast":true,"no_singleline_whitespace_before_semicolons":true,"no_superfluous_elseif":true,"no_trailing_comma_in_singleline_array":true,"no_unneeded_control_parentheses":true,"no_unused_imports":true,"no_useless_else":true,"no_whitespace_in_blank_line":true,"ordered_imports":true,"php_unit_construct":{"assertions":["assertEquals","assertSame","assertNotEquals","assertNotSame"]},"php_unit_mock_short_will_return":true,"php_unit_test_case_static_method_calls":{"call_type":"self"},"phpdoc_no_access":true,"phpdoc_no_empty_return":true,"phpdoc_no_package":true,"phpdoc_scalar":true,"phpdoc_trim":true,"phpdoc_types":true,"phpdoc_types_order":{"null_adjustment":"always_last","sort_algorithm":"none"},"return_type_declaration":{"space_before":"none"},"single_quote":true,"single_trait_insert_per_statement":true,"whitespace_after_comma_in_array":true},"hashes":{"Configuration\/TCA\/Overrides\/sys_template.php":"92e86f39f93cb72d0eb8c38894ca215d","Configuration\/TCA\/Overrides\/tt_content.php":"c6eb2424644c240eb7a09dabda4bdc05","Configuration\/RequestMiddlewares.php":"1212fa6ec0b5259677a7f8bf292db94e","Configuration\/JavaScriptModules.php":"844ae18e2a70c826c073ad0f95ea2834","Configuration\/ContentSecurityPolicies.php":"dec36396a43443c5577ab67176ca0205","ext_emconf.php":"c7aa7dbfe9caeb14a0a510fa4501345b","Classes\/Middleware\/UserTsConfig.php":"d1710f31b15b6716dd7f55547df0c05b","Classes\/Middleware\/CacheControl.php":"4c9cbc94a387c86ccc4db77cff0fc151","Classes\/Middleware\/CorsHeaders.php":"440dcdcbde878904df548a074649a632","Classes\/Middleware\/AbstractMiddleware.php":"ded78181e1024e9090c1d0bafb5a2af9","Classes\/Events\/ValidationEvent.php":"30ebad3dd4ea76295406e1905524ba7f","Classes\/ViewHelpers\/Headline\/AbstractRelationViewHelper.php":"7732010d930b8ff5076c8964e3442798","Classes\/ViewHelpers\/Headline\/SiblingViewHelper.php":"14ca8f7432172f8a0d799358cad8bd95","Classes\/ViewHelpers\/Headline\/ChildViewHelper.php":"27250a8b1a2c1832aaa2f0952e79166a","Classes\/ViewHelpers\/AbstractHeadlineViewHelper.php":"272d0840cd50a94ff1212b78f2918b47","Classes\/ViewHelpers\/HeadlineViewHelper.php":"25a2d5be64119a8b12716404ccad2d2c"}} \ No newline at end of file From 6bf844c758658044a8f2464930ba429c33368edc Mon Sep 17 00:00:00 2001 From: Raphael Thanner Date: Mon, 29 Sep 2025 08:28:02 +0200 Subject: [PATCH 4/5] Update syntax --- .gitignore | 1 + Classes/Middleware/CorsHeaders.php | 12 +++++------- composer.json | 2 +- 3 files changed, 7 insertions(+), 8 deletions(-) diff --git a/.gitignore b/.gitignore index 1dfed6c..b151849 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .php_cs.cache +.php-cs-fixer.cache .idea/ /node_modules /composer.lock diff --git a/Classes/Middleware/CorsHeaders.php b/Classes/Middleware/CorsHeaders.php index d3905c7..7695189 100644 --- a/Classes/Middleware/CorsHeaders.php +++ b/Classes/Middleware/CorsHeaders.php @@ -17,11 +17,9 @@ class CorsHeaders extends AbstractMiddleware { protected function urlToDomain(string $url): ?string { - if (empty($url)) { - return null; - } - - return parse_url($url, PHP_URL_SCHEME) . '://' . parse_url($url, PHP_URL_HOST); + return ($parts = parse_url($url)) && isset($parts['scheme'], $parts['host']) + ? $parts['scheme'] . '://' . $parts['host'] + : null; } private function isOriginAllowed(string $origin, array $serverParams): bool @@ -29,7 +27,7 @@ private function isOriginAllowed(string $origin, array $serverParams): bool // 1. Check urls of site config. NOTE: Base must be a full url definition! $siteUrls = array_filter(array_map( fn (Site $site) => $this->urlToDomain((string)$site->getBase()), - GeneralUtility::makeInstance(SiteFinder::class)?->getAllSites() ?? [] + GeneralUtility::makeInstance(SiteFinder::class)?->getAllSites() )); if (in_array($origin, $siteUrls)) { @@ -40,7 +38,7 @@ private function isOriginAllowed(string $origin, array $serverParams): bool $trustedHostsPattern = $GLOBALS['TYPO3_CONF_VARS']['SYS']['trustedHostsPattern'] ?? 'SERVER_NAME'; $verifier = new VerifyHostHeader($trustedHostsPattern); - return $verifier->isAllowedHostHeaderValue($origin, $serverParams); + return $verifier->isAllowedHostHeaderValue(parse_url($origin, PHP_URL_HOST), $serverParams); } public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface diff --git a/composer.json b/composer.json index 077135a..38b9a6b 100644 --- a/composer.json +++ b/composer.json @@ -63,7 +63,7 @@ "php-cs-fixer fix -v --dry-run --using-cache no --diff" ], "test-php-cs-fixer-fix": [ - "php-cs-fixer fix -v --using-cache false" + "php-cs-fixer fix" ], "prepare-release": [ "rm .editorconfig", From 4c4211da7b1bc3fa3b057ac28891a0d012b2e766 Mon Sep 17 00:00:00 2001 From: Sebastian Rosskopf Date: Mon, 30 Mar 2026 21:05:59 +0200 Subject: [PATCH 5/5] Reconfigure CORS support for multi-site TYPO3 installations with setup docs --- .htaccess.example | 30 +++++++++++ CORS-SETUP.md | 80 ++++++++++++++++++++++++++++ Classes/Middleware/CorsHeaders.php | 66 +++++++++++++++-------- Configuration/RequestMiddlewares.php | 2 +- README.md | 6 +++ nginx-cors.conf.example | 37 +++++++++++++ 6 files changed, 197 insertions(+), 24 deletions(-) create mode 100644 .htaccess.example create mode 100644 CORS-SETUP.md create mode 100644 nginx-cors.conf.example diff --git a/.htaccess.example b/.htaccess.example new file mode 100644 index 0000000..e19e22a --- /dev/null +++ b/.htaccess.example @@ -0,0 +1,30 @@ +# Apache CORS configuration for z7_semantilizer +# +# This configuration is ONLY needed if you want to use the Semantilizer +# across different domains (e.g., editing content on domain-a.com while +# viewing from the TYPO3 backend on domain-b.com). +# +# For same-origin usage, this is NOT required. +# +# Copy the relevant parts to your document root's .htaccess if using Apache + + + + RewriteEngine On + + # Handle preflight OPTIONS requests for semantilizer + # This must be done at webserver level as OPTIONS requests + # never reach PHP middleware in most setups + RewriteCond %{REQUEST_METHOD} OPTIONS + RewriteCond %{HTTP:Access-Control-Request-Headers} (?:^|,\s*)x-semantilizer(?:,|$) [NC] + RewriteCond %{HTTP:Origin} ^https?://(.+)$ [NC] + RewriteRule .* - [R=204,L,E=CORS_ORIGIN:%1] + + # Set CORS headers for preflight response + Header always set Access-Control-Allow-Origin "%{HTTP:Origin}e" env=CORS_ORIGIN + Header always set Access-Control-Allow-Credentials "true" env=CORS_ORIGIN + Header always set Access-Control-Allow-Methods "GET, OPTIONS" env=CORS_ORIGIN + Header always set Access-Control-Allow-Headers "X-Semantilizer" env=CORS_ORIGIN + Header always set Access-Control-Max-Age "86400" env=CORS_ORIGIN + + diff --git a/CORS-SETUP.md b/CORS-SETUP.md new file mode 100644 index 0000000..aa0baa4 --- /dev/null +++ b/CORS-SETUP.md @@ -0,0 +1,80 @@ +# CORS Setup for Multi-Site Usage + +## When is this needed? + +This configuration is **ONLY** required if you want to use the Semantilizer across different domains in a multi-site TYPO3 setup. For example: + +- TYPO3 Backend running on `backend.example.com` +- Site A running on `site-a.example.com` +- Site B running on `site-b.example.com` +- You want to use Semantilizer in the backend to check content on Site A and Site B + +**If you're using Semantilizer only on the same domain, you don't need this setup.** + +## Why is webserver configuration needed? + +When the browser detects a cross-origin request with custom headers (like `X-Semantilizer`), it automatically sends a **CORS preflight request** using the OPTIONS HTTP method. + +This preflight request is handled by your webserver (nginx/Apache) **before** it reaches PHP or TYPO3. By default, most webservers block or reject OPTIONS requests, which causes the "CORS policy" error. + +The PHP middleware in this extension handles the CORS response headers for the actual GET requests, but it **cannot** handle the OPTIONS preflight - that must be done at the webserver level. + +## Setup Instructions + +### For Apache + +1. Ensure `mod_headers` and `mod_rewrite` are enabled +2. Add the configuration from `.htaccess.example` to your document root's `.htaccess` file +3. Restart Apache: `sudo service apache2 restart` + +### For Nginx + +1. Add the configuration from `nginx-cors.conf.example` to your server block +2. Test the configuration: `nginx -t` +3. Reload nginx: `sudo service nginx reload` + +### For DDEV + +**Option 1: nginx_full (recommended for development)** + +1. Copy the nginx configuration to `.ddev/nginx_full/nginx-site.conf` +2. Remove the `#ddev-generated` line at the top +3. Integrate the CORS handling into the `location /` block +4. Run `ddev restart` + +**Option 2: Custom nginx snippet** + +1. Create `.ddev/nginx/semantilizer-cors.conf` with the CORS configuration +2. Run `ddev restart` + +## Testing + +After applying the configuration: + +1. Clear browser cache and cookies +2. Open Firefox/Chrome DevTools → Network tab +3. Use Semantilizer on a cross-origin site +4. You should see: + - An OPTIONS request with status 204 (success) + - The GET request with status 200 and CORS headers + +## Troubleshooting + +**Still getting CORS errors?** + +1. Verify the webserver configuration is active (check error logs) +2. Ensure `trustedHostsPattern` in TYPO3 includes your domains +3. Check that site configurations have proper `base` URLs defined +4. Clear browser cache completely (CORS responses are often cached) + +**OPTIONS request returns 405?** + +The webserver configuration is not active or not correctly placed. The OPTIONS request should never reach PHP - it should be answered by the webserver directly with 204. + +## Security Considerations + +The middleware checks allowed origins against: +1. Site configuration base URLs +2. TYPO3's `trustedHostsPattern` configuration + +Make sure your `trustedHostsPattern` is properly configured and not too permissive (avoid wildcards like `.*` in production). diff --git a/Classes/Middleware/CorsHeaders.php b/Classes/Middleware/CorsHeaders.php index 7695189..860b0aa 100644 --- a/Classes/Middleware/CorsHeaders.php +++ b/Classes/Middleware/CorsHeaders.php @@ -7,7 +7,6 @@ use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\RequestHandlerInterface; -use TYPO3\CMS\Core\Http\Response; use TYPO3\CMS\Core\Middleware\VerifyHostHeader; use TYPO3\CMS\Core\Site\Entity\Site; use TYPO3\CMS\Core\Site\SiteFinder; @@ -17,47 +16,68 @@ class CorsHeaders extends AbstractMiddleware { protected function urlToDomain(string $url): ?string { - return ($parts = parse_url($url)) && isset($parts['scheme'], $parts['host']) - ? $parts['scheme'] . '://' . $parts['host'] - : null; + $parts = parse_url($url); + if (!$parts || !isset($parts['scheme'], $parts['host'])) { + return null; + } + + $domain = $parts['scheme'] . '://' . $parts['host']; + + // Include port if present and not default + if (isset($parts['port'])) { + $isDefaultPort = ($parts['scheme'] === 'https' && $parts['port'] === 443) + || ($parts['scheme'] === 'http' && $parts['port'] === 80); + if (!$isDefaultPort) { + $domain .= ':' . $parts['port']; + } + } + + return $domain; } private function isOriginAllowed(string $origin, array $serverParams): bool { - // 1. Check urls of site config. NOTE: Base must be a full url definition! - $siteUrls = array_filter(array_map( - fn (Site $site) => $this->urlToDomain((string)$site->getBase()), - GeneralUtility::makeInstance(SiteFinder::class)?->getAllSites() - )); + // 1. Check trustedHostsPattern first (works without SiteFinder) + $trustedHostsPattern = $GLOBALS['TYPO3_CONF_VARS']['SYS']['trustedHostsPattern'] ?? 'SERVER_NAME'; + $verifier = new VerifyHostHeader($trustedHostsPattern); - if (in_array($origin, $siteUrls)) { + if ($verifier->isAllowedHostHeaderValue(parse_url($origin, PHP_URL_HOST), $serverParams)) { return true; } - // 2. Check trustedHostsPattern - $trustedHostsPattern = $GLOBALS['TYPO3_CONF_VARS']['SYS']['trustedHostsPattern'] ?? 'SERVER_NAME'; - $verifier = new VerifyHostHeader($trustedHostsPattern); + // 2. Check urls of site config if SiteFinder is available + try { + $siteUrls = array_filter(array_map( + fn (Site $site) => $this->urlToDomain((string)$site->getBase()), + GeneralUtility::makeInstance(SiteFinder::class)?->getAllSites() ?? [] + )); - return $verifier->isAllowedHostHeaderValue(parse_url($origin, PHP_URL_HOST), $serverParams); + if (in_array($origin, $siteUrls)) { + return true; + } + } catch (\Exception $e) { + // SiteFinder not ready yet (e.g., middleware runs very early) + // Already checked trustedHostsPattern above + } + + return false; } public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface { - $isPreflight = $request->getMethod() === 'OPTIONS'; - - if ($isPreflight || $this->isSemantilizerRequest($request)) { - $origin = $this->urlToDomain($request->getHeaderLine('Origin')); + $origin = $this->urlToDomain($request->getHeaderLine('Origin')); - if ($origin && $this->isOriginAllowed($origin, $request->getServerParams())) { - return ($isPreflight ? new Response(null, 204) : $handler->handle($request)) + // Only add CORS headers for actual semantilizer requests (not OPTIONS preflight) + // Note: OPTIONS preflight requests need to be handled by webserver configuration + // (nginx/Apache) as they never reach PHP middleware in most setups + if ($origin && $this->isSemantilizerRequest($request)) { + if ($this->isOriginAllowed($origin, $request->getServerParams())) { + return $handler->handle($request) ->withHeader('Access-Control-Allow-Origin', $origin) - ->withHeader('Access-Control-Allow-Methods', 'GET, OPTIONS') - ->withHeader('Access-Control-Allow-Headers', 'X-Semantilizer') ->withHeader('Access-Control-Allow-Credentials', 'true'); } } - // Go your way … return $handler->handle($request); } } diff --git a/Configuration/RequestMiddlewares.php b/Configuration/RequestMiddlewares.php index 99b5864..af3fde0 100644 --- a/Configuration/RequestMiddlewares.php +++ b/Configuration/RequestMiddlewares.php @@ -11,7 +11,7 @@ 'zeroseven/z7_semantilizer/cors-headers' => [ 'target' => \Zeroseven\Semantilizer\Middleware\CorsHeaders::class, 'after' => [ - 'typo3/cms-frontend/csp-headers' + 'typo3/cms-frontend/site' ] ], 'zeroseven/z7_semantilizer/user-ts-config' => [ diff --git a/README.md b/README.md index 1fbf2d1..b0d44ee 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,12 @@ possibly be there. ## Setup +### Multi-Site / Cross-Origin Setup + +For multi-site TYPO3 installations where the Semantilizer needs to work across different domains, additional webserver configuration may be required. See **[CORS-SETUP.md](CORS-SETUP.md)** for detailed instructions. + +Note: This is only needed for cross-origin scenarios. Same-origin usage works out of the box. + ### Render headlines You’ll need to render your headlines via an extra ViewHelper in order to be able to edit or correct them automatically diff --git a/nginx-cors.conf.example b/nginx-cors.conf.example new file mode 100644 index 0000000..c9b42cc --- /dev/null +++ b/nginx-cors.conf.example @@ -0,0 +1,37 @@ +# Nginx CORS configuration for z7_semantilizer +# +# This configuration is ONLY needed if you want to use the Semantilizer +# across different domains (e.g., editing content on domain-a.com while +# viewing from the TYPO3 backend on domain-b.com). +# +# For same-origin usage, this is NOT required. +# +# Add this to your nginx server block or include it from your main config + +# Handle CORS preflight (OPTIONS) requests +# This is required as OPTIONS requests don't reach PHP in most nginx setups +location / { + # Check if it's an OPTIONS request with X-Semantilizer header + if ($request_method = 'OPTIONS') { + # Check if Access-Control-Request-Headers contains x-semantilizer + set $cors_preflight 0; + if ($http_access_control_request_headers ~* "x-semantilizer") { + set $cors_preflight 1; + } + + # Return 204 for semantilizer preflight + if ($cors_preflight = 1) { + add_header 'Access-Control-Allow-Origin' $http_origin always; + add_header 'Access-Control-Allow-Credentials' 'true' always; + add_header 'Access-Control-Allow-Methods' 'GET, OPTIONS' always; + add_header 'Access-Control-Allow-Headers' 'X-Semantilizer' always; + add_header 'Access-Control-Max-Age' 86400 always; + add_header 'Content-Type' 'text/plain; charset=utf-8' always; + add_header 'Content-Length' 0 always; + return 204; + } + } + + # Your normal location configuration continues here + try_files $uri $uri/ /index.php$is_args$args; +}