From bd8764523c4b5022128abcaa48e726206590a5e8 Mon Sep 17 00:00:00 2001 From: isaacclad Date: Tue, 21 Jul 2026 22:17:14 +0000 Subject: [PATCH] Fix Paystack Fullscreen permission policy for Inline iframes. Pre-set allow=fullscreen on Paystack iframes before navigation (createElement hook + MutationObserver on allow/src/id), re-apply if Paystack overwrites allow, and send Permissions-Policy HTTP headers so checkout.paystack.com can use fullscreen inside the in-app sheet. --- .../Middleware/PaystackPermissionsPolicy.php | 31 ++++ bootstrap/app.php | 1 + .../views/partials/paystack-sheet.blade.php | 148 +++++++++++++++--- .../views/public/qr/payment-landing.blade.php | 3 +- tests/Feature/ResponsivePaystackSheetTest.php | 5 + 5 files changed, 166 insertions(+), 22 deletions(-) create mode 100644 app/Http/Middleware/PaystackPermissionsPolicy.php diff --git a/app/Http/Middleware/PaystackPermissionsPolicy.php b/app/Http/Middleware/PaystackPermissionsPolicy.php new file mode 100644 index 0000000..8e2b6aa --- /dev/null +++ b/app/Http/Middleware/PaystackPermissionsPolicy.php @@ -0,0 +1,31 @@ +headers->has('Permissions-Policy')) { + $response->headers->set('Permissions-Policy', self::HEADER_VALUE); + } + + return $response; + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index dd0f103..83400b7 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -25,6 +25,7 @@ return Application::configure(basePath: dirname(__DIR__)) $middleware->web(append: [ \App\Http\Middleware\InjectBootSplash::class, \App\Http\Middleware\SetActingAccount::class, + \App\Http\Middleware\PaystackPermissionsPolicy::class, ]); $middleware->alias([ 'platform.session' => \App\Http\Middleware\EnsurePlatformSession::class, diff --git a/resources/views/partials/paystack-sheet.blade.php b/resources/views/partials/paystack-sheet.blade.php index aba40fd..121bc76 100644 --- a/resources/views/partials/paystack-sheet.blade.php +++ b/resources/views/partials/paystack-sheet.blade.php @@ -147,45 +147,151 @@ activePopup = null; } + // Paystack Inline embeds checkout.paystack.com. Browsers default fullscreen to + // 'self', so cross-origin checkout needs allow="fullscreen" before navigation. + // Patch early (createElement hook + MutationObserver) and re-apply if Paystack + // overwrites allow without fullscreen. + var PAYSTACK_IFRAME_ALLOW = 'payment *; fullscreen *; clipboard-read *; clipboard-write *; publickey-credentials-get *'; + var PAYSTACK_ALLOW_FEATURES = ['payment', 'fullscreen', 'clipboard-read', 'clipboard-write', 'publickey-credentials-get']; + + function isPaystackIframe(iframe) { + if (!iframe || !iframe.tagName || iframe.tagName.toUpperCase() !== 'IFRAME') return false; + var id = (iframe.id || '').toLowerCase(); + var name = (iframe.getAttribute('name') || iframe.name || '').toLowerCase(); + var src = ''; + try { src = (iframe.getAttribute('src') || iframe.src || '').toLowerCase(); } catch (e) {} + return src.indexOf('paystack') !== -1 + || src.indexOf('checkout.paystack.com') !== -1 + || id.indexOf('paystack') !== -1 + || id.indexOf('inline-checkout') !== -1 + || id.indexOf('inline-background') !== -1 + || name.indexOf('paystack') !== -1 + || name.indexOf('inline-checkout') !== -1; + } + + function mergeIframeAllow(current) { + var parts = current + ? current.split(';').map(function (p) { return p.trim(); }).filter(Boolean) + : []; + var lower = parts.map(function (p) { return p.toLowerCase(); }); + PAYSTACK_ALLOW_FEATURES.forEach(function (perm) { + if (!lower.some(function (p) { return p === perm || p.indexOf(perm + ' ') === 0; })) { + parts.push(perm + ' *'); + } + }); + return parts.join('; ') || PAYSTACK_IFRAME_ALLOW; + } + + function applyIframePermissions(iframe) { + if (!iframe || !iframe.setAttribute) return; + try { + var next = mergeIframeAllow((iframe.getAttribute('allow') || '').trim()); + if ((iframe.getAttribute('allow') || '') !== next) { + iframe.setAttribute('allow', next); + } + if (!iframe.hasAttribute('allowfullscreen')) { + iframe.setAttribute('allowfullscreen', ''); + } + try { iframe.allowFullscreen = true; } catch (e) {} + } catch (e) {} + } + function ensurePaystackIframePermissions(root) { try { - var nodes = (root || document).querySelectorAll( - 'iframe[src*="checkout.paystack.com"], iframe[id^="inline-checkout"], iframe[id^="inline-background"], iframe[src*="paystack"]' - ); - nodes.forEach(function (iframe) { - var allow = (iframe.getAttribute('allow') || '').trim(); - var needed = ['payment', 'fullscreen', 'clipboard-read', 'clipboard-write']; - var parts = allow ? allow.split(';').map(function (p) { return p.trim(); }).filter(Boolean) : []; - var lower = parts.map(function (p) { return p.toLowerCase(); }); - needed.forEach(function (perm) { - if (!lower.some(function (p) { return p === perm || p.indexOf(perm + ' ') === 0; })) { - parts.push(perm); - } - }); - iframe.setAttribute('allow', parts.join('; ')); - if (!iframe.hasAttribute('allowfullscreen')) { - iframe.setAttribute('allowfullscreen', ''); + var scope = root || document; + var nodes = scope.querySelectorAll ? scope.querySelectorAll('iframe') : []; + for (var i = 0; i < nodes.length; i++) { + // Known Paystack frames, or any frame still missing a src (Paystack + // often inserts empty then sets id/src — pre-allow before navigation). + var iframe = nodes[i]; + var src = ''; + try { src = (iframe.getAttribute('src') || '').trim(); } catch (e) {} + if (isPaystackIframe(iframe) || !src) { + applyIframePermissions(iframe); } - }); + } + } catch (e) {} + } + + function installIframeAllowHook() { + if (window.__ladillIframeAllowHook) return; + window.__ladillIframeAllowHook = true; + try { + var orig = Document.prototype.createElement; + Document.prototype.createElement = function (tagName, options) { + var el = options !== undefined ? orig.call(this, tagName, options) : orig.call(this, tagName); + try { + if (String(tagName).toLowerCase() === 'iframe') { + // Pre-delegate before Paystack assigns src so policy applies on first load. + applyIframePermissions(el); + } + } catch (e) {} + return el; + }; } catch (e) {} } function watchPaystackIframes() { if (window.__ladillPaystackIframeWatch) return; window.__ladillPaystackIframeWatch = true; + installIframeAllowHook(); ensurePaystackIframePermissions(document); + try { var obs = new MutationObserver(function (mutations) { + var dirty = false; for (var i = 0; i < mutations.length; i++) { var m = mutations[i]; - if (m.addedNodes && m.addedNodes.length) { - ensurePaystackIframePermissions(document); - break; + if (m.type === 'attributes' && m.target) { + if (isPaystackIframe(m.target) || (m.target.tagName && m.target.tagName.toUpperCase() === 'IFRAME')) { + // Only force-allow known Paystack frames; for bare iframes wait until id/src set. + if (isPaystackIframe(m.target)) { + applyIframePermissions(m.target); + } else if (m.attributeName === 'id' || m.attributeName === 'src' || m.attributeName === 'name') { + if (isPaystackIframe(m.target)) applyIframePermissions(m.target); + } + dirty = true; + } + continue; + } + if (!m.addedNodes || !m.addedNodes.length) continue; + for (var n = 0; n < m.addedNodes.length; n++) { + var node = m.addedNodes[n]; + if (!node || node.nodeType !== 1) continue; + if (node.tagName && node.tagName.toUpperCase() === 'IFRAME') { + if (isPaystackIframe(node)) applyIframePermissions(node); + else applyIframePermissions(node); // safe pre-allow; Paystack often sets id after insert + dirty = true; + } else if (node.querySelectorAll) { + var nested = node.querySelectorAll('iframe'); + for (var k = 0; k < nested.length; k++) { + applyIframePermissions(nested[k]); + dirty = true; + } + } } } + if (dirty) { + // Second pass after Paystack mutates attributes in the same tick. + ensurePaystackIframePermissions(document); + } + }); + obs.observe(document.documentElement || document.body, { + childList: true, + subtree: true, + attributes: true, + attributeFilter: ['allow', 'src', 'id', 'name'], }); - obs.observe(document.documentElement || document.body, { childList: true, subtree: true }); } catch (e) {} + + // Brief poll during the first seconds after boot/open — covers cases where + // Paystack inserts via paths MutationObserver can miss under heavy load. + var polls = 0; + var pollId = setInterval(function () { + ensurePaystackIframePermissions(document); + polls += 1; + if (polls >= 40) clearInterval(pollId); + }, 250); } diff --git a/resources/views/public/qr/payment-landing.blade.php b/resources/views/public/qr/payment-landing.blade.php index bbbc979..91130a9 100644 --- a/resources/views/public/qr/payment-landing.blade.php +++ b/resources/views/public/qr/payment-landing.blade.php @@ -17,7 +17,8 @@ - + {{-- Permissions-Policy for Paystack is set as an HTTP header (middleware + nginx). + Meta http-equiv is not reliably applied by browsers for this feature. --}} Pay {{ $businessName }} diff --git a/tests/Feature/ResponsivePaystackSheetTest.php b/tests/Feature/ResponsivePaystackSheetTest.php index 41e2610..ef77d35 100644 --- a/tests/Feature/ResponsivePaystackSheetTest.php +++ b/tests/Feature/ResponsivePaystackSheetTest.php @@ -33,6 +33,11 @@ class ResponsivePaystackSheetTest extends TestCase $this->assertStringContainsString('Complete payment on this screen', $html); $this->assertStringContainsString('not in a separate browser', $html); $this->assertStringContainsString('items-end justify-center md:items-center', $html); + // Cross-origin checkout needs fullscreen delegated on the iframe + parent policy. + $this->assertStringContainsString('ensurePaystackIframePermissions', $html); + $this->assertStringContainsString('installIframeAllowHook', $html); + $this->assertStringContainsString('fullscreen *', $html); + $this->assertStringContainsString("attributeFilter: ['allow', 'src', 'id', 'name']", $html); // Must not push Paystack to a separate browser as the primary path. $this->assertStringNotContainsString('Continue to Paystack', $html); $this->assertStringNotContainsString('window.open(', $html);