Files
Paul LütznerandClaude Opus 5 ff29d7ba7d fix(mail): the message frame can measure itself again
Reported from a live instance: message bodies stopped resizing, with
"Executing inline script violates the following Content Security Policy
directive 'script-src 'self' 'nonce-…'" on every open.

A srcdoc frame inherits the EMBEDDING page's Content-Security-Policy on
top of the <meta> one it carries itself, so the height reporter inside it
— the only reason `allow-scripts` is on that sandbox at all — has to
satisfy both. MessageRenderer minted a nonce of its own, which the
frame's policy named and the page's policy had never heard of. The moment
the application itself grew a CSP, the script was blocked by the
inherited one and every message sat at its 80px floor with a scrollbar
inside a scrollbar.

The error names the policy that did it: 'self' appears in the page's
script-src and never in the frame's.

It uses the request's nonce now. Nothing is given away by sharing it —
the nonce is already in that document, beside the email's own markup, and
the whole point of a nonce is that markup which did not come from us
cannot name it. Asking CspNonce for the value is also what tells the
response listener the header must carry one.

WHY THE SUITE DID NOT CATCH IT, which matters more than the fix.

Under APP_DEBUG the full policy is sent Report-Only — deliberately: the
profiler injects inline scripts nothing can nonce. Report-Only does not
block. The browser stack runs debug, so message-frame-height.spec.ts
measured a frame that worked perfectly there while being blocked in
production, where the same policy is ENFORCED. Verified: that spec passes
with this fix reverted.

So the guard is a unit test. It asserts the invariant the enforced policy
depends on — the frame's nonce IS the request's — which is true or false
whichever header the environment happens to send. It fails without the
fix.

Also the two deprecation warnings from the same console:
Turbo.config.forms.confirm replaces the removed-in-future
Turbo.setConfirmMethod, and mobile-web-app-capable joins the Apple-
prefixed meta rather than replacing it — older iOS is the platform that
tag was written for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 12:51:18 +02:00

111 lines
4.4 KiB
JavaScript

/**
* Point Turbo's confirmation at the application's own dialog.
*
* `data-turbo-confirm` calls `window.confirm()` unless Turbo is told otherwise,
* and the browser dialog is the one piece of interface plMail cannot style,
* theme or translate — it also announces the origin at the top, which makes
* "delete this label?" read like a phishing warning. Twenty-two places used it.
*
* One registration rather than twenty-two rewrites, and that is the point: the
* next `data-turbo-confirm` anybody writes is covered without them having to
* know this file exists.
*
* Falls back to `window.confirm` when the dialog is not on the page. That is
* not defensive noise — the sharing and booking pages have their own layout
* which does not include it, and a confirmation that silently returned false
* there would turn a destructive button into one that does nothing, which is
* the worse failure of the two.
*/
import * as Turbo from "@hotwired/turbo";
/**
* Ask the question, and answer true or false.
*
* Exported as well as registered with Turbo, because not every destructive
* action in the app is a link or a form submission — `data-turbo-confirm` is
* only consulted for those two, and a Stimulus controller that posts with
* fetch() would carry the attribute and never be asked anything. Delete
* forever, on a row, is exactly that shape. One function so those callers do
* not each grow their own dialog.
*
* @param {string} message
* @returns {Promise<boolean>}
*/
export function askConfirm(message) {
const dialog = document.getElementById("confirm-dialog");
if (dialog?.plConfirm) {
return dialog.plConfirm(message);
}
// Not defensive noise: the sharing and booking pages use their own layout,
// which does not include the dialog. A confirmation that silently answered
// false there would turn a destructive button into one that does nothing,
// which is the worse of the two failures.
return Promise.resolve(window.confirm(message));
}
// `Turbo.config.forms.confirm`, not the top-level setConfirmMethod: that one is
// deprecated in Turbo 8 and warns on every page load, twice, which is two lines
// of console noise standing between a reader and a real error.
Turbo.config.forms.confirm = askConfirm;
/**
* The same guarantee for forms that opt OUT of Turbo.
*
* `data-turbo="false"` takes a submission away from Turbo entirely, so
* setConfirmMethod above is never consulted and a `data-turbo-confirm` beside
* it is inert — an attribute that looks like a guard and is not, which is worse
* than no attribute at all. templates/admin/_live_frame.html.twig says exactly
* that in a comment; the security page had the combination anyway, and revoking
* every trusted device and switching off two-factor authentication both stopped
* asking anything.
*
* Caught by the specs rather than by review, which is the argument for handling
* it here instead of writing the rule down again: a listener cannot forget, and
* the next form to combine the two is covered by having done nothing.
*
* Capture phase, so it runs before any other submit handler; and the submission
* is re-issued through requestSubmit() once confirmed rather than submit(),
* because submit() skips validation and does not fire this event again — the
* flag is what stops the second pass asking twice.
*/
document.addEventListener(
"submit",
(event) => {
const form = event.target;
if (!(form instanceof HTMLFormElement)) {
return;
}
const question = form.getAttribute("data-turbo-confirm");
// Turbo's own path handles everything else, and asking twice is worse
// than the bug this fixes.
if (null === question || "false" !== form.getAttribute("data-turbo")) {
return;
}
// A string compare, because dataset values are strings — `true ===`
// would never match and the confirmed submission would be intercepted
// again, asking the same question forever.
if ("true" === form.dataset.plConfirmed) {
delete form.dataset.plConfirmed;
return;
}
event.preventDefault();
event.stopImmediatePropagation();
void askConfirm(question).then((confirmed) => {
if (true === confirmed) {
form.dataset.plConfirmed = "true";
form.requestSubmit();
}
});
},
{ capture: true },
);