fix(procaptcha-puzzle): portal the puzzle overlay so it cannot be trapped in the widget - #3163
Merged
Conversation
…pped in the widget PuzzleCanvas renders a full-viewport scrim with `position: fixed; inset: 0`, but rendered it where the widget sits and relied on nothing above it establishing a containing block. The widget skeleton does exactly that: `.prosopo-widget__wrapper` carries `container-type: size` (it is the query container the checkbox sizes itself against), and a size container applies layout containment, making it the containing block for fixed descendants. Where that containment applies, `inset: 0` resolves against the 302x80 widget box rather than the viewport, and `.prosopo-widget__inner`'s `overflow: hidden` clips what remains, so the puzzle renders as an unusable sliver inside the host page. Reported on an iPad running iOS 17.7; recent WebKit does not apply the containment here, which is why it did not reproduce on desktop. The overlay now portals to document.body, the same escape the image captcha's Modal already used. react-dom moves to a runtime dependency to match @prosopo/procaptcha-react. Also fixes the dev certificate scripts, which is how this was reproduced on device. setup_certs.sh issued a self-signed leaf with no extendedKeyUsage: iOS 13+ rejects such a certificate outright with no override, surfacing as "the network connection was lost". It now issues a local CA and a server certificate signed by it, because iOS only lists root CAs under Certificate Trust Settings, so a self-signed leaf can be installed but never trusted. All local IPv4 addresses go into the SANs, and the result is verified against the CA rather than failing silently. install_cert.sh trusts the CA accordingly. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
forgetso
enabled auto-merge (squash)
September 1, 2026 11:26
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The bug
On an iPad (iOS 17.7) the puzzle modal renders inside the host page instead of over it, as an unusable sliver, and cannot be completed.
PuzzleCanvasrenders a full-viewport scrim withposition: fixed; inset: 0, but rendered it where the widget sits and relied on nothing above it establishing a containing block. The widget skeleton does exactly that —.prosopo-widget__wrappercarriescontainer-type: size(widget-skeleton/src/elements/skeleton.ts:136), because it is the query containerCheckboxsizes itself against. A size container applies layout containment, which makes it the containing block for fixed-position descendants.So
inset: 0resolves against the 302×80 widget box rather than the viewport, and.prosopo-widget__inner'soverflow: hiddenclips what's left.Recent WebKit doesn't apply the containment here, which is why this never showed up on desktop or in Playwright's WebKit — only on the older WebKit the iPad ships.
The fix
The overlay portals to
document.body— the same escape the image captcha'sModalalready used. Removingcontainer-typewas not an option: the checkbox's@container prosopo-widgetqueries depend on it, andinline-sizewould establish a containing block too.Leaving the widget's subtree means host-page styles now reach the overlay. Acceptable for the same reason it is on the image modal: every element in the canvas is styled inline.
react-dommoves from a dev to a runtime dependency, matching@prosopo/procaptcha-react.Also in here: the dev certificate scripts
Separable from the fix, but it is how the bug was reproduced on device, and both were real blockers. Happy to split if you'd rather.
setup_certs.shissued a self-signed leaf with noextendedKeyUsage. iOS 13+ rejects such a certificate outright with no override, and Safari reports it as "cannot open the page because the network connection was lost" — which reads like a Wi-Fi fault. Adding the extension got as far as the interstitial, but a self-signed leaf can be installed on iOS and never trusted: Certificate Trust Settings only lists root CAs.So it now issues a local CA plus a server certificate signed by it. Install the CA once and every port on the stack is trusted, which matters because a failed certificate on an XHR gets no interstitial at all — it fails silently and the widget just reports it cannot load.
openssl verifygate at the end; the quietened openssl calls had been hiding a failure that produced no server certificate at allinstall_cert.shtrustsca.crt, falling back toserver.crtfor existing certs directoriesVerification
npm run lint— cleannpm run -w @prosopo/procaptcha-puzzle test— 154 passed, no type errorsnpm run -w @prosopo/procaptcha-puzzle typecheck— cleanUnit test assertions move from the render container to the body, since the portal means the canvas is no longer inside the container it was rendered into.
package-lock.jsonalso picks up a stale3.8.2→3.8.3root version thatnpm installcorrected; unrelated to this change.🤖 Generated with Claude Code