mirror of
https://github.com/karatektus/pl_mail.git
synced 2026-10-25 01:54:10 +00:00
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>
111 lines
4.4 KiB
JavaScript
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 },
|
|
);
|