fix: ( payments ) allow telebirr checkout to bounce through /go

This commit is contained in:
Abubeker Yasin
2026-07-29 14:42:30 +03:00
parent e76ff5e159
commit 9a98d460b5
2 changed files with 89 additions and 26 deletions

View File

@@ -3,36 +3,47 @@
import { Suspense, useEffect, useMemo } from "react"; import { Suspense, useEffect, useMemo } from "react";
import { useSearchParams } from "next/navigation"; 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 * The redirect is done CLIENT-SIDE on purpose: the navigation must originate
* from the loaded https://edrpassenger.triaplc.com/go document so the browser * 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 * 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. * 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 * 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 * 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. * path. A short message shows only when the link is missing/untrusted.
* *
* `?url=` MUST be percent-encoded by the caller (Uri.encodeComponent() in the * `?url=` MUST be percent-encoded by the caller (Uri.encodeComponent() in the
* Flutter app / encodeURIComponent() on web); otherwise the query parser * Flutter app / encodeURIComponent() on web); otherwise the query parser
* truncates the D-Money URL at its first `&` and merch_code/sign are lost. * truncates the checkout URL at its first `&`. For telebirr this also matters
* See scripts/test-go-redirect.mjs. * for the base64 `sign`, whose `+` would otherwise decode to a space.
*/ */
function RedirectView() { function RedirectView() {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const raw = searchParams.get("url"); const raw = searchParams.get("url");
const target = useMemo(() => (isDMoneyCheckoutUrl(raw) ? raw : null), [raw]); const target = useMemo(
() => (isBounceableCheckoutUrl(raw) ? raw : null),
[raw],
);
useEffect(() => { useEffect(() => {
if (!target) return; if (!target) return;
// Navigate from this document so the D-Money request carries // Navigate from this document so the paygate request carries
// Referer: https://edrpassenger.triaplc.com (the origin D-Money whitelists). // Referer: https://edrpassenger.triaplc.com (the origin D-Money whitelists,
// and the one telebirr's paygate needs present).
window.location.replace(target); window.location.replace(target);
}, [target]); }, [target]);

View File

@@ -8,17 +8,60 @@
* Every place that follows a `clientAction.url` from `/payments/initiate` runs * Every place that follows a `clientAction.url` from `/payments/initiate` runs
* it through `resolvePaymentRedirectUrl()`; non-D-Money providers (Telebirr, * it through `resolvePaymentRedirectUrl()`; non-D-Money providers (Telebirr,
* Waafi, card) pass through untouched. * 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: // Default allows both D-Money environments:
// test/sandbox → pgtest.d-money.dj (base domain d-money.dj) // test/sandbox → pgtest.d-money.dj (base domain d-money.dj)
// production → pg.d-moneyservice.dj (base domain d-moneyservice.dj) // production → pg.d-moneyservice.dj (base domain d-moneyservice.dj)
process.env.NEXT_PUBLIC_DMONEY_ALLOWED_HOSTS ?? "d-money.dj,d-moneyservice.dj" process.env.NEXT_PUBLIC_DMONEY_ALLOWED_HOSTS ?? "d-money.dj,d-moneyservice.dj",
) );
.split(",")
.map((h) => h.trim().toLowerCase()) const TELEBIRR_ALLOWED_HOSTS = parseHosts(
.filter(Boolean); // 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 * 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. */ /** 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 { export function isDMoneyCheckoutUrl(raw: string | null | undefined): raw is string {
if (!raw) return false; return isHttpsHostMatch(raw, ALLOWED_HOSTS);
let parsed: URL; }
try {
parsed = new URL(raw); /** True only for https URLs whose host is (or is a subdomain of) an allowlisted telebirr host. */
} catch { export function isTelebirrCheckoutUrl(
return false; raw: string | null | undefined,
} ): raw is string {
if (parsed.protocol !== "https:") return false; return isHttpsHostMatch(raw, TELEBIRR_ALLOWED_HOSTS);
const host = parsed.hostname.toLowerCase(); }
return ALLOWED_HOSTS.some(
(allowed) => host === allowed || host.endsWith(`.${allowed}`), /**
); * 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);
} }
/** /**