Skip to content
Merged
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
32 changes: 31 additions & 1 deletion frontend/class-frontend.php
Original file line number Diff line number Diff line change
Expand Up @@ -945,6 +945,16 @@ public function enqueue_scripts() {
* containing ad-blocker keywords. Every FAZ frontend bundle must therefore
* use the same delivery path, not just the main and accessibility bundles.
*
* Known limit: an inline bundle is exposed to page rewriters that parse
* the document as HTML4 (DOMDocument::loadHTML(), e.g. WPSpeed's image
* optimiser). libxml ends a <script> at the first `</` followed by a
* letter, so a `</` inside the bundle's string literals truncates the
* script. The banner template escapes `</` as `<\/` for this reason
* (escape_template_end_tags()), but that is not done here on purpose:
* rewriting `</` in JavaScript source would corrupt regex literals and
* other code where `<\/` is not equivalent. A site that combines this
* option with such a rewriter should leave this option off.
*
* @param string $handle Script handle.
* @param string $relative_path Path relative to frontend/.
* @param array $dependencies Script dependencies.
Expand Down Expand Up @@ -2219,10 +2229,30 @@ public function banner_html() {
// that must appear inline so the renderer finds it synchronously.
// phpcs:ignore WordPress.WP.EnqueuedResources.NonEnqueuedScript -- inert HTML template (type=text/template is non-executable); see comment above.
echo '<script id="fazBannerTemplate" type="text/template">';
echo wp_kses( $html, faz_allowed_html() );
echo self::escape_template_end_tags( wp_kses( $html, faz_allowed_html() ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- already through wp_kses(); only `</` is rewritten.
echo '</script>';
}

/**
* Write every `</` in the banner template as `<\/`.
*
* The template travels inside a <script type="text/template">. HTML5 ends
* that element only at `</script`, but an HTML4 parser — libxml, i.e. PHP's
* DOMDocument::loadHTML() — ends a script at any `</` followed by a letter
* and then throws the stray end tags away. Optimisation plugins that
* rewrite the whole page through DOMDocument (WPSpeed's image optimiser,
* on by default) therefore delivered the template with every closing tag
* removed: the browser nested the whole banner inside the title and the
* consent bar rendered 0 px tall, with no buttons. `<\/` is plain text to
* both parsers; _fazReadBannerTemplate() in script.js turns it back.
*
* @param string $html Sanitised template markup.
* @return string
*/
public static function escape_template_end_tags( $html ) {
return str_replace( '</', '<\/', (string) $html );
}

/**
* Rewrite plugin-asset URLs left over from a previous site address.
*
Expand Down
41 changes: 36 additions & 5 deletions frontend/js/script.js
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,12 @@ ref._fazConsentStore = new Map();
// Build marker — bump when shipping behavioural changes to this file. Lets
// `fazcookie._diag().build` reveal at a glance whether a cache (CDN / optimizer)
// is serving a stale bundle after a plugin update. #auto-show-hardening
const _FAZ_BUILD = '1.33.0+shared-consent-restore';
// Diagnostic only: nothing compares it against the server. The cache buster is
// the `?ver=` on the enqueued script, which follows FAZ_VERSION, so a change
// that couples this file to new server output (like the `<\/` escape in the
// banner template, which an older script.js does not undo) only reaches
// visitors safely once FAZ_VERSION is bumped at release.
const _FAZ_BUILD = '1.33.0+html4-template-escape';

/**
* One-call frontend self-diagnosis for support: paste
Expand Down Expand Up @@ -2007,14 +2012,40 @@ function _fazDomReady(callback) {
if (typeof document === 'undefined') {
return;
}
if (document.readyState === 'complete' || /** DOMContentLoaded + Images/Styles/etc loaded, so we call directly. */
document.readyState === 'interactive' /** DOMContentLoaded fires at this point, so we call directly. */
if (document.readyState === 'complete' || /** DOMContentLoaded + Images/Styles/etc loaded. */
document.readyState === 'interactive' /** DOMContentLoaded fires at this point. */
) {
return void callback();
// Already parsed: the script arrived late — `defer`/`async`, merged into
// a combined bundle (WPSpeed, Autoptimize, WP Rocket), or replayed by a
// "delay JS" feature. Calling back synchronously would run the whole
// init in the middle of this file's evaluation, before the `const`/`let`
// declarations below this point exist: _fazAttachFocusLoop then threw
// "Cannot access '_fazFocusLoopHandlers' before initialization" and the
// banner rendered half-decorated. A microtask runs as soon as the file
// has finished evaluating — the same order as the DOMContentLoaded path.
return void Promise.resolve().then(callback);
} /** DOMContentLoaded has not fired yet, delay callback until then. */
document.addEventListener('DOMContentLoaded', callback);
}

/**
* Read the banner template markup.
*
* The server escapes every `</` in the template as `<\/` (see
* Frontend::banner_html()), so that an HTML4 parser that rewrites the page —
* PHP's DOMDocument::loadHTML(), used by image optimisers such as WPSpeed's —
* cannot read the closing tags as the end of the <script> element and drop
* them. Browsers keep `<\/` as literal text inside a script, so it is undone
* here. Content written client-side (the geo bootstrap) is not escaped, which
* the replacement leaves untouched.
*
* @param {Element} template #fazBannerTemplate.
* @returns {string}
*/
function _fazReadBannerTemplate(template) {
return String(template.innerHTML || '').replace(/<\\\//g, '</');
}

/**
* Callback function to Domready event.
*/
Expand Down Expand Up @@ -2874,7 +2905,7 @@ function _fazRenderBanner() {
// when the banner template cache is empty. Without this check the next
// line throws "Cannot read properties of null (reading 'innerHTML')".
if (!template) return;
const templateHtml = template.innerHTML;
const templateHtml = _fazReadBannerTemplate(template);
const doc = new DOMParser().parseFromString(templateHtml, 'text/html');
_fazSetFooterShadow(doc);
// Insert parsed DOM nodes instead of re-serializing to HTML string.
Expand Down
2 changes: 1 addition & 1 deletion frontend/js/script.min.js

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
<?php
/**
* Plugin Name: FAZ E2E HTML4 Rewriter
* Description: Passes the frontend HTML through DOMDocument::loadHTML()/saveHTML(), as page optimisers such as WPSpeed's image optimiser do, when the request carries ?faz_html4_rewrite=1.
* Version: 0.1.0
*
* @package FazCookieE2E
*/

if ( ! defined( 'ABSPATH' ) ) {
exit;
}

add_action(
'template_redirect',
static function () {
if ( is_admin() || wp_doing_ajax() || wp_doing_cron() || ( defined( 'REST_REQUEST' ) && REST_REQUEST ) ) {
return;
}
if ( ! isset( $_GET['faz_html4_rewrite'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- test fixture switch.
return;
}
// Priority 0: this buffer opens before FAZ's own (priority 10), so it
// receives the page after FAZ has finished with it — the position a
// page optimiser is in.
ob_start(
static function ( $html ) {
if ( ! is_string( $html ) || '' === $html || ! class_exists( 'DOMDocument' ) ) {
return $html;
}
$doc = new DOMDocument();
libxml_use_internal_errors( true );
$loaded = $doc->loadHTML( mb_encode_numericentity( $html, array( 0x80, 0x10FFFF, 0, 0x1FFFFF ), 'UTF-8' ) );
libxml_clear_errors();
if ( ! $loaded ) {
return $html;
}
$out = $doc->saveHTML();
return is_string( $out ) && '' !== $out ? $out : $html;
}
);
},
0
);
2 changes: 2 additions & 0 deletions tests/e2e/fixtures/plugins/faz-e2e-html4-rewriter/index.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
<?php
// Silence is golden.
101 changes: 101 additions & 0 deletions tests/e2e/specs/banner-late-load-and-html4-rewrite.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
/**
* The banner must survive the two things page optimisers do to it.
*
* Reported on wordpress.org ("Doesn't work with WPSpeed by JExtensions"): with
* WPSpeed active the banner did not show. Two independent causes:
*
* 1. WPSpeed's image optimiser (on by default) rewrites the whole page through
* PHP's DOMDocument::loadHTML(). Its HTML4 parser ends a <script> at any
* `</` + letter and drops the stray end tags, so the banner template inside
* <script type="text/template"> lost every closing tag and rendered 0 px tall.
* The server now writes `</` as `<\/` inside the template.
*
* 2. WPSpeed combines and defers the scripts. script.js then runs after the
* document is parsed, _fazDomReady() called the init synchronously in the
* middle of the file, and the init hit `const` declarations that did not
* exist yet ("Cannot access '_fazFocusLoopHandlers' before initialization").
* The late path now waits for the file to finish evaluating.
*
* Neither needs WPSpeed to reproduce: the fixture plugin performs the same
* DOMDocument round trip, and a route adds `defer` to script.js.
*/
import { expect, test, type Page } from '../fixtures/wp-fixture';
import { ensureFixturePlugin, listActivePluginFiles, restoreActivePluginFiles } from '../utils/wp-env';

const WP_BASE = process.env.WP_BASE_URL ?? 'http://127.0.0.1:9998';

function captureRuntimeErrors(page: Page): string[] {
const errors: string[] = [];
page.on('console', (msg) => {
if (msg.type() === 'error' && !/Failed to load resource/.test(msg.text())) errors.push(msg.text());
});
page.on('pageerror', (error) => errors.push(String(error)));
return errors;
}

async function expectWorkingBanner(page: Page, errors: string[]): Promise<void> {
const bar = page.locator('#faz-consent .faz-consent-bar');
await expect(bar).toBeVisible({ timeout: 10_000 });
expect((await bar.boundingBox())?.height ?? 0).toBeGreaterThan(50);
// The three notice buttons sit outside the title once the closing tags survive.
await expect(page.locator('#faz-consent [data-faz-tag="accept-button"]')).toBeVisible();
await expect(page.locator('#faz-consent [data-faz-tag="reject-button"]')).toBeVisible();
await expect(page.locator('#faz-consent [data-faz-tag="settings-button"]')).toBeVisible();
expect(await page.locator('#faz-consent .faz-title button').count()).toBe(0);

await page.locator('#faz-consent [data-faz-tag="reject-button"]').click();
await expect(bar).toBeHidden({ timeout: 5_000 });
const consent = (await page.context().cookies()).find((c) => c.name === 'fazcookie-consent');
expect(consent, 'Reject All stores a consent cookie').toBeTruthy();

expect(errors.join('\n')).not.toMatch(/before initialization/);
expect(errors.join('\n')).not.toMatch(/banner render step failed/);
}

// Not serial: the two tests are independent (the rewriter fixture only acts on
// requests carrying ?faz_html4_rewrite=1, and the first test restores the
// active plugin list in `finally`), so a failure in one must not skip the other.
test.describe('Banner under page optimisers (WPSpeed report)', () => {
test('a DOMDocument (HTML4) rewrite of the page keeps the banner template intact', async ({ page }) => {
test.setTimeout(90_000);
const originalActive = listActivePluginFiles();
ensureFixturePlugin('faz-e2e-html4-rewriter');
try {
await page.context().clearCookies();
const errors = captureRuntimeErrors(page);
const response = await page.goto(`${WP_BASE}/?faz_html4_rewrite=1&t=${Date.now()}`, { waitUntil: 'load' });
const html = (await response?.text()) ?? '';
const template = /<script[^>]*id="?fazBannerTemplate"?[^>]*>([\s\S]*?)<\/script>/i.exec(html)?.[1] ?? '';
expect(template.length, 'template present after the rewrite').toBeGreaterThan(1000);
expect(template, 'closing tags survive, escaped').toContain('<\\/div>');
await expectWorkingBanner(page, errors);
} finally {
restoreActivePluginFiles(originalActive);
}
});

test('script.js loaded with defer (after parsing) still renders a working banner', async ({ page }) => {
test.setTimeout(90_000);
await page.context().clearCookies();
let deferred = false;
// Match on the query marker only, so the test works whatever WP_BASE_URL is.
await page.route((url) => /[?&]faz_defer=/.test(url.search), async (route) => {
const resp = await route.fetch();
const body = (await resp.text()).replace(
/<script([^>]*\bid=["']faz-cookie-manager-js["'][^>]*)>/i,
(_m, attrs: string) => {
deferred = true;
return `<script${attrs} defer>`;
},
);
await route.fulfill({ response: resp, body });
});
const errors = captureRuntimeErrors(page);
// `load`, not the usual `domcontentloaded`: this test is about script.js
// running late, so it waits until the deferred script has certainly
// executed (and its microtask-scheduled init has started) before checking.
await page.goto(`${WP_BASE}/?faz_defer=${Date.now()}`, { waitUntil: 'load' });
expect(deferred, 'the route found the script.js tag').toBe(true);
await expectWorkingBanner(page, errors);
});
});
Loading
Loading