[DIAGNOSTIC] delegate fraud-stack sensors to unblock Dodo overlay 3DS (#4449)#4450
Conversation
…DS (#4449) EXPERIMENT: the Dodo overlay-checkout payment hangs at "Processing…" because its nested fraud/3DS stack (Hyperswitch → Airwallex → Sardine) can't read the device sensors our Permissions-Policy disables — accelerometer/bluetooth/gyroscope/ magnetometer were `()` (= blocked for the entire frame tree) and picture-in-picture wasn't granted to the checkout origins. Open them to `*` so the nested cross-origin fraud frames (static.airwallex.com / *.sardine.ai) can run. `*` rather than a scoped origin list because the sensors are needed by DEEPLY NESTED grandchild frames that a top-level origin allowlist can't reliably reach. - vercel.json + docker/nginx-security-headers.conf (kept in sync per the guardrail) - deploy-config.test.mjs: pin the new delegation, drop the 4 sensors from the "disabled" set, retarget the picture-in-picture assertion DIAGNOSTIC for #4449 — verify on a Vercel preview with a real card purchase before merging. If the overlay still hangs, the bottleneck is below our control (the Dodo SDK iframe `allow="payment keyboard-map *"` attribute, or processor-side) → adopt redirect mode and REVERT these directives to `()`. Refs #4449
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
Greptile SummaryThis diagnostic PR loosens the dashboard
Confidence Score: 3/5Do not merge without confirming from the HAR whether the fraud library performs an actual Bluetooth requestDevice() call or only checks API presence — if the latter, bluetooth=* is broader than needed and should be removed before any permanent adoption. The change deliberately opens five browser capabilities to * dashboard-wide. Four of them are well-motivated by the HAR evidence. The bluetooth=* entry is more ambiguous: unlike the passive motion sensors, the Web Bluetooth API allows embedded iframes to trigger a device-picker dialog on any dashboard page, and it is unclear whether the fraud library needs a real Bluetooth scan or only reads navigator.bluetooth !== undefined. The embed-route isolation work is clean and parity tests are thorough, but docker/nginx.conf inline embed headers are only prefix-matched, leaving a potential drift gap. vercel.json and docker/nginx-security-headers.conf carry the sensor delegation that needs scrutiny before any non-diagnostic merge; tests/deploy-config.test.mjs has the embed parity gap worth tightening.
|
| Filename | Overview |
|---|---|
| vercel.json | Loosens dashboard Permissions-Policy: accelerometer/bluetooth/gyroscope/magnetometer changed from () to *, picture-in-picture widened from a named-origin allowlist to *. The bluetooth=* change is qualitatively different from the motion sensors and warrants confirming whether the fraud library needs an actual Bluetooth scan or only capability presence. |
| docker/nginx-security-headers.conf | Mirrors the vercel.json sensor policy loosening (accelerometer=*, bluetooth=*, gyroscope=*, magnetometer=*, picture-in-picture=*) for the Docker/nginx production path. Kept in sync with vercel.json; parity is enforced by the test. |
| docker/nginx-embed-security-headers.conf | New file providing locked-down headers for the embed routes; all 5 newly loosened sensors remain blocked (=()) here. Content matches the vercel.json embed policy and the nginx.conf inline embed headers. |
| docker/nginx.conf | Adds /embed and /embed.html location blocks with inline locked-down headers. Functionally correct, but the inline headers are not fully compared to nginx-embed-security-headers.conf in tests — only prefix-checked via regex. |
| docker/nginx.conf.template | Adds embed location blocks that include embed_security_headers.conf and append Cache-Control. The structure is correct; add_header directives from the include and the block itself are both applied by nginx. |
| tests/deploy-config.test.mjs | Drops the accelerometer/bluetooth/gyroscope/magnetometer disabled-set assertions; adds a fraud-stack delegation test pinning the new =* values; adds an embed-route parity test. The parity assertion covers nginx-embed-security-headers.conf vs vercel.json, but docker/nginx.conf's inline embed headers are only prefix-checked. |
| docker/Dockerfile | Adds COPY docker/nginx-embed-security-headers.conf /etc/nginx/embed_security_headers.conf so the template-based production config can include it. Minimal and correct. |
Flowchart
%%{init: {'theme': 'neutral'}}%%
flowchart TD
A[Incoming Request] --> B{Route}
B -->|catch-all dashboard| C[nginx-security-headers.conf]
B -->|"/embed or /embed.html"| D[nginx-embed-security-headers.conf]
B -->|"/api/"| E[API proxy]
C --> C1["accelerometer=* bluetooth=*\ngyroscope=* magnetometer=*\npicture-in-picture=*\nLOOSENED diagnostic"]
C1 --> C2[All frame-src iframes gain sensor access]
D --> D1["accelerometer=() bluetooth=()\ngyroscope=() magnetometer=()\npicture-in-picture=()\nLOCKED DOWN unchanged"]
C2 --> F[Dodo overlay checkout iframe]
F --> G[fraud stack initBiometricAndTracking]
G --> H{Was level-1 block the only bottleneck?}
H -->|Yes| I[Overlay completes - tighten origins]
H -->|No| J[Adopt redirect mode - revert to empty]
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
flowchart TD
A[Incoming Request] --> B{Route}
B -->|catch-all dashboard| C[nginx-security-headers.conf]
B -->|"/embed or /embed.html"| D[nginx-embed-security-headers.conf]
B -->|"/api/"| E[API proxy]
C --> C1["accelerometer=* bluetooth=*\ngyroscope=* magnetometer=*\npicture-in-picture=*\nLOOSENED diagnostic"]
C1 --> C2[All frame-src iframes gain sensor access]
D --> D1["accelerometer=() bluetooth=()\ngyroscope=() magnetometer=()\npicture-in-picture=()\nLOCKED DOWN unchanged"]
C2 --> F[Dodo overlay checkout iframe]
F --> G[fraud stack initBiometricAndTracking]
G --> H{Was level-1 block the only bottleneck?}
H -->|Yes| I[Overlay completes - tighten origins]
H -->|No| J[Adopt redirect mode - revert to empty]
Reviews (1): Last reviewed commit: "fix(checkout): keep docker embed headers..." | Re-trigger Greptile
| { "key": "Cross-Origin-Opener-Policy-Report-Only", "value": "same-origin; report-to=\"wm-coop-coep\"" }, | ||
| { "key": "Cross-Origin-Embedder-Policy-Report-Only", "value": "require-corp; report-to=\"wm-coop-coep\"" }, | ||
| { "key": "Permissions-Policy", "value": "camera=(), microphone=(), geolocation=(self), accelerometer=(), autoplay=(self \"https://www.youtube.com\" \"https://www.youtube-nocookie.com\"), bluetooth=(), display-capture=(), encrypted-media=(self \"https://www.youtube.com\" \"https://www.youtube-nocookie.com\"), gyroscope=(), hid=(), idle-detection=(), magnetometer=(), midi=(), payment=(self \"https://checkout.dodopayments.com\" \"https://test.checkout.dodopayments.com\" \"https://pay.google.com\" \"https://hooks.stripe.com\" \"https://js.stripe.com\"), picture-in-picture=(self \"https://www.youtube.com\" \"https://www.youtube-nocookie.com\" \"https://challenges.cloudflare.com\"), unload=(*), screen-wake-lock=(), serial=(), usb=(), xr-spatial-tracking=(\"https://challenges.cloudflare.com\")" }, | ||
| { "key": "Permissions-Policy", "value": "camera=(), microphone=(), geolocation=(self), accelerometer=*, autoplay=(self \"https://www.youtube.com\" \"https://www.youtube-nocookie.com\"), bluetooth=*, display-capture=(), encrypted-media=(self \"https://www.youtube.com\" \"https://www.youtube-nocookie.com\"), gyroscope=*, hid=(), idle-detection=(), magnetometer=*, midi=(), payment=(self \"https://checkout.dodopayments.com\" \"https://test.checkout.dodopayments.com\" \"https://pay.google.com\" \"https://hooks.stripe.com\" \"https://js.stripe.com\"), picture-in-picture=*, unload=(*), screen-wake-lock=(), serial=(), usb=(), xr-spatial-tracking=(\"https://challenges.cloudflare.com\")" }, |
There was a problem hiding this comment.
bluetooth=* enables actual Bluetooth device connections, not just API presence
accelerometer, gyroscope, and magnetometer are passive read-only sensor APIs — once allowed by policy they're accessed silently with no user dialog. bluetooth=* is different: the Web Bluetooth API can enumerate paired devices and initiate connections, but the browser always requires an explicit user-facing device-picker prompt before any scan occurs.
The concern here is that every frame already in frame-src (YouTube, Cloudflare Turnstile, Dodo, Google Pay, Stripe, Vercel Live, etc.) now has the policy-level permission to trigger that Bluetooth picker on the dashboard. If the Sardine/Airwallex fraud library only calls navigator.bluetooth !== undefined to detect capability (not actually scan), then bluetooth=* is broader than necessary. It's worth confirming from the HAR whether the library performs an actual requestDevice() call or just checks API presence — if it's the latter, the sensor fix without bluetooth may already be sufficient to unblock it.
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
| assert.ok(!getCspDirectiveTokens(csp, 'script-src').includes("'unsafe-inline'")); | ||
| }); | ||
| } | ||
|
|
||
| it('keeps Docker embed routes on the locked-down embed security headers', () => { | ||
| const nginxTemplate = readFileSync(resolve(__dirname, '../docker/nginx.conf.template'), 'utf-8'); | ||
| assert.match(nginxTemplate, /location = \/embed \{[\s\S]*?include \/etc\/nginx\/embed_security_headers\.conf;/); | ||
| assert.match(nginxTemplate, /location = \/embed\.html \{[\s\S]*?include \/etc\/nginx\/embed_security_headers\.conf;/); | ||
| assert.match(frontendDockerfileSource, /COPY docker\/nginx-embed-security-headers\.conf \/etc\/nginx\/embed_security_headers\.conf/); | ||
| assert.match(dockerNginxSource, /location = \/embed \{[\s\S]*?add_header Permissions-Policy "camera=\(\), microphone=\(\), geolocation=\(\), accelerometer=\(\)/); | ||
| assert.match(dockerNginxSource, /location = \/embed\.html \{[\s\S]*?add_header Permissions-Policy "camera=\(\), microphone=\(\), geolocation=\(\), accelerometer=\(\)/); | ||
|
|
||
| const lockedPolicy = getHeaderValueForSource('/embed', 'Permissions-Policy'); | ||
| const dockerLockedPolicy = getNginxHeaderValueFrom('docker/nginx-embed-security-headers.conf', 'Permissions-Policy'); | ||
| assert.equal(dockerLockedPolicy, lockedPolicy, 'Docker embed Permissions-Policy must match Vercel embed policy'); | ||
| for (const directive of [ | ||
| 'accelerometer=()', | ||
| 'bluetooth=()', | ||
| 'gyroscope=()', | ||
| 'magnetometer=()', | ||
| 'picture-in-picture=()', | ||
| 'payment=()', | ||
| ]) { | ||
| assert.ok(dockerLockedPolicy.includes(directive), `Docker embed policy must keep ${directive}`); | ||
| } | ||
|
|
||
| const dockerEmbedCsp = getNginxHeaderValueFrom('docker/nginx-embed-security-headers.conf', 'Content-Security-Policy'); | ||
| assert.equal(dockerEmbedCsp, getHeaderValueForSource('/embed', 'Content-Security-Policy')); | ||
| }); | ||
| }); | ||
|
|
||
| // Per-route CSP override for the hosted brief magazine. The renderer |
There was a problem hiding this comment.
docker/nginx.conf embed headers are only prefix-checked, not compared for full parity
The parity assertion assert.equal(dockerLockedPolicy, lockedPolicy) compares nginx-embed-security-headers.conf against the Vercel /embed policy — good. But docker/nginx.conf's inline embed Permissions-Policy is only spot-checked via a regex prefix match (camera=\(\), microphone=\(\), geolocation=\(\), accelerometer=\(\)). The same getNginxHeaderValueFrom + assert.equal pattern could be applied to docker/nginx.conf's embed location to catch any future drift between the static dev config and the template-based production path.
#4449) (#4453) HAR-proven ineffective. With accelerometer=* / bluetooth=* / picture-in-picture=* live on prod, the nested Dodo → Airwallex → Sardine frame STILL reported "accelerometer is not allowed in this document" and the payment still hung at "Processing…". The block is the Dodo SDK's own iframe `allow="payment keyboard-map *"` attribute (level 2) plus Dodo's nested-frame delegation (level 3) — both outside our control. The loosening was therefore a pure security regression with zero benefit. Restores accelerometer/bluetooth/gyroscope/magnetometer to `()` and picture-in-picture to the YouTube+Turnstile allowlist across vercel.json, docker/nginx-security-headers.conf, and the deploy-config guardrail. Keeps the unrelated docker/nginx-embed work from #4450. The Dodo overlay is not salvageable from our side; the fix is redirect mode (#4449). Refs #4449 #4450
…y iframe (#4449) (#4454) * fix(checkout): redirect to hosted Dodo checkout instead of the overlay iframe (#4449) The overlay-checkout iframe cannot host Dodo's nested 3DS/fraud stack (Hyperswitch → Airwallex → Sardine): the device sensors it fingerprints with are blocked two frames deep by BOTH our Permissions-Policy AND the Dodo SDK's own iframe `allow` attribute — HAR-confirmed, loosening our header to `*` changed nothing (#4450). Card payments requiring 3DS hung at "Processing…" forever. Switch both checkout surfaces to navigate the top window to Dodo's HOSTED checkout (their documented primary flow): 3DS/fraud run unconstrained top-level, and #4447 returns the customer to /dashboard?wm_checkout=return to reconcile. - src/services/checkout.ts (dashboard) + pro-test/src/services/checkout.ts (/pro): replace DodoPayments.Checkout.open(checkout_url) with a validated window.location.assign. New safeHostedCheckoutUrl() guards open-redirect (https + checkout.dodopayments.com origin only); a missing/untrusted URL falls through to the existing contract-violation handler. - Overlay machinery (Initialize/onEvent/watchdog/openCheckout) left dormant pending removal — the overlay SDK is now never loaded on the dashboard. - Test: checkout-overlay-lifecycle asserts startCheckout navigates (assignedUrls) and does NOT open the overlay (openedUrls empty). Red→green. Refs #4449 #4450 * fix(checkout): address #4454 review — guard /pro return URL + test the redirect guard #1 (P1) /pro pre-marked success: /pro sent Dodo `?wm_checkout=success` as the return_url. With hosted redirect now the primary flow Dodo sends the buyer there for EVERY outcome — a failed/cancelled/pending/no-ID return false-succeeded via the bare success marker (checkout-return.ts:113). Switch /pro to the GUARDED `?wm_checkout=return` contract (mirrors the dashboard / #4447): success reconciles only against authoritative Dodo evidence (subscription_id/payment_id + success status); a non-success return can no longer succeed. #3 (P2) untested open-redirect guard: extract safeHostedCheckoutUrl + HOSTED_CHECKOUT_HOSTS to src/services/hosted-checkout-url.ts (dependency-free, imported by checkout.ts) and add tests/hosted-checkout-url.test.mts — http downgrade, third-party host, look-alike suffix host, unlisted subdomain, javascript: URL, unparseable/empty strings, non-string inputs, plus both valid hosts. 10 cases. #4 (P2) stale comments: update the checkout.ts and pro-test file headers + the dormant redirect_requested comment so they describe redirect as the active flow and the overlay machinery as dormant. #2 (P2 /pro test coverage): not addressed — pro-test has no test runner; adding one is a separate infra task. The shared validator (#3) and the dashboard navigation test cover the redirect logic, which /pro mirrors. Rebuilt public/pro bundle. Refs #4449 #4454. * fix(checkout): #4454 review round 2 — drop dead /pro overlay SDK load + Sentry parity + docs-stats CI docs-stats: the new src/services/hosted-checkout-url.ts bumped serviceTopLevelEntries 187→188 — regenerate docs/generated/stats.json and update the AGENTS.md count. Greptile (a) dead overlay SDK load on /pro: App.tsx called initOverlay() on mount, which dynamically imported the heavy `dodopayments-checkout` SDK and registered a success banner that can never fire under redirect mode (the buyer lands on the dashboard, not /pro). Remove the call (+ now-unused imports). Symmetric with the dashboard, which simply stopped invoking its overlay init. Bonus: initOverlay is now an unused export, so the bundler tree-shakes the overlay SDK chunk out of the /pro bundle entirely (index.esm-*.js removed). Greptile (b) Sentry parity: the /pro missing/untrusted checkout_url path was console.error-only; add a Sentry.captureMessage so the server-contract violation is observable, matching the dashboard's missing-checkout-url path. Rebuilt public/pro. Refs #4449 #4454.
Tests whether the Dodo overlay checkout is salvageable from our side, before we commit to the bigger
displayType:'redirect'change (#4449). Do not merge blindly — see the test plan.Hypothesis
The overlay payment hangs at "Processing…" because its nested fraud/3DS stack — Hyperswitch → Airwallex → Sardine — can't read the device sensors our
Permissions-Policydisables. Per the HAR (#4449),loader.min.js → initBiometricAndTrackingis blocked onaccelerometer/devicemotion/deviceorientation/bluetooth, andpicture-in-pictureis blocked in the checkout frame.Change
Open the 5 fraud-stack sensor features (
accelerometer,bluetooth,gyroscope,magnetometer,picture-in-picture) from()(blocked for the whole frame tree) to*on the dashboardPermissions-Policyonly — embed/locked-down routes are untouched.vercel.json+docker/nginx-security-headers.conf(kept in sync —deploy-config.test.mjsenforces parity).deploy-config.test.mjs: pins the new delegation, drops the 4 sensors from the "disabled" set, retargets thepicture-in-pictureassertion. 81/81 green.Why
*and not a scoped origin list: the sensors are consumed by deeply nested grandchild frames (static.airwallex.com,*.sardine.ai) that a top-level origin allowlist can't reliably reach (Permissions-Policy delegates one frame level at a time, and subdomain wildcards aren't well-supported).Honest caveat
This removes our top-level ceiling (level 1). It may still not be enough: the Dodo SDK's own iframe sets
allow="payment keyboard-map *"(level 2, we don't control it) and Dodo's hosted page controls the Airwallex/Sardine delegation (level 3). The trailing*in the SDK's allow attribute is ambiguous — this experiment tells us whether level 1 was the (sole) blocker.Test plan (decisive)
*with security review) and merge. No leave-the-site UX change needed.().Security note
accelerometer/gyroscope/magnetometer/bluetooth/picture-in-picture=*is a real loosening (any embedded iframe gains these). It is scoped to the dashboard route and is a diagnostic — if we keep it, it needs a security sign-off; if redirect mode wins, revert.Part of #4449 / epic #4440.