Skip to content

fix(procaptcha-puzzle): portal the puzzle overlay so it cannot be trapped in the widget - #3163

Merged
forgetso merged 2 commits into
mainfrom
fix/puzzle-overlay-portal
Sep 1, 2026
Merged

fix(procaptcha-puzzle): portal the puzzle overlay so it cannot be trapped in the widget#3163
forgetso merged 2 commits into
mainfrom
fix/puzzle-overlay-portal

Conversation

@forgetso

@forgetso forgetso commented Sep 1, 2026

Copy link
Copy Markdown
Member

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.

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 (widget-skeleton/src/elements/skeleton.ts:136), because it is the query container Checkbox sizes itself against. A size container applies layout containment, which makes it the containing block for fixed-position descendants.

So inset: 0 resolves against the 302×80 widget box rather than the viewport, and .prosopo-widget__inner's overflow: hidden clips 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's Modal already used. Removing container-type was not an option: the checkbox's @container prosopo-widget queries depend on it, and inline-size would 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-dom moves 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.sh issued a self-signed leaf with no extendedKeyUsage. 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.

  • all local IPv4 addresses in the SANs, not only the default route's (a box with ethernet and wifi up answers on both)
  • 397-day server lifetime, inside Apple's 398-day cap
  • openssl verify gate at the end; the quietened openssl calls had been hiding a failure that produced no server certificate at all
  • install_cert.sh trusts ca.crt, falling back to server.crt for existing certs directories

Verification

  • npm run lint — clean
  • npm run -w @prosopo/procaptcha-puzzle test — 154 passed, no type errors
  • npm run -w @prosopo/procaptcha-puzzle typecheck — clean
  • Confirmed working on the reporter's iPad against a local provider stack

Unit 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.json also picks up a stale 3.8.23.8.3 root version that npm install corrected; unrelated to this change.

🤖 Generated with Claude Code

…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
forgetso enabled auto-merge (squash) September 1, 2026 11:26
@forgetso
forgetso merged commit 37ab95c into main Sep 1, 2026
12 checks passed
@forgetso
forgetso deleted the fix/puzzle-overlay-portal branch September 1, 2026 11:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant