Fix Paystack Fullscreen permission policy for Inline iframes.
Deploy Ladill Mini / deploy (push) Successful in 44s

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.
This commit is contained in:
isaacclad
2026-07-21 22:17:14 +00:00
parent 4291d95518
commit bd8764523c
5 changed files with 166 additions and 22 deletions
@@ -0,0 +1,31 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* Delegate payment-related browser features to Paystack Inline iframes.
*
* Cross-origin checkout.paystack.com needs the top-level document to allow
* fullscreen (and payment/clipboard) via Permissions-Policy. Meta tags are
* not reliably honored this must be an HTTP response header.
*/
class PaystackPermissionsPolicy
{
public const HEADER_VALUE = 'payment=*, fullscreen=*, clipboard-read=*, clipboard-write=*, publickey-credentials-get=*';
public function handle(Request $request, Closure $next): Response
{
/** @var Response $response */
$response = $next($request);
if (! $response->headers->has('Permissions-Policy')) {
$response->headers->set('Permissions-Policy', self::HEADER_VALUE);
}
return $response;
}
}
+1
View File
@@ -25,6 +25,7 @@ return Application::configure(basePath: dirname(__DIR__))
$middleware->web(append: [ $middleware->web(append: [
\App\Http\Middleware\InjectBootSplash::class, \App\Http\Middleware\InjectBootSplash::class,
\App\Http\Middleware\SetActingAccount::class, \App\Http\Middleware\SetActingAccount::class,
\App\Http\Middleware\PaystackPermissionsPolicy::class,
]); ]);
$middleware->alias([ $middleware->alias([
'platform.session' => \App\Http\Middleware\EnsurePlatformSession::class, 'platform.session' => \App\Http\Middleware\EnsurePlatformSession::class,
+123 -17
View File
@@ -147,45 +147,151 @@
activePopup = null; activePopup = null;
} }
function ensurePaystackIframePermissions(root) { // Paystack Inline embeds checkout.paystack.com. Browsers default fullscreen to
try { // 'self', so cross-origin checkout needs allow="fullscreen" before navigation.
var nodes = (root || document).querySelectorAll( // Patch early (createElement hook + MutationObserver) and re-apply if Paystack
'iframe[src*="checkout.paystack.com"], iframe[id^="inline-checkout"], iframe[id^="inline-background"], iframe[src*="paystack"]' // overwrites allow without fullscreen.
); var PAYSTACK_IFRAME_ALLOW = 'payment *; fullscreen *; clipboard-read *; clipboard-write *; publickey-credentials-get *';
nodes.forEach(function (iframe) { var PAYSTACK_ALLOW_FEATURES = ['payment', 'fullscreen', 'clipboard-read', 'clipboard-write', 'publickey-credentials-get'];
var allow = (iframe.getAttribute('allow') || '').trim();
var needed = ['payment', 'fullscreen', 'clipboard-read', 'clipboard-write']; function isPaystackIframe(iframe) {
var parts = allow ? allow.split(';').map(function (p) { return p.trim(); }).filter(Boolean) : []; 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(); }); var lower = parts.map(function (p) { return p.toLowerCase(); });
needed.forEach(function (perm) { PAYSTACK_ALLOW_FEATURES.forEach(function (perm) {
if (!lower.some(function (p) { return p === perm || p.indexOf(perm + ' ') === 0; })) { if (!lower.some(function (p) { return p === perm || p.indexOf(perm + ' ') === 0; })) {
parts.push(perm); parts.push(perm + ' *');
} }
}); });
iframe.setAttribute('allow', parts.join('; ')); 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')) { if (!iframe.hasAttribute('allowfullscreen')) {
iframe.setAttribute('allowfullscreen', ''); iframe.setAttribute('allowfullscreen', '');
} }
}); try { iframe.allowFullscreen = true; } catch (e) {}
} catch (e) {}
}
function ensurePaystackIframePermissions(root) {
try {
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) {} } catch (e) {}
} }
function watchPaystackIframes() { function watchPaystackIframes() {
if (window.__ladillPaystackIframeWatch) return; if (window.__ladillPaystackIframeWatch) return;
window.__ladillPaystackIframeWatch = true; window.__ladillPaystackIframeWatch = true;
installIframeAllowHook();
ensurePaystackIframePermissions(document); ensurePaystackIframePermissions(document);
try { try {
var obs = new MutationObserver(function (mutations) { var obs = new MutationObserver(function (mutations) {
var dirty = false;
for (var i = 0; i < mutations.length; i++) { for (var i = 0; i < mutations.length; i++) {
var m = mutations[i]; var m = mutations[i];
if (m.addedNodes && m.addedNodes.length) { if (m.type === 'attributes' && m.target) {
ensurePaystackIframePermissions(document); if (isPaystackIframe(m.target) || (m.target.tagName && m.target.tagName.toUpperCase() === 'IFRAME')) {
break; // 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 }); obs.observe(document.documentElement || document.body, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['allow', 'src', 'id', 'name'],
});
} catch (e) {} } 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);
} }
@@ -17,7 +17,8 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="csrf-token" content="{{ $csrf }}"> <meta name="csrf-token" content="{{ $csrf }}">
<meta http-equiv="Permissions-Policy" content="payment=(self &quot;https://checkout.paystack.com&quot;), fullscreen=(self &quot;https://checkout.paystack.com&quot;), clipboard-read=(self &quot;https://checkout.paystack.com&quot;), clipboard-write=(self &quot;https://checkout.paystack.com&quot;)"> {{-- Permissions-Policy for Paystack is set as an HTTP header (middleware + nginx).
Meta http-equiv is not reliably applied by browsers for this feature. --}}
<title>Pay {{ $businessName }}</title> <title>Pay {{ $businessName }}</title>
<link rel="preconnect" href="https://fonts.bunny.net"> <link rel="preconnect" href="https://fonts.bunny.net">
<link href="https://fonts.bunny.net/css?family=figtree:400,500,600,700&display=swap" rel="stylesheet" /> <link href="https://fonts.bunny.net/css?family=figtree:400,500,600,700&display=swap" rel="stylesheet" />
@@ -33,6 +33,11 @@ class ResponsivePaystackSheetTest extends TestCase
$this->assertStringContainsString('Complete payment on this screen', $html); $this->assertStringContainsString('Complete payment on this screen', $html);
$this->assertStringContainsString('not in a separate browser', $html); $this->assertStringContainsString('not in a separate browser', $html);
$this->assertStringContainsString('items-end justify-center md:items-center', $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. // Must not push Paystack to a separate browser as the primary path.
$this->assertStringNotContainsString('Continue to Paystack', $html); $this->assertStringNotContainsString('Continue to Paystack', $html);
$this->assertStringNotContainsString('window.open(', $html); $this->assertStringNotContainsString('window.open(', $html);