Payment from the Android app: reach Zarinpal through pay.html so it carries our Referer

The app hands every outside URL to the phone browser, which sends no Referer, and Zarinpal's StartPay
refuses a request without our domain as Referer («دسترسی از این دامنه مجاز نمی‌باشد»). Checked against
production on a pending order: no Referer → refused; Referer https://math.tadrisino2.ir/ → forwards to
the bank (sep.shaparak.ir).

- pay.html + src/pay.ts: opened in the phone browser, forwards only to our own
  /api/payment/orders/<id>/pay link (anything else is refused), so the Referer is math.tadrisino2.ir.
  The app opens it as http:// (the WebView keeps only https on its own host); ArvanCloud moves it to
  https, and the link rides in the fragment, which never reaches a server.
- MainActivity.kt (next APK): payment pages (our subdomains, zarinpal, shaparak) stay inside the WebView
  and `paysInApp()` tells the frontend to navigate directly. Not built.

Not checked: a full payment on a real phone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-10 18:40:10 +03:30
parent 468f5b2d29
commit 737414f246
5 changed files with 109 additions and 4 deletions

View File

@@ -48,6 +48,15 @@ class MainActivity : AppCompatActivity() {
/** فقط خودِ سایت و فقط با https؛ «http://دامنه‌ی ما» قابلِ دست‌کاریِ شبکه است و نباید پل داشته باشد. */ /** فقط خودِ سایت و فقط با https؛ «http://دامنه‌ی ما» قابلِ دست‌کاریِ شبکه است و نباید پل داشته باشد. */
private fun isOurPage(uri: Uri?): Boolean = uri != null && uri.scheme == "https" && uri.host == webHost private fun isOurPage(uri: Uri?): Boolean = uri != null && uri.scheme == "https" && uri.host == webHost
/** API و درگاه: زیردامنه‌های دامنه‌ی خودمان (mathapi…)، زرین‌پال و صفحه‌های بانکیِ شاپرک؛ فقط https. پل این‌جا کار نمی‌کند. */
private fun isPaymentPage(uri: Uri?): Boolean {
val host = uri?.host ?: return false
if (uri.scheme != "https") return false
val ours = webHost.substringAfter('.')
return host == ours || host.endsWith(".$ours") ||
host == "zarinpal.com" || host.endsWith(".zarinpal.com") || host.endsWith(".shaparak.ir")
}
@SuppressLint("SetJavaScriptEnabled") @SuppressLint("SetJavaScriptEnabled")
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@@ -66,6 +75,9 @@ class MainActivity : AppCompatActivity() {
// در صفحه‌های خودمان در دسترس است. // در صفحه‌های خودمان در دسترس است.
override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean { override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean {
if (isOurPage(request.url)) return false if (isOurPage(request.url)) return false
// پرداخت با درگاه داخلِ خودِ اپ می‌ماند: زرین‌پال بی‌Refererِ دامنه‌ی ما صفحه را باز نمی‌کند و مرورگرِ
// بیرونی Referer نمی‌فرستد. بعد از پرداخت، زرین‌پال به سایتِ ما برمی‌گرداند و نتیجه همین‌جا دیده می‌شود.
if (isPaymentPage(request.url)) return false
// فقط http(s) به مرورگرِ گوشی می‌رود؛ intent:، file: و schemeهای سفارشی بی‌صدا بسته می‌شوند // فقط http(s) به مرورگرِ گوشی می‌رود؛ intent:، file: و schemeهای سفارشی بی‌صدا بسته می‌شوند
// (پیش‌تر هر آدرسی بیرونِ دامنه به ACTION_VIEW می‌رفت و صفحه می‌توانست اپِ دلخواه باز کند). // (پیش‌تر هر آدرسی بیرونِ دامنه به ACTION_VIEW می‌رفت و صفحه می‌توانست اپِ دلخواه باز کند).
if (request.url.scheme == "https" || request.url.scheme == "http") { if (request.url.scheme == "https" || request.url.scheme == "http") {
@@ -193,6 +205,10 @@ class MainActivity : AppCompatActivity() {
else @Suppress("DEPRECATION") vibrator.vibrate(timings, -1) else @Suppress("DEPRECATION") vibrator.vibrate(timings, -1)
} }
/** فرانت با این می‌فهمد درگاه داخلِ اپ باز می‌شود (isPaymentPage)، نه در مرورگرِ بیرونی. */
@JavascriptInterface
fun paysInApp(): Boolean = true
@JavascriptInterface @JavascriptInterface
fun bazaarConsume(purchaseToken: String) { fun bazaarConsume(purchaseToken: String) {
runOnUiThread { runOnUiThread {

28
pay.html Normal file
View File

@@ -0,0 +1,28 @@
<!doctype html>
<html lang="fa" dir="rtl">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="robots" content="noindex, nofollow" />
<!-- Referer باید از دامنه‌ی خودمان برود؛ زرین‌پال بی‌آن صفحه را باز نمی‌کند (src/pay.ts). -->
<meta name="referrer" content="strict-origin-when-cross-origin" />
<title>رفتن به درگاه — تدریسینو</title>
<script src="/config.js"></script>
<style>
body { margin: 0; min-height: 100vh; display: flex; align-items: center; justify-content: center; background: #f6f5fb; color: #1a1a2e; font-family: Vazirmatn, system-ui, sans-serif; }
main { text-align: center; padding: 24px; max-width: 340px; }
.spin { width: 36px; height: 36px; margin: 0 auto 16px; border-radius: 50%; border: 4px solid #f3c9c2; border-top-color: #ED6956; animation: s 0.8s linear infinite; }
@keyframes s { to { transform: rotate(360deg); } }
a { display: none; margin-top: 16px; padding: 14px 24px; border-radius: 16px; background: #ED6956; color: #fff; font-weight: 800; text-decoration: none; }
p { line-height: 1.9; }
</style>
</head>
<body>
<main>
<div class="spin" id="spin"></div>
<p id="msg">داریم می‌ریم به درگاهِ پرداخت…</p>
<a id="go" href="#">رفتن به درگاه</a>
</main>
<script type="module" src="/src/pay.ts"></script>
</body>
</html>

50
src/pay.ts Normal file
View File

@@ -0,0 +1,50 @@
import { resolveApiBaseUrl } from '@/config';
/**
* صفحه‌ی واسطه‌ی پرداخت (pay.html)، برای اپِ اندرویدی که درگاه را در مرورگرِ گوشی باز می‌کند.
*
* زرین‌پال صفحه‌ی StartPay را فقط وقتی باز می‌کند که Referer از دامنه‌ی ثبت‌شده باشد؛ بی‌آن می‌گوید «دسترسی از این
* دامنه مجاز نمی‌باشد» (روی سرور با درخواستِ بی‌Referer دیده شد، ۱۴۰۵-۰۷). مرورگری که اپِ دیگری بازش کرده Referer
* نمی‌فرستد. پس اپ اول همین صفحه را در مرورگر باز می‌کند و این صفحه کاربر را به لینکِ پرداخت می‌فرستد؛ حالا Referer
* از math.tadrisino2.ir است، درست مثلِ پرداخت از سایت.
*
* فقط لینکِ پرداختِ API خودمان پذیرفته می‌شود (پیشوندِ آدرسِ API + /payment/orders/<id>/pay)، تا این صفحه
* ریدایرکتِ باز به هر جایی نشود.
*/
const msg = document.getElementById('msg')!;
const spin = document.getElementById('spin')!;
const go = document.getElementById('go') as HTMLAnchorElement;
function fail(text: string) {
spin.style.display = 'none';
msg.textContent = text;
}
// در fragment می‌آید، نه query: اپ این صفحه را با http:// باز می‌کند (پایینِ SubscriptionScreen) و fragment هرگز
// به سرور فرستاده نمی‌شود، پس امضای لینکِ پرداخت روی آن یک گامِ بی‌رمز دیده نمی‌شود.
const raw = new URLSearchParams(location.hash.slice(1)).get('u') ?? '';
let target: URL | null = null;
try {
target = new URL(raw);
} catch {
target = null;
}
const api = new URL(resolveApiBaseUrl() + '/', location.origin);
const ok =
target !== null &&
target.protocol === 'https:' &&
target.origin === api.origin &&
new RegExp(`^${api.pathname.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}payment/orders/\\d+/pay$`).test(target.pathname);
if (!ok || !target) {
fail('این لینکِ پرداخت معتبر نیست. به اپ برگرد و دوباره «پرداخت» رو بزن.');
} else {
go.href = target.href;
location.replace(target.href);
// اگر مرورگر جابه‌جا نشد (مثلاً جلوی ریدایرکت را گرفت)، دکمه‌ی دستی.
setTimeout(() => {
spin.style.display = 'none';
msg.textContent = 'اگه صفحه‌ی پرداخت باز نشد، این دکمه رو بزن:';
go.style.display = 'inline-block';
}, 2500);
}

View File

@@ -333,9 +333,18 @@ function CheckoutSheet({ plan, bazaar, onClose, onPaid }: { plan: Plan | null; b
if (!order.paymentUrl) throw new Error('order without paymentUrl'); if (!order.paymentUrl) throw new Error('order without paymentUrl');
// فقط http(s): یک «javascript:» یا «data:» از پاسخِ API نباید با assign اجرا شود. // فقط http(s): یک «javascript:» یا «data:» از پاسخِ API نباید با assign اجرا شود.
if (!/^https?:\/\//i.test(order.paymentUrl)) throw new Error('paymentUrl is not http(s)'); if (!/^https?:\/\//i.test(order.paymentUrl)) throw new Error('paymentUrl is not http(s)');
window.location.assign(order.paymentUrl); const shell = (window as unknown as { TadrisinoAndroid?: { paysInApp?: () => boolean } }).TadrisinoAndroid;
// داخلِ اپ آدرسِ درگاه در مرورگرِ گوشی باز می‌شود و این صفحه همین‌جا می‌ماند؛ دکمه نباید تا ابد بچرخد. if (bazaar && !shell?.paysInApp?.()) {
if (bazaar) setTimeout(() => { setBusy(false); setDone('gateway'); }, 1500); // اپِ اندرویدِ فعلی هر آدرسِ بیرونی را در مرورگرِ گوشی باز می‌کند و آن مرورگر Referer نمی‌فرستد؛ زرین‌پال
// بی‌Referer می‌گوید «دسترسی از این دامنه مجاز نمی‌باشد». پس اول pay.htmlِ خودمان در مرورگر باز می‌شود
// (src/pay.ts) و آن به درگاه می‌رود. «http://» عمداً است: WebView فقط https‌ِ همین دامنه را در خودش نگه
// می‌دارد؛ آروان بلافاصله به https می‌برد و لینک در fragment است، پس به سرور نمی‌رسد.
window.location.assign(`http://${window.location.host}/pay.html#u=${encodeURIComponent(order.paymentUrl)}`);
setTimeout(() => { setBusy(false); setDone('gateway'); }, 1500);
} else {
// مرورگر، یا اپی که پرداخت را در خودش باز می‌کند (paysInApp): همین صفحه به درگاه می‌رود و Referer دارد.
window.location.assign(order.paymentUrl);
}
} catch (err) { } catch (err) {
setError(friendlyError(err, 'اتصال به درگاه انجام نشد، دوباره امتحان کن')); setError(friendlyError(err, 'اتصال به درگاه انجام نشد، دوباره امتحان کن'));
setBusy(false); setBusy(false);
@@ -397,7 +406,7 @@ function CheckoutSheet({ plan, bazaar, onClose, onPaid }: { plan: Plan | null; b
<PrimaryButton loading={busy} onClick={() => void pay()}>پرداخت</PrimaryButton> <PrimaryButton loading={busy} onClick={() => void pay()}>پرداخت</PrimaryButton>
{method === 'gateway' && ( {method === 'gateway' && (
<p className="mt-3 flex items-center justify-center gap-1.5 text-xs text-muted"> <p className="mt-3 flex items-center justify-center gap-1.5 text-xs text-muted">
<ShieldCheck size={14} /> {bazaar ? 'صفحه‌ی پرداخت در مرورگرِ گوشی باز می‌شه؛ بعدش برگرد به اپ.' : 'پرداخت در درگاهِ امنِ زرین‌پال انجام می‌شه.'} <ShieldCheck size={14} /> پرداخت در درگاهِ امنِ زرین‌پال انجام می‌شه.
</p> </p>
)} )}
</> </>

View File

@@ -14,6 +14,8 @@ export default defineConfig(({ mode }) => {
}, },
build: { build: {
rollupOptions: { rollupOptions: {
// pay.html: صفحه‌ی واسطه‌ی درگاه برای اپِ اندروید (src/pay.ts).
input: { main: path.resolve(__dirname, 'index.html'), pay: path.resolve(__dirname, 'pay.html') },
output: { output: {
// کتابخانه‌ها با هر انتشار عوض نمی‌شوند؛ فایلِ جدا یعنی بعد از هر دیپلوی // کتابخانه‌ها با هر انتشار عوض نمی‌شوند؛ فایلِ جدا یعنی بعد از هر دیپلوی
// از کشِ مرورگر می‌آیند و فقط کدِ خودِ اپ دوباره دانلود می‌شود. // از کشِ مرورگر می‌آیند و فقط کدِ خودِ اپ دوباره دانلود می‌شود.