mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge pull request #1005 from Tria-plc/alpha
fix: ( payments ) allow telebirr checkout to bounce through /go
This commit is contained in:
@@ -3,36 +3,47 @@
|
||||
import { Suspense, useEffect, useMemo } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
|
||||
import { isDMoneyCheckoutUrl } from "@/lib/payment-redirect";
|
||||
import { isBounceableCheckoutUrl } from "@/lib/payment-redirect";
|
||||
|
||||
/**
|
||||
* /go — payment redirect bounce page for D-Money web checkout.
|
||||
* /go — payment redirect bounce page for provider web checkout (D-Money, telebirr).
|
||||
*
|
||||
* The redirect is done CLIENT-SIDE on purpose: the navigation must originate
|
||||
* from the loaded https://edrpassenger.triaplc.com/go document so the browser
|
||||
* sends `Referer: https://edrpassenger.triaplc.com` to D-Money. D-Money only
|
||||
* sends `Referer: https://edrpassenger.triaplc.com` to the paygate. D-Money only
|
||||
* whitelists that origin, so a server-side 307 (whose referrer on the redirect
|
||||
* hop is browser-dependent and can be stripped) must NOT be used here.
|
||||
*
|
||||
* Telebirr's paygate has the same requirement — its checkout URL opens when the
|
||||
* portal navigates to it but returns "the required parameter of the request is
|
||||
* empty" when opened with no Referer at all (address-bar paste, or a native
|
||||
* WebView's `loadRequest`). Native clients therefore bounce telebirr through
|
||||
* here too. The portal's own web flow does NOT: it already navigates from the
|
||||
* payment page, so it supplies a Referer without the extra hop.
|
||||
*
|
||||
* It fires immediately (no delay) and paints a bare white full-screen cover
|
||||
* above the sidebar/tab bar (z-40) — no portal chrome, no text on the happy
|
||||
* path. A short message shows only when the link is missing/untrusted.
|
||||
*
|
||||
* `?url=` MUST be percent-encoded by the caller (Uri.encodeComponent() in the
|
||||
* Flutter app / encodeURIComponent() on web); otherwise the query parser
|
||||
* truncates the D-Money URL at its first `&` and merch_code/sign are lost.
|
||||
* See scripts/test-go-redirect.mjs.
|
||||
* truncates the checkout URL at its first `&`. For telebirr this also matters
|
||||
* for the base64 `sign`, whose `+` would otherwise decode to a space.
|
||||
*/
|
||||
|
||||
function RedirectView() {
|
||||
const searchParams = useSearchParams();
|
||||
const raw = searchParams.get("url");
|
||||
const target = useMemo(() => (isDMoneyCheckoutUrl(raw) ? raw : null), [raw]);
|
||||
const target = useMemo(
|
||||
() => (isBounceableCheckoutUrl(raw) ? raw : null),
|
||||
[raw],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!target) return;
|
||||
// Navigate from this document so the D-Money request carries
|
||||
// Referer: https://edrpassenger.triaplc.com (the origin D-Money whitelists).
|
||||
// Navigate from this document so the paygate request carries
|
||||
// Referer: https://edrpassenger.triaplc.com (the origin D-Money whitelists,
|
||||
// and the one telebirr's paygate needs present).
|
||||
window.location.replace(target);
|
||||
}, [target]);
|
||||
|
||||
|
||||
@@ -8,17 +8,60 @@
|
||||
* Every place that follows a `clientAction.url` from `/payments/initiate` runs
|
||||
* it through `resolvePaymentRedirectUrl()`; non-D-Money providers (Telebirr,
|
||||
* Waafi, card) pass through untouched.
|
||||
*
|
||||
* Telebirr's paygate turns out to behave the same way — the checkout URL loads
|
||||
* when the portal navigates to it with `window.location.href` but fails with
|
||||
* "the required parameter of the request is empty" when opened with no Referer
|
||||
* (address-bar paste, or a native WebView's `loadRequest`). It is therefore
|
||||
* accepted by `/go` (see `isBounceableCheckoutUrl`) so non-browser clients can
|
||||
* borrow the portal's origin.
|
||||
*
|
||||
* On web, telebirr is deliberately NOT auto-wrapped: `resolvePaymentRedirectUrl`
|
||||
* still returns telebirr URLs unchanged. The browser flow already supplies a
|
||||
* Referer for free by navigating from the payment page, it is live, and putting
|
||||
* an extra hop in front of it buys nothing.
|
||||
*/
|
||||
|
||||
const ALLOWED_HOSTS = (
|
||||
/** Comma-separated env list → normalized, non-empty, lowercase hosts. */
|
||||
function parseHosts(raw: string): string[] {
|
||||
return raw
|
||||
.split(",")
|
||||
.map((h) => h.trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
const ALLOWED_HOSTS = parseHosts(
|
||||
// Default allows both D-Money environments:
|
||||
// test/sandbox → pgtest.d-money.dj (base domain d-money.dj)
|
||||
// production → pg.d-moneyservice.dj (base domain d-moneyservice.dj)
|
||||
process.env.NEXT_PUBLIC_DMONEY_ALLOWED_HOSTS ?? "d-money.dj,d-moneyservice.dj"
|
||||
)
|
||||
.split(",")
|
||||
.map((h) => h.trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
process.env.NEXT_PUBLIC_DMONEY_ALLOWED_HOSTS ?? "d-money.dj,d-moneyservice.dj",
|
||||
);
|
||||
|
||||
const TELEBIRR_ALLOWED_HOSTS = parseHosts(
|
||||
// Default allows both telebirr environments:
|
||||
// test/sandbox → developerportal.ethiotelebirr.et (base domain ethiotelebirr.et)
|
||||
// production → superapp.ethiomobilemoney.et (base domain ethiomobilemoney.et)
|
||||
// Both serve the paygate on port 38443; the port is not part of the match.
|
||||
process.env.NEXT_PUBLIC_TELEBIRR_ALLOWED_HOSTS ??
|
||||
"ethiotelebirr.et,ethiomobilemoney.et",
|
||||
);
|
||||
|
||||
/** True for https URLs whose host is (or is a subdomain of) one of `allowed`. */
|
||||
function isHttpsHostMatch(
|
||||
raw: string | null | undefined,
|
||||
allowed: string[],
|
||||
): raw is string {
|
||||
if (!raw) return false;
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(raw);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (parsed.protocol !== "https:") return false;
|
||||
const host = parsed.hostname.toLowerCase();
|
||||
return allowed.some((a) => host === a || host.endsWith(`.${a}`));
|
||||
}
|
||||
|
||||
/**
|
||||
* Origin the `/go` bounce page is served from — must be the exact origin D-Money
|
||||
@@ -34,18 +77,27 @@ const BOUNCE_ORIGIN = (
|
||||
|
||||
/** True only for https URLs whose host is (or is a subdomain of) an allowlisted D-Money host. */
|
||||
export function isDMoneyCheckoutUrl(raw: string | null | undefined): raw is string {
|
||||
if (!raw) return false;
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(raw);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (parsed.protocol !== "https:") return false;
|
||||
const host = parsed.hostname.toLowerCase();
|
||||
return ALLOWED_HOSTS.some(
|
||||
(allowed) => host === allowed || host.endsWith(`.${allowed}`),
|
||||
);
|
||||
return isHttpsHostMatch(raw, ALLOWED_HOSTS);
|
||||
}
|
||||
|
||||
/** True only for https URLs whose host is (or is a subdomain of) an allowlisted telebirr host. */
|
||||
export function isTelebirrCheckoutUrl(
|
||||
raw: string | null | undefined,
|
||||
): raw is string {
|
||||
return isHttpsHostMatch(raw, TELEBIRR_ALLOWED_HOSTS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Providers whose paygate `/go` is allowed to bounce to.
|
||||
*
|
||||
* This is the allowlist that keeps `/go` from being an open redirect, so it must
|
||||
* stay tight: only hosts that (a) we actually initiate payments against and
|
||||
* (b) need a portal-origin `Referer` to open.
|
||||
*/
|
||||
export function isBounceableCheckoutUrl(
|
||||
raw: string | null | undefined,
|
||||
): raw is string {
|
||||
return isDMoneyCheckoutUrl(raw) || isTelebirrCheckoutUrl(raw);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user