Fix Paystack Fullscreen permission policy for Inline iframes.
Deploy Ladill Mini / deploy (push) Successful in 44s
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:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -17,7 +17,8 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<meta name="csrf-token" content="{{ $csrf }}">
|
||||
<meta http-equiv="Permissions-Policy" content="payment=(self "https://checkout.paystack.com"), fullscreen=(self "https://checkout.paystack.com"), clipboard-read=(self "https://checkout.paystack.com"), clipboard-write=(self "https://checkout.paystack.com")">
|
||||
{{-- 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>
|
||||
<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" />
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user