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: [
|
$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,
|
||||||
|
|||||||
@@ -147,45 +147,151 @@
|
|||||||
activePopup = null;
|
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) {
|
function ensurePaystackIframePermissions(root) {
|
||||||
try {
|
try {
|
||||||
var nodes = (root || document).querySelectorAll(
|
var scope = root || document;
|
||||||
'iframe[src*="checkout.paystack.com"], iframe[id^="inline-checkout"], iframe[id^="inline-background"], iframe[src*="paystack"]'
|
var nodes = scope.querySelectorAll ? scope.querySelectorAll('iframe') : [];
|
||||||
);
|
for (var i = 0; i < nodes.length; i++) {
|
||||||
nodes.forEach(function (iframe) {
|
// Known Paystack frames, or any frame still missing a src (Paystack
|
||||||
var allow = (iframe.getAttribute('allow') || '').trim();
|
// often inserts empty then sets id/src — pre-allow before navigation).
|
||||||
var needed = ['payment', 'fullscreen', 'clipboard-read', 'clipboard-write'];
|
var iframe = nodes[i];
|
||||||
var parts = allow ? allow.split(';').map(function (p) { return p.trim(); }).filter(Boolean) : [];
|
var src = '';
|
||||||
var lower = parts.map(function (p) { return p.toLowerCase(); });
|
try { src = (iframe.getAttribute('src') || '').trim(); } catch (e) {}
|
||||||
needed.forEach(function (perm) {
|
if (isPaystackIframe(iframe) || !src) {
|
||||||
if (!lower.some(function (p) { return p === perm || p.indexOf(perm + ' ') === 0; })) {
|
applyIframePermissions(iframe);
|
||||||
parts.push(perm);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
iframe.setAttribute('allow', parts.join('; '));
|
|
||||||
if (!iframe.hasAttribute('allowfullscreen')) {
|
|
||||||
iframe.setAttribute('allowfullscreen', '');
|
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
} 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,
|
||||||
|
attributes: true,
|
||||||
|
attributeFilter: ['allow', 'src', 'id', 'name'],
|
||||||
});
|
});
|
||||||
obs.observe(document.documentElement || document.body, { childList: true, subtree: true });
|
|
||||||
} 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 "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>
|
<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);
|
||||||
|
|||||||
Reference in New Issue
Block a user