Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/Components/ClickToPayAuthenticate.res
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ let make = (
if !isClickToPayAuthenticateError && email !== "" {
let iframe = CommonHooks.createElement("iframe")
iframe.id = "mastercard-account-verification-iframe"
iframe.title = "Click to Pay Authentication"
iframe.width = "100%"
iframe.height = "410px"
let element = ClickToPayHelpers.getElementById(
Expand Down
2 changes: 1 addition & 1 deletion src/Payments/ThreeDSRedirectionModal.res
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ let make = () => {
})
<Modal loader openModal setOpenModal closeCallback=handleOnClose>
<div className="w-full h-[500px] bg-white">
<iframe className="w-full h-full" src={popupUrl} />
<iframe className="w-full h-full" src={popupUrl} title="3D Secure Authentication" />
</div>
</Modal>
}
32 changes: 32 additions & 0 deletions src/Utilities/AccessibilityUtils.res
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,40 @@ external querySelectorAllWithin: (Dom.element, string) => array<Dom.element> = "

@val @scope("document") external activeElement: Nullable.t<Dom.element> = "activeElement"

@set external setTextContent: (Dom.element, string) => unit = "textContent"

let focusableSelector = "button, [href], input, select, textarea, [tabindex]:not([tabindex='-1'])"

let announceFailedSubmit = message => {
switch Window.querySelector("#hyperswitch-sdk-live-alert")->Nullable.toOption {
| Some(alert) =>
alert->setTextContent("")
setTimeout(() => alert->setTextContent(message), 0)->ignore
setTimeout(() => alert->setTextContent(""), 5000)->ignore
| None => ()
}
}

let ensureKnownIframeTitles = () => {
Window.querySelectorAll("iframe")->Array.forEach(iframe => {
let currentTitle = iframe->Window.getAttribute("title")->Nullable.toOption->Option.getOr("")
if currentTitle === "" {
let src = iframe->Window.getAttribute("src")->Nullable.toOption->Option.getOr("")
if src->String.includes("pay.google.com") {
iframe->Window.setAttribute("title", "Google Pay processing frame")
} else if src === "" || src === "about:blank" {
iframe->Window.setAttribute("title", "Secure payment processing frame")
}
}
})
}

let scheduleKnownIframeTitleRepair = () => {
ensureKnownIframeTitles()
setTimeout(ensureKnownIframeTitles, 1000)->ignore
setTimeout(ensureKnownIframeTitles, 3000)->ignore
}
Comment on lines +58 to +62

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why multi timeouts?


let onActivateKeyDown = (~onActivate: unit => unit) => (event: JsxEvent.Keyboard.t) => {
let key = JsxEvent.Keyboard.key(event)
if key == "Enter" || JsxEvent.Keyboard.keyCode(event) == 13 || key == " " {
Expand Down
137 changes: 137 additions & 0 deletions src/Utilities/FocusDelegation.res
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
/*
* Iframe focus delegation.
*
* The SDK renders payment fields inside an iframe embedded in the merchant's
* page. Without help, tabbing past the iframe's last focusable element (or
* shift-tabbing past its first) jumps focus to the top of the parent document
* instead of the next/previous control around the iframe.
*
* This module lets the iframe detect those boundary cases and ask the parent
* page (via `postMessage`) to move focus to the next/previous focusable element
* just outside the iframe. The parent-side handler lives in the hyper-loader
* (see `src/hyper-loader/Elements.res`).
*
* Protocol (flat JSON message): `[("focusDelegation", "next"|"previous"), ("iframeId", <id>)]`.
*/

// Notify the parent page to move focus to the next focusable element after the iframe.
let sendFocusNext = (~iframeId, ~targetOrigin="*") =>
Utils.messageParentWindow(
[("focusDelegation", "next"->JSON.Encode.string), ("iframeId", iframeId->JSON.Encode.string)],
~targetOrigin,
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Critical security concern - targetOrigin="*" in postMessage allows any origin to receive focus delegation messages.

While the message content (focus delegation) is relatively low-risk, this pattern establishes a precedent that could be copied for more sensitive messages. Consider deriving the target origin from the actual merchant origin or using a restrictive whitelist rather than the wildcard.

Suggested change
// Notify the parent page to move focus to the next focusable element after the iframe.
let sendFocusNext = (~iframeId, ~targetOrigin) =>
Utils.messageParentWindow(
[("focusDelegation", "next"->JSON.Encode.string), ("iframeId", iframeId->JSON.Encode.string)],
~targetOrigin,
)

// Notify the parent page to move focus to the previous focusable element before the iframe.
let sendFocusPrevious = (~iframeId, ~targetOrigin="*") =>
Utils.messageParentWindow(
[
("focusDelegation", "previous"->JSON.Encode.string),
("iframeId", iframeId->JSON.Encode.string),
],
~targetOrigin,
)

// All focusable descendants of `container`, in DOM order.
let getFocusableElements = (container: Dom.element): array<Dom.element> =>
container->AccessibilityUtils.querySelectorAllWithin(AccessibilityUtils.focusableSelector)

let parentFocusableSelector = "iframe, " ++ AccessibilityUtils.focusableSelector

// --- Parent-side handling -------------------------------------------------
//
// Runs in the merchant's page (outside the iframe). On receiving a
// `focusDelegation` message it locates the SDK iframe by `iframeId`, finds its
// position among the parent document's focusable elements, and moves focus to
// the next/previous one. Defensive: any missing element is a no-op.

// Index of `target` in `elements` via reference equality (no Array.indexOf for
// Dom.element in the stdlib).
let indexOfElement = (elements: array<Dom.element>, target: Dom.element): option<int> => {
let found = ref(None)
elements->Array.forEachWithIndex((el, idx) =>
if found.contents === None && el === target {
found := Some(idx)
}
)
found.contents
}

// Resolve the SDK iframe DOM element in the parent document from the `iframeId`
// carried in the message. The iframe inside the SDK only knows its logical
// `iframeId` (the merchant's mount selector string); the actual element in the
// parent is mounted with an `orca-...-iframeRef-<iframeId>` id (see
// `LoaderPaymentElement.buildIframeHtmlString`). Try the known element-mount
// patterns first, then fall back to the bare id.
let resolveIframeElement = (~iframeId: string): Nullable.t<Dom.element> => {
let candidates = [
"#orca-payment-element-iframeRef-" ++ iframeId,
"#orca-payment-methods-management-element-iframeRef-" ++ iframeId,
"#" ++ iframeId,
]
candidates->Array.reduce(Nullable.null, (acc, selector) =>
switch acc->Nullable.toOption {
| Some(_) => acc
| None => Window.querySelector(selector)
}
)
}

// Move focus to the next ("next") or previous ("previous") focusable element in
// the parent document, relative to the SDK iframe identified by `iframeId`.
let handleParentFocusDelegation = (~direction: string, ~iframeId: string) => {
try {
switch resolveIframeElement(~iframeId)->Nullable.toOption {
| Some(iframe) =>
let focusable = Window.querySelectorAll(parentFocusableSelector)
switch focusable->indexOfElement(iframe) {
| Some(iframeIdx) =>
let targetIdx = direction === "previous" ? iframeIdx - 1 : iframeIdx + 1
switch focusable->Array.get(targetIdx) {
| Some(target) => target->AccessibilityUtils.focus
| None => () // boundary of the page — nothing to move focus to
}
| None => () // iframe not in the focusable list — no-op
}
| None => () // iframe not found in parent document — no-op
}
} catch {
| _ => () // never let focus delegation break the merchant page
}
}

// Boundary keydown handler for the form root. On `Tab` (no shift) while focus is
// on the LAST focusable element → ask the parent to move focus forward. On
// `Shift+Tab` while focus is on the FIRST focusable element → move focus back.
// In both cases we `preventDefault` so the browser does not first escape to the
// top of the parent document.
let handleBoundaryKeyDown = (
event: JsxEvent.Keyboard.t,
~container: Nullable.t<Dom.element>,
~iframeId,
) => {
if JsxEvent.Keyboard.key(event) === "Tab" {
switch container->Nullable.toOption {
| Some(container) =>
let focusable = [container]->Array.concat(getFocusableElements(container))
switch (focusable->Array.get(0), focusable->Array.get(focusable->Array.length - 1)) {
| (Some(first), Some(last)) =>
let current = AccessibilityUtils.activeElement->Nullable.toOption
if JsxEvent.Keyboard.shiftKey(event) {
// Shift+Tab on the first element → delegate focus to the previous
// element in the parent.
if current === Some(first) {
event->JsxEvent.Keyboard.preventDefault
sendFocusPrevious(~iframeId)
}
} else if current === Some(last) {
// Tab on the last element → delegate focus to the next element in
// the parent.
event->JsxEvent.Keyboard.preventDefault
sendFocusNext(~iframeId)
}
| _ => ()
}
| None => ()
}
}
}
3 changes: 3 additions & 0 deletions src/Utilities/Utils.res
Original file line number Diff line number Diff line change
Expand Up @@ -358,6 +358,7 @@ let mergeJsons = (json1, json2) => {
}

let postFailedSubmitResponse = (~errortype, ~message) => {
AccessibilityUtils.announceFailedSubmit(message)
let errorDict =
[
("type", errortype->JSON.Encode.string),
Expand Down Expand Up @@ -1533,6 +1534,7 @@ let makeIframe = (element, url) => {
iframe.id = "orca-fullscreen"
iframe.src = url
iframe.name = "fullscreen"
iframe.title = "Secure payment dialog"
iframe.style = "position: fixed; inset: 0; width: 100vw; height: 100vh; border: 0; z-index: 422222133323; "
iframe.onload = () => {
resolve(Dict.make())
Expand All @@ -1544,6 +1546,7 @@ let makeHiddenIframe = (element, ~src, ~id) => {
let iframe = Window.createElement("iframe")
iframe->Window.setAttribute("id", id)
iframe->Window.setAttribute("src", src)
iframe->Window.setAttribute("title", "Secure payment processing")
iframe->Window.setAttribute(
"style",
"position: absolute; width: 1px; height: 1px; border: none; overflow: hidden; left: -9999px; top: -9999px;",
Expand Down
1 change: 1 addition & 0 deletions src/Window.res
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ type element = {
mutable crossorigin: string,
mutable \"type": string,
mutable id: string,
mutable title: string,
mutable width: string,
mutable height: string,
remove: unit => unit,
Expand Down
19 changes: 19 additions & 0 deletions src/hyper-loader/Elements.res
Original file line number Diff line number Diff line change
Expand Up @@ -996,10 +996,29 @@ let make = (
}
}

// Accessibility: when a keyboard user tabs past the iframe's last focusable
// element (or shift-tabs past its first), the iframe posts a `focusDelegation`
// message asking us to move focus to the next/previous focusable element
// around the SDK iframe in this (parent) document. Defensive: a missing
// iframe or boundary is a silent no-op.
let handleFocusDelegation = (ev: Types.event) => {
let dict = ev.data->anyTypeToJson->getDictFromJson
switch dict->Dict.get("focusDelegation") {
| Some(direction) =>
let direction = direction->JSON.Decode.string->Option.getOr("")
if direction === "next" || direction === "previous" {
let iframeId = dict->getString("iframeId", "")
FocusDelegation.handleParentFocusDelegation(~direction, ~iframeId)
}
| None => ()
}
}

addSmartEventListener("message", handleApplePayMounted, "onApplePayMount")
addSmartEventListener("message", handlePollStatusMessage, "onPollStatusMsg")
addSmartEventListener("message", handleGooglePayThirdPartyFlow, "onGooglePayThirdParty")
addSmartEventListener("message", handleApplePayThirdPartyFlow, "onApplePayThirdParty")
addSmartEventListener("message", handleFocusDelegation, "onFocusDelegation")

let forwardSessionTokensToIframe = mountedIframeRef => {
sessionTokensDataPromise.contents
Expand Down
7 changes: 5 additions & 2 deletions src/hyper-loader/LoaderPaymentElement.res
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,12 @@ let buildIframeHtmlString = (~iframeId: string, ~iframeSrc: string, ~additionalS
name="${iframeId}"
src="${iframeSrc}"
allow="payment *"
title="Orca Payment Element Frame"
title="Hyperswitch payment element"
sandbox="allow-scripts allow-popups allow-same-origin allow-forms"
style="border: 0px; ${additionalStyle} outline: none;"
width="100%"
></iframe>`
></iframe>`

// Multi-instance support: tracks unmounted element refs so sibling mount() calls can
// adopt handler-only instances (e.g. React wrapper) that never mount themselves.
let unclaimedSelectorRefsByType: Dict.t<array<ref<string>>> = Dict.make()
Expand Down Expand Up @@ -353,6 +354,7 @@ let make = (
let optionsDict = options->getDictFromJson
let handle = (ev: Types.event) => {
let eventDataObject = ev.data->anyTypeToJson
AccessibilityUtils.ensureKnownIframeTitles()

let iframeHeight = eventDataObject->getOptionalJsonFromJson("iframeHeight")
if iframeHeight->Option.isSome {
Expand Down Expand Up @@ -534,6 +536,7 @@ let make = (
</div>`
elem->Window.innerHTML(iframeDiv)
setPaymentIframeRef(Window.querySelector(`#${iframeElementId}`))
AccessibilityUtils.scheduleKnownIframeTitleRepair()

let elem = Window.querySelector(`#${iframeElementId}`)
switch elem->Nullable.toOption {
Expand Down
1 change: 1 addition & 0 deletions src/hyper-loader/PaymentMethodsManagementElements.res
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ let make = (
src="${ApiEndpoint.sdkDomainUrl}/index.html?fullscreenType=${componentType}&publishableKey=${publishableKey}&pmSessionId=${pmSessionId}&sessionId=${sdkSessionId}&endpoint=${endpoint}&hyperComponentName=${hyperComponentName->getStrFromHyperComponentName}&sdkAuthorization=${sdkAuthorization}"
allow="*"
name="orca-payment"
title="Payment Methods Management"
style="outline: none;"
></iframe>
</div>`
Expand Down
1 change: 1 addition & 0 deletions src/hyper-loader/Types.res
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,7 @@ type rec ele = {
mutable id: string,
mutable src: string,
mutable name: string,
mutable title: string,
mutable style: string,
mutable onload: unit => unit,
mutable action: string,
Expand Down
2 changes: 1 addition & 1 deletion src/hyper-loader/UpdateIntentHelpersNew.res
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ let mountPreMountLoaderIframe = (
<iframe
id="orca-payment-element-iframeRef-${selectorString}"
name="orca-payment-element-iframeRef-${selectorString}"
title="Orca Payment Element Frame"
title="Hyperswitch payment setup frame"
src="${ApiEndpoint.sdkDomainUrl}/index.html?fullscreenType=${componentType}&publishableKey=${publishableKey}&clientSecret=${currentClientSecret}&sessionId=${sdkSessionId}&endpoint=${endpoint}&merchantHostname=${merchantHostname}&customPodUri=${customPodUri}&isTestMode=${isTestModeValue}&isSdkParamsEnabled=${isSdkParamsEnabledValue}&sdkAuthorization=${currentSdkAuthorization}"
allow="*"
name="orca-payment"
Expand Down
Loading