@open-mercato/checkout 0.6.5-develop.5382.1.f542de69af → 0.6.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +1 -0
- package/dist/modules/checkout/api/pay/[slug]/submit/route.js +66 -32
- package/dist/modules/checkout/api/pay/[slug]/submit/route.js.map +2 -2
- package/package.json +7 -8
- package/src/modules/checkout/api/pay/[slug]/submit/__tests__/route.test.ts +155 -0
- package/src/modules/checkout/api/pay/[slug]/submit/route.ts +93 -33
package/AGENTS.md
CHANGED
|
@@ -52,4 +52,5 @@ yarn workspace @open-mercato/checkout build
|
|
|
52
52
|
- Password verification must remain slug-bound and cookie-backed.
|
|
53
53
|
- Checkout transaction status updates must be idempotent because gateway events and status polling can race.
|
|
54
54
|
- The public submit endpoint validates Origin/Referer headers against allowed origins. Extra origins can be added via `CHECKOUT_ALLOWED_ORIGINS` (comma-separated) for cross-domain pay pages.
|
|
55
|
+
- Gateway-bound `successUrl`/`cancelUrl` and the embedded session's `returnUrl`/`cancelUrl` are built from a **server-pinned origin** (`APP_URL`/`NEXT_PUBLIC_APP_URL`, or `CHECKOUT_ALLOWED_ORIGINS`), never from the inbound request `Host`/`X-Forwarded-Host` — otherwise a spoofed Host would redirect payers to an attacker origin (open redirect / phishing). The allowed-origins set used to validate `Origin`/`Referer` (and to reject spoofed `Host`/`X-Forwarded-Host`) is likewise built only from those configured values. A non-loopback deployment with no configured origin rejects submit with `500 Checkout origin is not configured`; configure `APP_URL`/`NEXT_PUBLIC_APP_URL`.
|
|
55
56
|
- Idempotency-Key must be 16–128 characters to prevent trivially guessable keys.
|
|
@@ -65,30 +65,57 @@ function addAllowedOrigin(allowedOrigins, candidate) {
|
|
|
65
65
|
} catch {
|
|
66
66
|
}
|
|
67
67
|
}
|
|
68
|
-
function
|
|
69
|
-
const
|
|
68
|
+
function collectConfiguredOrigins() {
|
|
69
|
+
const origins = [...CONFIGURED_ALLOWED_ORIGINS];
|
|
70
|
+
for (const origin of [process.env.APP_URL, process.env.NEXT_PUBLIC_APP_URL]) {
|
|
71
|
+
if (origin) origins.push(origin);
|
|
72
|
+
}
|
|
73
|
+
return origins;
|
|
74
|
+
}
|
|
75
|
+
function buildAllowedOrigins() {
|
|
70
76
|
const allowedOrigins = /* @__PURE__ */ new Set();
|
|
71
|
-
|
|
72
|
-
for (const origin of CONFIGURED_ALLOWED_ORIGINS) {
|
|
77
|
+
for (const origin of collectConfiguredOrigins()) {
|
|
73
78
|
addAllowedOrigin(allowedOrigins, origin);
|
|
74
79
|
}
|
|
75
|
-
|
|
76
|
-
|
|
80
|
+
return Array.from(allowedOrigins);
|
|
81
|
+
}
|
|
82
|
+
function resolveServerOrigin(req) {
|
|
83
|
+
for (const candidate of collectConfiguredOrigins()) {
|
|
84
|
+
const normalized = normalizeConfiguredOrigin(candidate);
|
|
85
|
+
if (normalized) return normalized;
|
|
77
86
|
}
|
|
78
|
-
const
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
87
|
+
const requestUrl = new URL(req.url);
|
|
88
|
+
if (isLoopbackHostname(requestUrl.hostname)) return requestUrl.origin;
|
|
89
|
+
throw new CrudHttpError(500, { error: "Checkout origin is not configured" });
|
|
90
|
+
}
|
|
91
|
+
function hostAuthority(value) {
|
|
92
|
+
const candidate = value.includes("://") ? value : `https://${value}`;
|
|
93
|
+
try {
|
|
94
|
+
const url = new URL(candidate);
|
|
95
|
+
if (!url.hostname) return null;
|
|
96
|
+
const port = url.port && url.port !== "80" && url.port !== "443" ? `:${url.port}` : "";
|
|
97
|
+
return `${url.hostname.toLowerCase()}${port}`;
|
|
98
|
+
} catch {
|
|
99
|
+
return null;
|
|
90
100
|
}
|
|
91
|
-
|
|
101
|
+
}
|
|
102
|
+
function allowedHostAuthorities(allowedOrigins) {
|
|
103
|
+
const authorities = /* @__PURE__ */ new Set();
|
|
104
|
+
for (const origin of allowedOrigins) {
|
|
105
|
+
const authority = hostAuthority(origin);
|
|
106
|
+
if (authority) authorities.add(authority);
|
|
107
|
+
}
|
|
108
|
+
return authorities;
|
|
109
|
+
}
|
|
110
|
+
function requestHostAuthorities(req) {
|
|
111
|
+
const forwarded = splitHeaderValues(req.headers.get("x-forwarded-host"));
|
|
112
|
+
const hostValues = forwarded.length > 0 ? forwarded : splitHeaderValues(req.headers.get("host"));
|
|
113
|
+
const authorities = [];
|
|
114
|
+
for (const value of hostValues) {
|
|
115
|
+
const authority = hostAuthority(value);
|
|
116
|
+
if (authority) authorities.push(authority);
|
|
117
|
+
}
|
|
118
|
+
return authorities;
|
|
92
119
|
}
|
|
93
120
|
function isIdempotencyConflict(error) {
|
|
94
121
|
const message = error instanceof Error ? error.message : "";
|
|
@@ -126,15 +153,15 @@ async function buildSubmitResponse(req, em, link, transaction, providerKey) {
|
|
|
126
153
|
tenantId: transaction.tenantId,
|
|
127
154
|
deletedAt: null
|
|
128
155
|
}) : null;
|
|
129
|
-
const
|
|
156
|
+
const serverOrigin = resolveServerOrigin(req);
|
|
130
157
|
const clientSession = gatewayTransaction ? readClientSession(gatewayTransaction.gatewayMetadata) : null;
|
|
131
158
|
const paymentSession = clientSession && gatewayTransaction ? {
|
|
132
159
|
...clientSession,
|
|
133
160
|
...clientSession.type === "embedded" ? {
|
|
134
161
|
payload: {
|
|
135
162
|
...clientSession.payload ?? {},
|
|
136
|
-
returnUrl: `${
|
|
137
|
-
cancelUrl: `${
|
|
163
|
+
returnUrl: `${serverOrigin}/pay/${encodeURIComponent(link.slug)}/success/${encodeURIComponent(transaction.id)}`,
|
|
164
|
+
cancelUrl: `${serverOrigin}/pay/${encodeURIComponent(link.slug)}/cancel/${encodeURIComponent(transaction.id)}`
|
|
138
165
|
}
|
|
139
166
|
} : {},
|
|
140
167
|
providerKey: providerKey ?? gatewayTransaction.providerKey ?? null,
|
|
@@ -161,13 +188,20 @@ async function POST(req, { params }) {
|
|
|
161
188
|
} catch {
|
|
162
189
|
}
|
|
163
190
|
const resolvedParams = await params;
|
|
164
|
-
const
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
const
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
191
|
+
const allowedOrigins = buildAllowedOrigins();
|
|
192
|
+
if (allowedOrigins.length > 0) {
|
|
193
|
+
const allowedAuthorities = allowedHostAuthorities(allowedOrigins);
|
|
194
|
+
const hostAuthorities = requestHostAuthorities(req);
|
|
195
|
+
if (hostAuthorities.length > 0 && !hostAuthorities.every((authority) => allowedAuthorities.has(authority))) {
|
|
196
|
+
return NextResponse.json({ error: "Invalid request host" }, { status: 403 });
|
|
197
|
+
}
|
|
198
|
+
const origin = req.headers.get("origin");
|
|
199
|
+
const referer = req.headers.get("referer");
|
|
200
|
+
if (origin || referer) {
|
|
201
|
+
const submittedOrigin = origin ?? (referer ? new URL(referer).origin : null);
|
|
202
|
+
if (submittedOrigin && !allowedOrigins.some((allowedOrigin) => isAllowedRequestOrigin(submittedOrigin, allowedOrigin))) {
|
|
203
|
+
return NextResponse.json({ error: "Invalid request origin" }, { status: 403 });
|
|
204
|
+
}
|
|
171
205
|
}
|
|
172
206
|
}
|
|
173
207
|
const idempotencyKey = req.headers.get("Idempotency-Key")?.trim();
|
|
@@ -292,9 +326,9 @@ async function POST(req, { params }) {
|
|
|
292
326
|
if (!transactionId) {
|
|
293
327
|
throw new CrudHttpError(500, { error: "Failed to initialize checkout transaction" });
|
|
294
328
|
}
|
|
295
|
-
const
|
|
296
|
-
const successUrl = `${
|
|
297
|
-
const cancelUrl = `${
|
|
329
|
+
const serverOrigin = resolveServerOrigin(req);
|
|
330
|
+
const successUrl = `${serverOrigin}/pay/${encodeURIComponent(link.slug)}/success/${encodeURIComponent(transactionId)}`;
|
|
331
|
+
const cancelUrl = `${serverOrigin}/pay/${encodeURIComponent(link.slug)}/cancel/${encodeURIComponent(transactionId)}`;
|
|
298
332
|
const transaction = await findOneWithDecryption(em, CheckoutTransaction, {
|
|
299
333
|
id: transactionId,
|
|
300
334
|
organizationId: link.organizationId,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../../../../src/modules/checkout/api/pay/%5Bslug%5D/submit/route.ts"],
|
|
4
|
-
"sourcesContent": ["import { NextResponse } from 'next/server'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport type { CommandBus } from '@open-mercato/shared/lib/commands/command-bus'\nimport type { CommandRuntimeContext } from '@open-mercato/shared/lib/commands'\nimport { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'\nimport { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport type { RateLimiterService } from '@open-mercato/shared/lib/ratelimit/service'\nimport { checkRateLimit, getClientIp } from '@open-mercato/shared/lib/ratelimit/helpers'\nimport type { PaymentGatewayClientSession } from '@open-mercato/shared/modules/payment_gateways/types'\nimport type { PaymentGatewayService } from '@open-mercato/core/modules/payment_gateways/lib/gateway-service'\nimport { GatewayTransaction } from '@open-mercato/core/modules/payment_gateways/data/entities'\nimport { CheckoutLink, CheckoutTransaction } from '../../../../data/entities'\nimport { publicSubmitSchema } from '../../../../data/validators'\nimport { handleCheckoutRouteError, requireCheckoutPasswordSession } from '../../../helpers'\nimport { emitCheckoutEvent } from '../../../../events'\nimport {\n buildConsentProof,\n isCheckoutLinkPublic,\n mapGatewayStatusToCheckoutStatus,\n resolveSubmittedAmount,\n validateDescriptorCurrencies,\n} from '../../../../lib/utils'\nimport { validateCheckoutCustomerData } from '../../../../lib/customerDataValidation'\nimport { checkoutSubmitRateLimitConfig } from '../../../../lib/rateLimiter'\nimport { checkoutTag } from '../../../openapi'\n\ntype CachedSubmitResponse = {\n transactionId: string\n redirectUrl?: string | null\n paymentSession?: (PaymentGatewayClientSession & {\n providerKey: string | null\n gatewayTransactionId: string\n }) | null\n}\n\ntype CheckoutLegalDocumentRequirement = {\n required?: boolean\n}\n\ntype CheckoutCustomerFieldRequirement = {\n key: string\n required?: boolean\n}\n\nfunction normalizePort(url: URL): string {\n if (url.port) return url.port\n return url.protocol === 'https:' ? '443' : '80'\n}\n\nfunction normalizeConfiguredOrigin(candidate: string): string | null {\n try {\n return new URL(candidate).origin\n } catch {\n return null\n }\n}\n\nfunction isLoopbackHostname(hostname: string): boolean {\n return hostname === '127.0.0.1' || hostname === 'localhost'\n}\n\nfunction splitHeaderValues(value: string | null): string[] {\n if (!value) return []\n return value\n .split(',')\n .map((entry) => entry.trim())\n .filter((entry) => entry.length > 0)\n}\n\nfunction isAllowedRequestOrigin(submittedOrigin: string, allowedOrigin: string): boolean {\n if (submittedOrigin === allowedOrigin) return true\n try {\n const submittedUrl = new URL(submittedOrigin)\n const allowedUrl = new URL(allowedOrigin)\n return submittedUrl.protocol === allowedUrl.protocol\n && normalizePort(submittedUrl) === normalizePort(allowedUrl)\n && isLoopbackHostname(submittedUrl.hostname)\n && isLoopbackHostname(allowedUrl.hostname)\n } catch {\n return false\n }\n}\n\nconst CONFIGURED_ALLOWED_ORIGINS: string[] = (process.env.CHECKOUT_ALLOWED_ORIGINS ?? '')\n .split(',')\n .map((origin) => origin.trim())\n .filter((origin) => origin.length > 0)\n\nfunction addAllowedOrigin(allowedOrigins: Set<string>, candidate: string) {\n const normalized = normalizeConfiguredOrigin(candidate)\n if (!normalized) return\n allowedOrigins.add(normalized)\n try {\n const parsed = new URL(normalized)\n if (!isLoopbackHostname(parsed.hostname)) return\n for (const hostname of ['127.0.0.1', 'localhost']) {\n for (const protocol of ['http:', 'https:']) {\n const variant = new URL(normalized)\n variant.hostname = hostname\n variant.protocol = protocol\n allowedOrigins.add(variant.origin)\n }\n }\n } catch {\n // Ignore invalid variants.\n }\n}\n\nfunction buildAllowedOrigins(req: Request): string[] {\n const requestUrl = new URL(req.url)\n const allowedOrigins = new Set<string>()\n addAllowedOrigin(allowedOrigins, requestUrl.origin)\n\n for (const origin of CONFIGURED_ALLOWED_ORIGINS) {\n addAllowedOrigin(allowedOrigins, origin)\n }\n for (const origin of [process.env.APP_URL, process.env.NEXT_PUBLIC_APP_URL]) {\n if (origin) addAllowedOrigin(allowedOrigins, origin)\n }\n\n const hostCandidates = [\n ...splitHeaderValues(req.headers.get('x-forwarded-host')),\n ...splitHeaderValues(req.headers.get('host')),\n ]\n const protoCandidates = new Set<string>([\n ...splitHeaderValues(req.headers.get('x-forwarded-proto')),\n requestUrl.protocol.replace(/:$/, ''),\n ])\n\n for (const host of hostCandidates) {\n for (const proto of protoCandidates) {\n addAllowedOrigin(allowedOrigins, `${proto}://${host}`)\n }\n }\n\n return Array.from(allowedOrigins)\n}\n\nfunction isIdempotencyConflict(error: unknown): boolean {\n const message = error instanceof Error ? error.message : ''\n return message.includes('checkout_transactions_organization_id_tenant_id_link_idempotency_key_index')\n || message.includes('checkout_transactions_organization_id_tenant_id_link_idempotency_key_unique')\n || message.includes('duplicate key value')\n}\n\nfunction readClientSession(\n metadata: Record<string, unknown> | null | undefined,\n): PaymentGatewayClientSession | null {\n const candidate = metadata?.clientSession\n if (!candidate || typeof candidate !== 'object' || Array.isArray(candidate)) return null\n const type = (candidate as { type?: unknown }).type\n if (type === 'redirect') {\n const redirectUrl = (candidate as { redirectUrl?: unknown }).redirectUrl\n if (typeof redirectUrl !== 'string' || redirectUrl.trim().length === 0) return null\n const target = (candidate as { target?: unknown }).target\n return {\n type: 'redirect',\n redirectUrl,\n target: target === 'top' ? 'top' : 'self',\n }\n }\n if (type !== 'embedded') return null\n if (typeof (candidate as { rendererKey?: unknown }).rendererKey !== 'string') return null\n const payload = (candidate as { payload?: unknown }).payload\n const settings = (candidate as { settings?: unknown }).settings\n return {\n type: 'embedded',\n rendererKey: (candidate as { rendererKey: string }).rendererKey,\n payload: payload && typeof payload === 'object' && !Array.isArray(payload)\n ? payload as Record<string, unknown>\n : undefined,\n settings: settings && typeof settings === 'object' && !Array.isArray(settings)\n ? settings as Record<string, unknown>\n : undefined,\n }\n}\n\nasync function buildSubmitResponse(\n req: Request,\n em: EntityManager,\n link: CheckoutLink,\n transaction: CheckoutTransaction,\n providerKey: string | null | undefined,\n): Promise<CachedSubmitResponse> {\n const gatewayTransaction = transaction.gatewayTransactionId\n ? await em.findOne(GatewayTransaction, {\n id: transaction.gatewayTransactionId,\n organizationId: transaction.organizationId,\n tenantId: transaction.tenantId,\n deletedAt: null,\n })\n : null\n const requestUrl = new URL(req.url)\n const clientSession = gatewayTransaction\n ? readClientSession(gatewayTransaction.gatewayMetadata)\n : null\n const paymentSession = (clientSession && gatewayTransaction)\n ? {\n ...clientSession,\n ...(clientSession.type === 'embedded'\n ? {\n payload: {\n ...(clientSession.payload ?? {}),\n returnUrl: `${requestUrl.origin}/pay/${encodeURIComponent(link.slug)}/success/${encodeURIComponent(transaction.id)}`,\n cancelUrl: `${requestUrl.origin}/pay/${encodeURIComponent(link.slug)}/cancel/${encodeURIComponent(transaction.id)}`,\n },\n }\n : {}),\n providerKey: providerKey ?? gatewayTransaction.providerKey ?? null,\n gatewayTransactionId: gatewayTransaction.id,\n }\n : null\n return {\n transactionId: transaction.id,\n redirectUrl: gatewayTransaction?.redirectUrl ?? null,\n paymentSession,\n }\n}\n\nexport const metadata = {\n path: '/checkout/pay/[slug]/submit',\n POST: { requireAuth: false },\n}\n\nexport async function POST(req: Request, { params }: { params: Promise<{ slug: string }> | { slug: string } }) {\n try {\n const container = await createRequestContainer()\n try {\n const rateLimiter = container.resolve('rateLimiterService') as RateLimiterService\n const ip = getClientIp(req, 1) ?? 'unknown'\n const rateLimitResponse = await checkRateLimit(rateLimiter, checkoutSubmitRateLimitConfig, `checkout-submit:${ip}`, 'Too many payment attempts. Please try again later.')\n if (rateLimitResponse) return rateLimitResponse\n } catch {\n // Rate limiting is fail-open\n }\n const resolvedParams = await params\n\n const origin = req.headers.get('origin')\n const referer = req.headers.get('referer')\n if (origin || referer) {\n const allowedOrigins = buildAllowedOrigins(req)\n const submittedOrigin = origin ?? (referer ? new URL(referer).origin : null)\n if (submittedOrigin && !allowedOrigins.some((allowedOrigin) => isAllowedRequestOrigin(submittedOrigin, allowedOrigin))) {\n return NextResponse.json({ error: 'Invalid request origin' }, { status: 403 })\n }\n }\n\n const idempotencyKey = req.headers.get('Idempotency-Key')?.trim()\n if (!idempotencyKey) {\n return NextResponse.json({ error: 'Idempotency-Key header is required' }, { status: 400 })\n }\n if (idempotencyKey.length < 16 || idempotencyKey.length > 128) {\n return NextResponse.json({ error: 'Idempotency-Key must be between 16 and 128 characters' }, { status: 400 })\n }\n\n const body = publicSubmitSchema.parse(await req.json().catch(() => ({})))\n const em = container.resolve('em')\n const commandBus = container.resolve('commandBus') as CommandBus\n const paymentGatewayService = container.resolve('paymentGatewayService') as PaymentGatewayService\n const link = await findOneWithDecryption(em, CheckoutLink, {\n slug: resolvedParams.slug,\n deletedAt: null,\n })\n if (!link) {\n return NextResponse.json({ error: 'Payment link not found' }, { status: 404 })\n }\n if (!isCheckoutLinkPublic(link.status)) {\n throw new CrudHttpError(422, { error: 'This payment link is not currently accepting payments' })\n }\n if (link.passwordHash) {\n requireCheckoutPasswordSession(req, link.slug, {\n linkId: link.id,\n sessionVersion: link.passwordHash,\n })\n }\n if (!link.gatewayProviderKey) {\n throw new CrudHttpError(422, { error: 'A payment gateway must be configured before this link can be used' })\n }\n const legalDocuments = link.legalDocuments && typeof link.legalDocuments === 'object'\n ? link.legalDocuments as Partial<Record<'terms' | 'privacyPolicy', CheckoutLegalDocumentRequirement>>\n : {}\n const legalConsentErrors: Record<string, string> = {}\n for (const key of ['terms', 'privacyPolicy'] as const) {\n const document = legalDocuments[key]\n if (document?.required === true && body.acceptedLegalConsents?.[key] !== true) {\n legalConsentErrors[`acceptedLegalConsents.${key}`] = 'checkout.payPage.validation.documentRequired'\n }\n }\n if (Object.keys(legalConsentErrors).length > 0) {\n throw new CrudHttpError(422, {\n error: 'checkout.payPage.validation.fixErrors',\n fieldErrors: legalConsentErrors,\n })\n }\n const collectedCustomerData = link.collectCustomerDetails === false ? {} : body.customerData\n const customerFields = Array.isArray(link.customerFieldsSchema)\n ? link.customerFieldsSchema as CheckoutCustomerFieldRequirement[]\n : []\n if (link.collectCustomerDetails !== false) {\n const customerFieldErrors = validateCheckoutCustomerData(customerFields, collectedCustomerData)\n if (Object.keys(customerFieldErrors).length > 0) {\n throw new CrudHttpError(422, {\n error: 'checkout.payPage.validation.fixErrors',\n fieldErrors: customerFieldErrors,\n })\n }\n }\n const resolvedAmount = resolveSubmittedAmount(link, body)\n const existingTransaction = await findOneWithDecryption(em, CheckoutTransaction, {\n linkId: link.id,\n idempotencyKey,\n organizationId: link.organizationId,\n tenantId: link.tenantId,\n }, undefined, { organizationId: link.organizationId, tenantId: link.tenantId })\n validateDescriptorCurrencies(link.gatewayProviderKey, [\n existingTransaction?.currencyCode ?? resolvedAmount.currencyCode,\n ])\n if (existingTransaction?.gatewayTransactionId) {\n return NextResponse.json(\n await buildSubmitResponse(req, em, link, existingTransaction, link.gatewayProviderKey),\n )\n }\n\n const transactionInput = {\n linkId: link.id,\n amount: resolvedAmount.amount,\n currencyCode: resolvedAmount.currencyCode,\n idempotencyKey,\n customerData: collectedCustomerData,\n firstName: typeof collectedCustomerData.firstName === 'string' ? collectedCustomerData.firstName : null,\n lastName: typeof collectedCustomerData.lastName === 'string' ? collectedCustomerData.lastName : null,\n email: typeof collectedCustomerData.email === 'string' ? collectedCustomerData.email : null,\n phone: typeof collectedCustomerData.phone === 'string' ? collectedCustomerData.phone : null,\n selectedPriceItemId: resolvedAmount.selectedPriceItemId,\n acceptedLegalConsents: buildConsentProof(link, body.acceptedLegalConsents),\n ipAddress: req.headers.get('x-forwarded-for') ?? null,\n userAgent: req.headers.get('user-agent') ?? null,\n tenantId: link.tenantId,\n organizationId: link.organizationId,\n }\n const ctx: CommandRuntimeContext = {\n container,\n auth: null,\n organizationScope: null,\n selectedOrganizationId: link.organizationId,\n organizationIds: [link.organizationId],\n request: req,\n }\n let transactionId = existingTransaction?.id ?? null\n if (!transactionId) {\n try {\n const created = await commandBus.execute<typeof transactionInput, { id: string }>('checkout.transaction.create', {\n input: transactionInput,\n ctx,\n })\n transactionId = created.result.id\n } catch (error) {\n if (!isIdempotencyConflict(error)) {\n throw error\n }\n const duplicated = await findOneWithDecryption(em, CheckoutTransaction, {\n linkId: link.id,\n idempotencyKey,\n organizationId: link.organizationId,\n tenantId: link.tenantId,\n }, undefined, { organizationId: link.organizationId, tenantId: link.tenantId })\n if (!duplicated) {\n throw error\n }\n transactionId = duplicated.id\n }\n }\n if (!transactionId) {\n throw new CrudHttpError(500, { error: 'Failed to initialize checkout transaction' })\n }\n const requestUrl = new URL(req.url)\n const successUrl = `${requestUrl.origin}/pay/${encodeURIComponent(link.slug)}/success/${encodeURIComponent(transactionId)}`\n const cancelUrl = `${requestUrl.origin}/pay/${encodeURIComponent(link.slug)}/cancel/${encodeURIComponent(transactionId)}`\n const transaction = await findOneWithDecryption(em, CheckoutTransaction, {\n id: transactionId,\n organizationId: link.organizationId,\n tenantId: link.tenantId,\n }, undefined, { organizationId: link.organizationId, tenantId: link.tenantId })\n if (!transaction) {\n throw new CrudHttpError(404, { error: 'Transaction not found' })\n }\n const sessionAmount = Number(transaction.amount)\n if (!Number.isFinite(sessionAmount)) {\n throw new CrudHttpError(500, { error: 'Invalid checkout transaction amount' })\n }\n const sessionCurrencyCode = transaction.currencyCode\n if (!transaction.gatewayTransactionId) {\n const configuredPaymentTypes = Array.isArray(link.gatewaySettings?.paymentTypes)\n ? link.gatewaySettings.paymentTypes.filter(\n (value: unknown): value is string => typeof value === 'string' && value.trim().length > 0,\n )\n : []\n const rendererKey = typeof link.gatewaySettings?.rendererKey === 'string' && link.gatewaySettings.rendererKey.trim().length > 0\n ? link.gatewaySettings.rendererKey.trim()\n : undefined\n const rendererSettings = link.gatewaySettings?.rendererSettings\n && typeof link.gatewaySettings.rendererSettings === 'object'\n && !Array.isArray(link.gatewaySettings.rendererSettings)\n ? link.gatewaySettings.rendererSettings as Record<string, unknown>\n : undefined\n const presentationMode = link.gatewaySettings?.presentationMode === 'embedded'\n || link.gatewaySettings?.presentationMode === 'redirect'\n || link.gatewaySettings?.presentationMode === 'auto'\n ? link.gatewaySettings.presentationMode\n : undefined\n try {\n const sessionResult = await paymentGatewayService.createPaymentSession({\n providerKey: link.gatewayProviderKey,\n paymentId: transactionId,\n amount: sessionAmount,\n currencyCode: sessionCurrencyCode,\n paymentTypes: configuredPaymentTypes.length > 0 ? configuredPaymentTypes : undefined,\n description: link.title ?? link.name,\n successUrl,\n cancelUrl,\n metadata: {\n checkoutLinkId: link.id,\n checkoutSlug: link.slug,\n },\n presentation: rendererKey || rendererSettings || presentationMode\n ? {\n ...(presentationMode ? { mode: presentationMode } : {}),\n ...(rendererKey ? { rendererKey } : {}),\n ...(rendererSettings ? { rendererSettings } : {}),\n }\n : undefined,\n organizationId: link.organizationId,\n tenantId: link.tenantId,\n })\n await commandBus.execute('checkout.transaction.updateStatus', {\n input: {\n id: transaction.id,\n status: mapGatewayStatusToCheckoutStatus(sessionResult.transaction.unifiedStatus),\n paymentStatus: sessionResult.transaction.unifiedStatus,\n gatewayTransactionId: sessionResult.transaction.id,\n organizationId: link.organizationId,\n tenantId: link.tenantId,\n },\n ctx,\n })\n } catch (error) {\n await commandBus.execute('checkout.transaction.updateStatus', {\n input: {\n id: transaction.id,\n status: 'failed',\n paymentStatus: transaction.paymentStatus ?? 'failed',\n organizationId: link.organizationId,\n tenantId: link.tenantId,\n },\n ctx,\n }).catch(() => undefined)\n console.error('[checkout] Failed to create payment session', {\n linkId: link.id,\n transactionId: transaction.id,\n providerKey: link.gatewayProviderKey,\n error: error instanceof Error ? error.message : String(error),\n })\n throw new CrudHttpError(502, { error: 'Unable to start the payment session' })\n }\n const refreshedTransaction = await findOneWithDecryption(em, CheckoutTransaction, {\n id: transaction.id,\n organizationId: link.organizationId,\n tenantId: link.tenantId,\n }, undefined, { organizationId: link.organizationId, tenantId: link.tenantId })\n if (!refreshedTransaction) {\n throw new CrudHttpError(404, { error: 'Transaction not found' })\n }\n await emitCheckoutEvent('checkout.transaction.sessionStarted', {\n transactionId: refreshedTransaction.id,\n linkId: refreshedTransaction.linkId,\n templateId: link.templateId ?? null,\n slug: link.slug,\n status: refreshedTransaction.status,\n paymentStatus: refreshedTransaction.paymentStatus ?? null,\n amount: Number(refreshedTransaction.amount),\n currency: refreshedTransaction.currencyCode,\n gatewayProvider: link.gatewayProviderKey,\n gatewayTransactionId: refreshedTransaction.gatewayTransactionId ?? null,\n occurredAt: new Date().toISOString(),\n tenantId: link.tenantId,\n organizationId: link.organizationId,\n }).catch(() => undefined)\n return NextResponse.json(\n await buildSubmitResponse(req, em, link, refreshedTransaction, link.gatewayProviderKey),\n { status: 201 },\n )\n }\n return NextResponse.json(await buildSubmitResponse(req, em, link, transaction, link.gatewayProviderKey), { status: 201 })\n } catch (error) {\n return handleCheckoutRouteError(error)\n }\n}\n\nexport const openApi = {\n tags: [checkoutTag],\n}\n\nexport default POST\n"],
|
|
5
|
-
"mappings": "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",
|
|
4
|
+
"sourcesContent": ["import { NextResponse } from 'next/server'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport type { CommandBus } from '@open-mercato/shared/lib/commands/command-bus'\nimport type { CommandRuntimeContext } from '@open-mercato/shared/lib/commands'\nimport { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'\nimport { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport type { RateLimiterService } from '@open-mercato/shared/lib/ratelimit/service'\nimport { checkRateLimit, getClientIp } from '@open-mercato/shared/lib/ratelimit/helpers'\nimport type { PaymentGatewayClientSession } from '@open-mercato/shared/modules/payment_gateways/types'\nimport type { PaymentGatewayService } from '@open-mercato/core/modules/payment_gateways/lib/gateway-service'\nimport { GatewayTransaction } from '@open-mercato/core/modules/payment_gateways/data/entities'\nimport { CheckoutLink, CheckoutTransaction } from '../../../../data/entities'\nimport { publicSubmitSchema } from '../../../../data/validators'\nimport { handleCheckoutRouteError, requireCheckoutPasswordSession } from '../../../helpers'\nimport { emitCheckoutEvent } from '../../../../events'\nimport {\n buildConsentProof,\n isCheckoutLinkPublic,\n mapGatewayStatusToCheckoutStatus,\n resolveSubmittedAmount,\n validateDescriptorCurrencies,\n} from '../../../../lib/utils'\nimport { validateCheckoutCustomerData } from '../../../../lib/customerDataValidation'\nimport { checkoutSubmitRateLimitConfig } from '../../../../lib/rateLimiter'\nimport { checkoutTag } from '../../../openapi'\n\ntype CachedSubmitResponse = {\n transactionId: string\n redirectUrl?: string | null\n paymentSession?: (PaymentGatewayClientSession & {\n providerKey: string | null\n gatewayTransactionId: string\n }) | null\n}\n\ntype CheckoutLegalDocumentRequirement = {\n required?: boolean\n}\n\ntype CheckoutCustomerFieldRequirement = {\n key: string\n required?: boolean\n}\n\nfunction normalizePort(url: URL): string {\n if (url.port) return url.port\n return url.protocol === 'https:' ? '443' : '80'\n}\n\nfunction normalizeConfiguredOrigin(candidate: string): string | null {\n try {\n return new URL(candidate).origin\n } catch {\n return null\n }\n}\n\nfunction isLoopbackHostname(hostname: string): boolean {\n return hostname === '127.0.0.1' || hostname === 'localhost'\n}\n\nfunction splitHeaderValues(value: string | null): string[] {\n if (!value) return []\n return value\n .split(',')\n .map((entry) => entry.trim())\n .filter((entry) => entry.length > 0)\n}\n\nfunction isAllowedRequestOrigin(submittedOrigin: string, allowedOrigin: string): boolean {\n if (submittedOrigin === allowedOrigin) return true\n try {\n const submittedUrl = new URL(submittedOrigin)\n const allowedUrl = new URL(allowedOrigin)\n return submittedUrl.protocol === allowedUrl.protocol\n && normalizePort(submittedUrl) === normalizePort(allowedUrl)\n && isLoopbackHostname(submittedUrl.hostname)\n && isLoopbackHostname(allowedUrl.hostname)\n } catch {\n return false\n }\n}\n\nconst CONFIGURED_ALLOWED_ORIGINS: string[] = (process.env.CHECKOUT_ALLOWED_ORIGINS ?? '')\n .split(',')\n .map((origin) => origin.trim())\n .filter((origin) => origin.length > 0)\n\nfunction addAllowedOrigin(allowedOrigins: Set<string>, candidate: string) {\n const normalized = normalizeConfiguredOrigin(candidate)\n if (!normalized) return\n allowedOrigins.add(normalized)\n try {\n const parsed = new URL(normalized)\n if (!isLoopbackHostname(parsed.hostname)) return\n for (const hostname of ['127.0.0.1', 'localhost']) {\n for (const protocol of ['http:', 'https:']) {\n const variant = new URL(normalized)\n variant.hostname = hostname\n variant.protocol = protocol\n allowedOrigins.add(variant.origin)\n }\n }\n } catch {\n // Ignore invalid variants.\n }\n}\n\nfunction collectConfiguredOrigins(): string[] {\n const origins = [...CONFIGURED_ALLOWED_ORIGINS]\n for (const origin of [process.env.APP_URL, process.env.NEXT_PUBLIC_APP_URL]) {\n if (origin) origins.push(origin)\n }\n return origins\n}\n\n// Allowed origins are built ONLY from server-configured values. The inbound\n// request URL, Host and X-Forwarded-Host headers are attacker-controllable on\n// deployments that do not normalize them at the edge, so they must never seed\n// the allowlist (otherwise a spoofed Host self-passes the origin check).\nfunction buildAllowedOrigins(): string[] {\n const allowedOrigins = new Set<string>()\n for (const origin of collectConfiguredOrigins()) {\n addAllowedOrigin(allowedOrigins, origin)\n }\n return Array.from(allowedOrigins)\n}\n\n// Resolve the origin used to build gateway-bound success/cancel/return URLs.\n// Always prefer a server-pinned configured origin; only fall back to the request\n// origin on loopback (local dev/test), where the Host cannot be spoofed by a\n// remote attacker. A non-loopback request with no configured origin is a\n// misconfiguration and is rejected rather than emitting an attacker-controllable URL.\nfunction resolveServerOrigin(req: Request): string {\n for (const candidate of collectConfiguredOrigins()) {\n const normalized = normalizeConfiguredOrigin(candidate)\n if (normalized) return normalized\n }\n const requestUrl = new URL(req.url)\n if (isLoopbackHostname(requestUrl.hostname)) return requestUrl.origin\n throw new CrudHttpError(500, { error: 'Checkout origin is not configured' })\n}\n\n// Normalize a host value (bare host, host:port, or full origin) to its authority\n// (`hostname` plus any non-default port), lower-cased. Protocol is intentionally\n// dropped: the gateway-bound URLs are already pinned to the configured origin, so\n// the host guard below only needs to reject foreign hosts \u2014 not enforce protocol\n// or internal-upstream-host parity, which would 403 legitimate proxied requests.\nfunction hostAuthority(value: string): string | null {\n const candidate = value.includes('://') ? value : `https://${value}`\n try {\n const url = new URL(candidate)\n if (!url.hostname) return null\n const port = url.port && url.port !== '80' && url.port !== '443' ? `:${url.port}` : ''\n return `${url.hostname.toLowerCase()}${port}`\n } catch {\n return null\n }\n}\n\nfunction allowedHostAuthorities(allowedOrigins: string[]): Set<string> {\n const authorities = new Set<string>()\n for (const origin of allowedOrigins) {\n const authority = hostAuthority(origin)\n if (authority) authorities.add(authority)\n }\n return authorities\n}\n\n// The externally-asserted host: the proxy-forwarded X-Forwarded-Host when present,\n// otherwise the Host header. The internal upstream Host (and protocol) are ignored\n// so a correctly-proxied request is not rejected for not matching the public origin.\nfunction requestHostAuthorities(req: Request): string[] {\n const forwarded = splitHeaderValues(req.headers.get('x-forwarded-host'))\n const hostValues = forwarded.length > 0 ? forwarded : splitHeaderValues(req.headers.get('host'))\n const authorities: string[] = []\n for (const value of hostValues) {\n const authority = hostAuthority(value)\n if (authority) authorities.push(authority)\n }\n return authorities\n}\n\nfunction isIdempotencyConflict(error: unknown): boolean {\n const message = error instanceof Error ? error.message : ''\n return message.includes('checkout_transactions_organization_id_tenant_id_link_idempotency_key_index')\n || message.includes('checkout_transactions_organization_id_tenant_id_link_idempotency_key_unique')\n || message.includes('duplicate key value')\n}\n\nfunction readClientSession(\n metadata: Record<string, unknown> | null | undefined,\n): PaymentGatewayClientSession | null {\n const candidate = metadata?.clientSession\n if (!candidate || typeof candidate !== 'object' || Array.isArray(candidate)) return null\n const type = (candidate as { type?: unknown }).type\n if (type === 'redirect') {\n const redirectUrl = (candidate as { redirectUrl?: unknown }).redirectUrl\n if (typeof redirectUrl !== 'string' || redirectUrl.trim().length === 0) return null\n const target = (candidate as { target?: unknown }).target\n return {\n type: 'redirect',\n redirectUrl,\n target: target === 'top' ? 'top' : 'self',\n }\n }\n if (type !== 'embedded') return null\n if (typeof (candidate as { rendererKey?: unknown }).rendererKey !== 'string') return null\n const payload = (candidate as { payload?: unknown }).payload\n const settings = (candidate as { settings?: unknown }).settings\n return {\n type: 'embedded',\n rendererKey: (candidate as { rendererKey: string }).rendererKey,\n payload: payload && typeof payload === 'object' && !Array.isArray(payload)\n ? payload as Record<string, unknown>\n : undefined,\n settings: settings && typeof settings === 'object' && !Array.isArray(settings)\n ? settings as Record<string, unknown>\n : undefined,\n }\n}\n\nasync function buildSubmitResponse(\n req: Request,\n em: EntityManager,\n link: CheckoutLink,\n transaction: CheckoutTransaction,\n providerKey: string | null | undefined,\n): Promise<CachedSubmitResponse> {\n const gatewayTransaction = transaction.gatewayTransactionId\n ? await em.findOne(GatewayTransaction, {\n id: transaction.gatewayTransactionId,\n organizationId: transaction.organizationId,\n tenantId: transaction.tenantId,\n deletedAt: null,\n })\n : null\n const serverOrigin = resolveServerOrigin(req)\n const clientSession = gatewayTransaction\n ? readClientSession(gatewayTransaction.gatewayMetadata)\n : null\n const paymentSession = (clientSession && gatewayTransaction)\n ? {\n ...clientSession,\n ...(clientSession.type === 'embedded'\n ? {\n payload: {\n ...(clientSession.payload ?? {}),\n returnUrl: `${serverOrigin}/pay/${encodeURIComponent(link.slug)}/success/${encodeURIComponent(transaction.id)}`,\n cancelUrl: `${serverOrigin}/pay/${encodeURIComponent(link.slug)}/cancel/${encodeURIComponent(transaction.id)}`,\n },\n }\n : {}),\n providerKey: providerKey ?? gatewayTransaction.providerKey ?? null,\n gatewayTransactionId: gatewayTransaction.id,\n }\n : null\n return {\n transactionId: transaction.id,\n redirectUrl: gatewayTransaction?.redirectUrl ?? null,\n paymentSession,\n }\n}\n\nexport const metadata = {\n path: '/checkout/pay/[slug]/submit',\n POST: { requireAuth: false },\n}\n\nexport async function POST(req: Request, { params }: { params: Promise<{ slug: string }> | { slug: string } }) {\n try {\n const container = await createRequestContainer()\n try {\n const rateLimiter = container.resolve('rateLimiterService') as RateLimiterService\n const ip = getClientIp(req, 1) ?? 'unknown'\n const rateLimitResponse = await checkRateLimit(rateLimiter, checkoutSubmitRateLimitConfig, `checkout-submit:${ip}`, 'Too many payment attempts. Please try again later.')\n if (rateLimitResponse) return rateLimitResponse\n } catch {\n // Rate limiting is fail-open\n }\n const resolvedParams = await params\n\n const allowedOrigins = buildAllowedOrigins()\n if (allowedOrigins.length > 0) {\n // Reject spoofed Host / X-Forwarded-Host before any business logic runs so\n // an attacker-controlled host can never flow into gateway-bound URLs. Hosts\n // are matched by authority (protocol-independent) so a correctly TLS-proxied\n // request \u2014 internal http, X-Forwarded-Proto https \u2014 is not rejected.\n const allowedAuthorities = allowedHostAuthorities(allowedOrigins)\n const hostAuthorities = requestHostAuthorities(req)\n if (\n hostAuthorities.length > 0\n && !hostAuthorities.every((authority) => allowedAuthorities.has(authority))\n ) {\n return NextResponse.json({ error: 'Invalid request host' }, { status: 403 })\n }\n\n const origin = req.headers.get('origin')\n const referer = req.headers.get('referer')\n if (origin || referer) {\n const submittedOrigin = origin ?? (referer ? new URL(referer).origin : null)\n if (submittedOrigin && !allowedOrigins.some((allowedOrigin) => isAllowedRequestOrigin(submittedOrigin, allowedOrigin))) {\n return NextResponse.json({ error: 'Invalid request origin' }, { status: 403 })\n }\n }\n }\n\n const idempotencyKey = req.headers.get('Idempotency-Key')?.trim()\n if (!idempotencyKey) {\n return NextResponse.json({ error: 'Idempotency-Key header is required' }, { status: 400 })\n }\n if (idempotencyKey.length < 16 || idempotencyKey.length > 128) {\n return NextResponse.json({ error: 'Idempotency-Key must be between 16 and 128 characters' }, { status: 400 })\n }\n\n const body = publicSubmitSchema.parse(await req.json().catch(() => ({})))\n const em = container.resolve('em')\n const commandBus = container.resolve('commandBus') as CommandBus\n const paymentGatewayService = container.resolve('paymentGatewayService') as PaymentGatewayService\n const link = await findOneWithDecryption(em, CheckoutLink, {\n slug: resolvedParams.slug,\n deletedAt: null,\n })\n if (!link) {\n return NextResponse.json({ error: 'Payment link not found' }, { status: 404 })\n }\n if (!isCheckoutLinkPublic(link.status)) {\n throw new CrudHttpError(422, { error: 'This payment link is not currently accepting payments' })\n }\n if (link.passwordHash) {\n requireCheckoutPasswordSession(req, link.slug, {\n linkId: link.id,\n sessionVersion: link.passwordHash,\n })\n }\n if (!link.gatewayProviderKey) {\n throw new CrudHttpError(422, { error: 'A payment gateway must be configured before this link can be used' })\n }\n const legalDocuments = link.legalDocuments && typeof link.legalDocuments === 'object'\n ? link.legalDocuments as Partial<Record<'terms' | 'privacyPolicy', CheckoutLegalDocumentRequirement>>\n : {}\n const legalConsentErrors: Record<string, string> = {}\n for (const key of ['terms', 'privacyPolicy'] as const) {\n const document = legalDocuments[key]\n if (document?.required === true && body.acceptedLegalConsents?.[key] !== true) {\n legalConsentErrors[`acceptedLegalConsents.${key}`] = 'checkout.payPage.validation.documentRequired'\n }\n }\n if (Object.keys(legalConsentErrors).length > 0) {\n throw new CrudHttpError(422, {\n error: 'checkout.payPage.validation.fixErrors',\n fieldErrors: legalConsentErrors,\n })\n }\n const collectedCustomerData = link.collectCustomerDetails === false ? {} : body.customerData\n const customerFields = Array.isArray(link.customerFieldsSchema)\n ? link.customerFieldsSchema as CheckoutCustomerFieldRequirement[]\n : []\n if (link.collectCustomerDetails !== false) {\n const customerFieldErrors = validateCheckoutCustomerData(customerFields, collectedCustomerData)\n if (Object.keys(customerFieldErrors).length > 0) {\n throw new CrudHttpError(422, {\n error: 'checkout.payPage.validation.fixErrors',\n fieldErrors: customerFieldErrors,\n })\n }\n }\n const resolvedAmount = resolveSubmittedAmount(link, body)\n const existingTransaction = await findOneWithDecryption(em, CheckoutTransaction, {\n linkId: link.id,\n idempotencyKey,\n organizationId: link.organizationId,\n tenantId: link.tenantId,\n }, undefined, { organizationId: link.organizationId, tenantId: link.tenantId })\n validateDescriptorCurrencies(link.gatewayProviderKey, [\n existingTransaction?.currencyCode ?? resolvedAmount.currencyCode,\n ])\n if (existingTransaction?.gatewayTransactionId) {\n return NextResponse.json(\n await buildSubmitResponse(req, em, link, existingTransaction, link.gatewayProviderKey),\n )\n }\n\n const transactionInput = {\n linkId: link.id,\n amount: resolvedAmount.amount,\n currencyCode: resolvedAmount.currencyCode,\n idempotencyKey,\n customerData: collectedCustomerData,\n firstName: typeof collectedCustomerData.firstName === 'string' ? collectedCustomerData.firstName : null,\n lastName: typeof collectedCustomerData.lastName === 'string' ? collectedCustomerData.lastName : null,\n email: typeof collectedCustomerData.email === 'string' ? collectedCustomerData.email : null,\n phone: typeof collectedCustomerData.phone === 'string' ? collectedCustomerData.phone : null,\n selectedPriceItemId: resolvedAmount.selectedPriceItemId,\n acceptedLegalConsents: buildConsentProof(link, body.acceptedLegalConsents),\n ipAddress: req.headers.get('x-forwarded-for') ?? null,\n userAgent: req.headers.get('user-agent') ?? null,\n tenantId: link.tenantId,\n organizationId: link.organizationId,\n }\n const ctx: CommandRuntimeContext = {\n container,\n auth: null,\n organizationScope: null,\n selectedOrganizationId: link.organizationId,\n organizationIds: [link.organizationId],\n request: req,\n }\n let transactionId = existingTransaction?.id ?? null\n if (!transactionId) {\n try {\n const created = await commandBus.execute<typeof transactionInput, { id: string }>('checkout.transaction.create', {\n input: transactionInput,\n ctx,\n })\n transactionId = created.result.id\n } catch (error) {\n if (!isIdempotencyConflict(error)) {\n throw error\n }\n const duplicated = await findOneWithDecryption(em, CheckoutTransaction, {\n linkId: link.id,\n idempotencyKey,\n organizationId: link.organizationId,\n tenantId: link.tenantId,\n }, undefined, { organizationId: link.organizationId, tenantId: link.tenantId })\n if (!duplicated) {\n throw error\n }\n transactionId = duplicated.id\n }\n }\n if (!transactionId) {\n throw new CrudHttpError(500, { error: 'Failed to initialize checkout transaction' })\n }\n const serverOrigin = resolveServerOrigin(req)\n const successUrl = `${serverOrigin}/pay/${encodeURIComponent(link.slug)}/success/${encodeURIComponent(transactionId)}`\n const cancelUrl = `${serverOrigin}/pay/${encodeURIComponent(link.slug)}/cancel/${encodeURIComponent(transactionId)}`\n const transaction = await findOneWithDecryption(em, CheckoutTransaction, {\n id: transactionId,\n organizationId: link.organizationId,\n tenantId: link.tenantId,\n }, undefined, { organizationId: link.organizationId, tenantId: link.tenantId })\n if (!transaction) {\n throw new CrudHttpError(404, { error: 'Transaction not found' })\n }\n const sessionAmount = Number(transaction.amount)\n if (!Number.isFinite(sessionAmount)) {\n throw new CrudHttpError(500, { error: 'Invalid checkout transaction amount' })\n }\n const sessionCurrencyCode = transaction.currencyCode\n if (!transaction.gatewayTransactionId) {\n const configuredPaymentTypes = Array.isArray(link.gatewaySettings?.paymentTypes)\n ? link.gatewaySettings.paymentTypes.filter(\n (value: unknown): value is string => typeof value === 'string' && value.trim().length > 0,\n )\n : []\n const rendererKey = typeof link.gatewaySettings?.rendererKey === 'string' && link.gatewaySettings.rendererKey.trim().length > 0\n ? link.gatewaySettings.rendererKey.trim()\n : undefined\n const rendererSettings = link.gatewaySettings?.rendererSettings\n && typeof link.gatewaySettings.rendererSettings === 'object'\n && !Array.isArray(link.gatewaySettings.rendererSettings)\n ? link.gatewaySettings.rendererSettings as Record<string, unknown>\n : undefined\n const presentationMode = link.gatewaySettings?.presentationMode === 'embedded'\n || link.gatewaySettings?.presentationMode === 'redirect'\n || link.gatewaySettings?.presentationMode === 'auto'\n ? link.gatewaySettings.presentationMode\n : undefined\n try {\n const sessionResult = await paymentGatewayService.createPaymentSession({\n providerKey: link.gatewayProviderKey,\n paymentId: transactionId,\n amount: sessionAmount,\n currencyCode: sessionCurrencyCode,\n paymentTypes: configuredPaymentTypes.length > 0 ? configuredPaymentTypes : undefined,\n description: link.title ?? link.name,\n successUrl,\n cancelUrl,\n metadata: {\n checkoutLinkId: link.id,\n checkoutSlug: link.slug,\n },\n presentation: rendererKey || rendererSettings || presentationMode\n ? {\n ...(presentationMode ? { mode: presentationMode } : {}),\n ...(rendererKey ? { rendererKey } : {}),\n ...(rendererSettings ? { rendererSettings } : {}),\n }\n : undefined,\n organizationId: link.organizationId,\n tenantId: link.tenantId,\n })\n await commandBus.execute('checkout.transaction.updateStatus', {\n input: {\n id: transaction.id,\n status: mapGatewayStatusToCheckoutStatus(sessionResult.transaction.unifiedStatus),\n paymentStatus: sessionResult.transaction.unifiedStatus,\n gatewayTransactionId: sessionResult.transaction.id,\n organizationId: link.organizationId,\n tenantId: link.tenantId,\n },\n ctx,\n })\n } catch (error) {\n await commandBus.execute('checkout.transaction.updateStatus', {\n input: {\n id: transaction.id,\n status: 'failed',\n paymentStatus: transaction.paymentStatus ?? 'failed',\n organizationId: link.organizationId,\n tenantId: link.tenantId,\n },\n ctx,\n }).catch(() => undefined)\n console.error('[checkout] Failed to create payment session', {\n linkId: link.id,\n transactionId: transaction.id,\n providerKey: link.gatewayProviderKey,\n error: error instanceof Error ? error.message : String(error),\n })\n throw new CrudHttpError(502, { error: 'Unable to start the payment session' })\n }\n const refreshedTransaction = await findOneWithDecryption(em, CheckoutTransaction, {\n id: transaction.id,\n organizationId: link.organizationId,\n tenantId: link.tenantId,\n }, undefined, { organizationId: link.organizationId, tenantId: link.tenantId })\n if (!refreshedTransaction) {\n throw new CrudHttpError(404, { error: 'Transaction not found' })\n }\n await emitCheckoutEvent('checkout.transaction.sessionStarted', {\n transactionId: refreshedTransaction.id,\n linkId: refreshedTransaction.linkId,\n templateId: link.templateId ?? null,\n slug: link.slug,\n status: refreshedTransaction.status,\n paymentStatus: refreshedTransaction.paymentStatus ?? null,\n amount: Number(refreshedTransaction.amount),\n currency: refreshedTransaction.currencyCode,\n gatewayProvider: link.gatewayProviderKey,\n gatewayTransactionId: refreshedTransaction.gatewayTransactionId ?? null,\n occurredAt: new Date().toISOString(),\n tenantId: link.tenantId,\n organizationId: link.organizationId,\n }).catch(() => undefined)\n return NextResponse.json(\n await buildSubmitResponse(req, em, link, refreshedTransaction, link.gatewayProviderKey),\n { status: 201 },\n )\n }\n return NextResponse.json(await buildSubmitResponse(req, em, link, transaction, link.gatewayProviderKey), { status: 201 })\n } catch (error) {\n return handleCheckoutRouteError(error)\n }\n}\n\nexport const openApi = {\n tags: [checkoutTag],\n}\n\nexport default POST\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": ["metadata"]
|
|
7
7
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/checkout",
|
|
3
|
-
"version": "0.6.5
|
|
3
|
+
"version": "0.6.5",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"scripts": {
|
|
@@ -61,22 +61,22 @@
|
|
|
61
61
|
}
|
|
62
62
|
},
|
|
63
63
|
"dependencies": {
|
|
64
|
-
"@open-mercato/core": "0.6.5
|
|
65
|
-
"@open-mercato/ui": "0.6.5
|
|
64
|
+
"@open-mercato/core": "0.6.5",
|
|
65
|
+
"@open-mercato/ui": "0.6.5",
|
|
66
66
|
"bcryptjs": "^3.0.3"
|
|
67
67
|
},
|
|
68
68
|
"peerDependencies": {
|
|
69
69
|
"@mikro-orm/postgresql": "^7.0.14",
|
|
70
|
-
"@open-mercato/shared": "0.6.5
|
|
70
|
+
"@open-mercato/shared": "0.6.5",
|
|
71
71
|
"react": "^19.0.0",
|
|
72
72
|
"react-dom": "^19.0.0"
|
|
73
73
|
},
|
|
74
74
|
"devDependencies": {
|
|
75
|
-
"@open-mercato/shared": "0.6.5
|
|
75
|
+
"@open-mercato/shared": "0.6.5",
|
|
76
76
|
"@types/jest": "^30.0.0",
|
|
77
77
|
"@types/react": "^19.2.17",
|
|
78
78
|
"@types/react-dom": "^19.2.3",
|
|
79
|
-
"esbuild": "^0.28.
|
|
79
|
+
"esbuild": "^0.28.1",
|
|
80
80
|
"glob": "^13.0.6",
|
|
81
81
|
"jest": "^30.4.2",
|
|
82
82
|
"react": "19.2.7",
|
|
@@ -90,6 +90,5 @@
|
|
|
90
90
|
"type": "git",
|
|
91
91
|
"url": "https://github.com/open-mercato/open-mercato",
|
|
92
92
|
"directory": "packages/checkout"
|
|
93
|
-
}
|
|
94
|
-
"stableVersion": "0.6.4"
|
|
93
|
+
}
|
|
95
94
|
}
|
|
@@ -68,8 +68,20 @@ function createTransaction(overrides: Record<string, unknown> = {}) {
|
|
|
68
68
|
}
|
|
69
69
|
|
|
70
70
|
describe('POST /api/checkout/pay/[slug]/submit', () => {
|
|
71
|
+
const originalAppUrl = process.env.APP_URL
|
|
72
|
+
const originalPublicAppUrl = process.env.NEXT_PUBLIC_APP_URL
|
|
73
|
+
|
|
74
|
+
afterEach(() => {
|
|
75
|
+
if (originalAppUrl === undefined) delete process.env.APP_URL
|
|
76
|
+
else process.env.APP_URL = originalAppUrl
|
|
77
|
+
if (originalPublicAppUrl === undefined) delete process.env.NEXT_PUBLIC_APP_URL
|
|
78
|
+
else process.env.NEXT_PUBLIC_APP_URL = originalPublicAppUrl
|
|
79
|
+
})
|
|
80
|
+
|
|
71
81
|
beforeEach(() => {
|
|
72
82
|
jest.clearAllMocks()
|
|
83
|
+
process.env.APP_URL = 'https://merchant.example'
|
|
84
|
+
delete process.env.NEXT_PUBLIC_APP_URL
|
|
73
85
|
|
|
74
86
|
;(checkRateLimit as jest.Mock).mockResolvedValue(null)
|
|
75
87
|
;(getClientIp as jest.Mock).mockReturnValue('127.0.0.1')
|
|
@@ -141,4 +153,147 @@ describe('POST /api/checkout/pay/[slug]/submit', () => {
|
|
|
141
153
|
}),
|
|
142
154
|
)
|
|
143
155
|
})
|
|
156
|
+
|
|
157
|
+
it('pins gateway success/cancel URLs to the configured origin instead of the spoofable request Host', async () => {
|
|
158
|
+
;(findOneWithDecryption as jest.Mock)
|
|
159
|
+
.mockResolvedValueOnce(createLink())
|
|
160
|
+
.mockResolvedValueOnce(createTransaction())
|
|
161
|
+
.mockResolvedValueOnce(createTransaction())
|
|
162
|
+
.mockResolvedValueOnce(createTransaction({ gatewayTransactionId: GATEWAY_TRANSACTION_ID }))
|
|
163
|
+
|
|
164
|
+
// Bare Next.js: the inbound Host flows into req.url and there is no Origin/Referer.
|
|
165
|
+
const response = await POST(
|
|
166
|
+
new Request('https://evil.example/api/checkout/pay/donate/submit', {
|
|
167
|
+
method: 'POST',
|
|
168
|
+
headers: {
|
|
169
|
+
'content-type': 'application/json',
|
|
170
|
+
'Idempotency-Key': 'pin-key-1234567890',
|
|
171
|
+
},
|
|
172
|
+
body: JSON.stringify({ customerData: {}, acceptedLegalConsents: {}, amount: 1 }),
|
|
173
|
+
}),
|
|
174
|
+
{ params: { slug: 'donate' } },
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
expect(response.status).toBe(201)
|
|
178
|
+
expect(mockCreatePaymentSession).toHaveBeenCalledWith(
|
|
179
|
+
expect.objectContaining({
|
|
180
|
+
successUrl: `https://merchant.example/pay/donate/success/${TRANSACTION_ID}`,
|
|
181
|
+
cancelUrl: `https://merchant.example/pay/donate/cancel/${TRANSACTION_ID}`,
|
|
182
|
+
}),
|
|
183
|
+
)
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
it('pins the embedded session returnUrl/cancelUrl to the configured origin', async () => {
|
|
187
|
+
mockEmFindOne.mockResolvedValue({
|
|
188
|
+
id: GATEWAY_TRANSACTION_ID,
|
|
189
|
+
providerKey: 'test_gateway',
|
|
190
|
+
redirectUrl: null,
|
|
191
|
+
gatewayMetadata: {
|
|
192
|
+
clientSession: {
|
|
193
|
+
type: 'embedded',
|
|
194
|
+
rendererKey: 'inline',
|
|
195
|
+
payload: {},
|
|
196
|
+
},
|
|
197
|
+
},
|
|
198
|
+
})
|
|
199
|
+
;(findOneWithDecryption as jest.Mock)
|
|
200
|
+
.mockResolvedValueOnce(createLink())
|
|
201
|
+
.mockResolvedValueOnce(createTransaction())
|
|
202
|
+
.mockResolvedValueOnce(createTransaction())
|
|
203
|
+
.mockResolvedValueOnce(createTransaction({ gatewayTransactionId: GATEWAY_TRANSACTION_ID }))
|
|
204
|
+
|
|
205
|
+
const response = await POST(
|
|
206
|
+
new Request('https://evil.example/api/checkout/pay/donate/submit', {
|
|
207
|
+
method: 'POST',
|
|
208
|
+
headers: {
|
|
209
|
+
'content-type': 'application/json',
|
|
210
|
+
'Idempotency-Key': 'embedded-key-1234567',
|
|
211
|
+
},
|
|
212
|
+
body: JSON.stringify({ customerData: {}, acceptedLegalConsents: {}, amount: 1 }),
|
|
213
|
+
}),
|
|
214
|
+
{ params: { slug: 'donate' } },
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
expect(response.status).toBe(201)
|
|
218
|
+
const payload = await response.json()
|
|
219
|
+
expect(payload.paymentSession.payload.returnUrl).toBe(
|
|
220
|
+
`https://merchant.example/pay/donate/success/${TRANSACTION_ID}`,
|
|
221
|
+
)
|
|
222
|
+
expect(payload.paymentSession.payload.cancelUrl).toBe(
|
|
223
|
+
`https://merchant.example/pay/donate/cancel/${TRANSACTION_ID}`,
|
|
224
|
+
)
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
it('rejects a spoofed X-Forwarded-Host that is not in the configured allowlist', async () => {
|
|
228
|
+
const response = await POST(
|
|
229
|
+
new Request('https://merchant.example/api/checkout/pay/donate/submit', {
|
|
230
|
+
method: 'POST',
|
|
231
|
+
headers: {
|
|
232
|
+
'content-type': 'application/json',
|
|
233
|
+
'Idempotency-Key': 'spoof-key-1234567890',
|
|
234
|
+
'x-forwarded-host': 'evil.example',
|
|
235
|
+
},
|
|
236
|
+
body: JSON.stringify({ customerData: {}, acceptedLegalConsents: {}, amount: 1 }),
|
|
237
|
+
}),
|
|
238
|
+
{ params: { slug: 'donate' } },
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
expect(response.status).toBe(403)
|
|
242
|
+
const payload = await response.json()
|
|
243
|
+
expect(payload.error).toBe('Invalid request host')
|
|
244
|
+
expect(findOneWithDecryption as jest.Mock).not.toHaveBeenCalled()
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
it('accepts a correctly TLS-proxied request (internal http upstream, X-Forwarded-Proto https)', async () => {
|
|
248
|
+
;(findOneWithDecryption as jest.Mock)
|
|
249
|
+
.mockResolvedValueOnce(createLink())
|
|
250
|
+
.mockResolvedValueOnce(createTransaction())
|
|
251
|
+
.mockResolvedValueOnce(createTransaction())
|
|
252
|
+
.mockResolvedValueOnce(createTransaction({ gatewayTransactionId: GATEWAY_TRANSACTION_ID }))
|
|
253
|
+
|
|
254
|
+
// Proxy terminated TLS: the upstream connection is plain http to an internal
|
|
255
|
+
// host, while X-Forwarded-* carry the real public origin (matches APP_URL).
|
|
256
|
+
const response = await POST(
|
|
257
|
+
new Request('http://10.0.0.5:3000/api/checkout/pay/donate/submit', {
|
|
258
|
+
method: 'POST',
|
|
259
|
+
headers: {
|
|
260
|
+
'content-type': 'application/json',
|
|
261
|
+
'Idempotency-Key': 'proxied-key-12345678',
|
|
262
|
+
'x-forwarded-proto': 'https',
|
|
263
|
+
'x-forwarded-host': 'merchant.example',
|
|
264
|
+
host: '10.0.0.5:3000',
|
|
265
|
+
},
|
|
266
|
+
body: JSON.stringify({ customerData: {}, acceptedLegalConsents: {}, amount: 1 }),
|
|
267
|
+
}),
|
|
268
|
+
{ params: { slug: 'donate' } },
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
expect(response.status).toBe(201)
|
|
272
|
+
expect(mockCreatePaymentSession).toHaveBeenCalledWith(
|
|
273
|
+
expect.objectContaining({
|
|
274
|
+
successUrl: `https://merchant.example/pay/donate/success/${TRANSACTION_ID}`,
|
|
275
|
+
cancelUrl: `https://merchant.example/pay/donate/cancel/${TRANSACTION_ID}`,
|
|
276
|
+
}),
|
|
277
|
+
)
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
it('closes the self-pass bypass where a matching spoofed Origin and Host are supplied together', async () => {
|
|
281
|
+
const response = await POST(
|
|
282
|
+
new Request('https://merchant.example/api/checkout/pay/donate/submit', {
|
|
283
|
+
method: 'POST',
|
|
284
|
+
headers: {
|
|
285
|
+
'content-type': 'application/json',
|
|
286
|
+
'Idempotency-Key': 'self-pass-key-123456',
|
|
287
|
+
origin: 'https://evil.example',
|
|
288
|
+
host: 'evil.example',
|
|
289
|
+
},
|
|
290
|
+
body: JSON.stringify({ customerData: {}, acceptedLegalConsents: {}, amount: 1 }),
|
|
291
|
+
}),
|
|
292
|
+
{ params: { slug: 'donate' } },
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
expect(response.status).toBe(403)
|
|
296
|
+
const payload = await response.json()
|
|
297
|
+
expect(payload.error).toBe('Invalid request host')
|
|
298
|
+
})
|
|
144
299
|
})
|
|
@@ -107,34 +107,79 @@ function addAllowedOrigin(allowedOrigins: Set<string>, candidate: string) {
|
|
|
107
107
|
}
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
-
function
|
|
111
|
-
const
|
|
112
|
-
const
|
|
113
|
-
|
|
110
|
+
function collectConfiguredOrigins(): string[] {
|
|
111
|
+
const origins = [...CONFIGURED_ALLOWED_ORIGINS]
|
|
112
|
+
for (const origin of [process.env.APP_URL, process.env.NEXT_PUBLIC_APP_URL]) {
|
|
113
|
+
if (origin) origins.push(origin)
|
|
114
|
+
}
|
|
115
|
+
return origins
|
|
116
|
+
}
|
|
114
117
|
|
|
115
|
-
|
|
118
|
+
// Allowed origins are built ONLY from server-configured values. The inbound
|
|
119
|
+
// request URL, Host and X-Forwarded-Host headers are attacker-controllable on
|
|
120
|
+
// deployments that do not normalize them at the edge, so they must never seed
|
|
121
|
+
// the allowlist (otherwise a spoofed Host self-passes the origin check).
|
|
122
|
+
function buildAllowedOrigins(): string[] {
|
|
123
|
+
const allowedOrigins = new Set<string>()
|
|
124
|
+
for (const origin of collectConfiguredOrigins()) {
|
|
116
125
|
addAllowedOrigin(allowedOrigins, origin)
|
|
117
126
|
}
|
|
118
|
-
|
|
119
|
-
|
|
127
|
+
return Array.from(allowedOrigins)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Resolve the origin used to build gateway-bound success/cancel/return URLs.
|
|
131
|
+
// Always prefer a server-pinned configured origin; only fall back to the request
|
|
132
|
+
// origin on loopback (local dev/test), where the Host cannot be spoofed by a
|
|
133
|
+
// remote attacker. A non-loopback request with no configured origin is a
|
|
134
|
+
// misconfiguration and is rejected rather than emitting an attacker-controllable URL.
|
|
135
|
+
function resolveServerOrigin(req: Request): string {
|
|
136
|
+
for (const candidate of collectConfiguredOrigins()) {
|
|
137
|
+
const normalized = normalizeConfiguredOrigin(candidate)
|
|
138
|
+
if (normalized) return normalized
|
|
120
139
|
}
|
|
140
|
+
const requestUrl = new URL(req.url)
|
|
141
|
+
if (isLoopbackHostname(requestUrl.hostname)) return requestUrl.origin
|
|
142
|
+
throw new CrudHttpError(500, { error: 'Checkout origin is not configured' })
|
|
143
|
+
}
|
|
121
144
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
145
|
+
// Normalize a host value (bare host, host:port, or full origin) to its authority
|
|
146
|
+
// (`hostname` plus any non-default port), lower-cased. Protocol is intentionally
|
|
147
|
+
// dropped: the gateway-bound URLs are already pinned to the configured origin, so
|
|
148
|
+
// the host guard below only needs to reject foreign hosts — not enforce protocol
|
|
149
|
+
// or internal-upstream-host parity, which would 403 legitimate proxied requests.
|
|
150
|
+
function hostAuthority(value: string): string | null {
|
|
151
|
+
const candidate = value.includes('://') ? value : `https://${value}`
|
|
152
|
+
try {
|
|
153
|
+
const url = new URL(candidate)
|
|
154
|
+
if (!url.hostname) return null
|
|
155
|
+
const port = url.port && url.port !== '80' && url.port !== '443' ? `:${url.port}` : ''
|
|
156
|
+
return `${url.hostname.toLowerCase()}${port}`
|
|
157
|
+
} catch {
|
|
158
|
+
return null
|
|
159
|
+
}
|
|
160
|
+
}
|
|
130
161
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
162
|
+
function allowedHostAuthorities(allowedOrigins: string[]): Set<string> {
|
|
163
|
+
const authorities = new Set<string>()
|
|
164
|
+
for (const origin of allowedOrigins) {
|
|
165
|
+
const authority = hostAuthority(origin)
|
|
166
|
+
if (authority) authorities.add(authority)
|
|
135
167
|
}
|
|
168
|
+
return authorities
|
|
169
|
+
}
|
|
136
170
|
|
|
137
|
-
|
|
171
|
+
// The externally-asserted host: the proxy-forwarded X-Forwarded-Host when present,
|
|
172
|
+
// otherwise the Host header. The internal upstream Host (and protocol) are ignored
|
|
173
|
+
// so a correctly-proxied request is not rejected for not matching the public origin.
|
|
174
|
+
function requestHostAuthorities(req: Request): string[] {
|
|
175
|
+
const forwarded = splitHeaderValues(req.headers.get('x-forwarded-host'))
|
|
176
|
+
const hostValues = forwarded.length > 0 ? forwarded : splitHeaderValues(req.headers.get('host'))
|
|
177
|
+
const authorities: string[] = []
|
|
178
|
+
for (const value of hostValues) {
|
|
179
|
+
const authority = hostAuthority(value)
|
|
180
|
+
if (authority) authorities.push(authority)
|
|
181
|
+
}
|
|
182
|
+
return authorities
|
|
138
183
|
}
|
|
139
184
|
|
|
140
185
|
function isIdempotencyConflict(error: unknown): boolean {
|
|
@@ -191,7 +236,7 @@ async function buildSubmitResponse(
|
|
|
191
236
|
deletedAt: null,
|
|
192
237
|
})
|
|
193
238
|
: null
|
|
194
|
-
const
|
|
239
|
+
const serverOrigin = resolveServerOrigin(req)
|
|
195
240
|
const clientSession = gatewayTransaction
|
|
196
241
|
? readClientSession(gatewayTransaction.gatewayMetadata)
|
|
197
242
|
: null
|
|
@@ -202,8 +247,8 @@ async function buildSubmitResponse(
|
|
|
202
247
|
? {
|
|
203
248
|
payload: {
|
|
204
249
|
...(clientSession.payload ?? {}),
|
|
205
|
-
returnUrl: `${
|
|
206
|
-
cancelUrl: `${
|
|
250
|
+
returnUrl: `${serverOrigin}/pay/${encodeURIComponent(link.slug)}/success/${encodeURIComponent(transaction.id)}`,
|
|
251
|
+
cancelUrl: `${serverOrigin}/pay/${encodeURIComponent(link.slug)}/cancel/${encodeURIComponent(transaction.id)}`,
|
|
207
252
|
},
|
|
208
253
|
}
|
|
209
254
|
: {}),
|
|
@@ -236,13 +281,28 @@ export async function POST(req: Request, { params }: { params: Promise<{ slug: s
|
|
|
236
281
|
}
|
|
237
282
|
const resolvedParams = await params
|
|
238
283
|
|
|
239
|
-
const
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
284
|
+
const allowedOrigins = buildAllowedOrigins()
|
|
285
|
+
if (allowedOrigins.length > 0) {
|
|
286
|
+
// Reject spoofed Host / X-Forwarded-Host before any business logic runs so
|
|
287
|
+
// an attacker-controlled host can never flow into gateway-bound URLs. Hosts
|
|
288
|
+
// are matched by authority (protocol-independent) so a correctly TLS-proxied
|
|
289
|
+
// request — internal http, X-Forwarded-Proto https — is not rejected.
|
|
290
|
+
const allowedAuthorities = allowedHostAuthorities(allowedOrigins)
|
|
291
|
+
const hostAuthorities = requestHostAuthorities(req)
|
|
292
|
+
if (
|
|
293
|
+
hostAuthorities.length > 0
|
|
294
|
+
&& !hostAuthorities.every((authority) => allowedAuthorities.has(authority))
|
|
295
|
+
) {
|
|
296
|
+
return NextResponse.json({ error: 'Invalid request host' }, { status: 403 })
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const origin = req.headers.get('origin')
|
|
300
|
+
const referer = req.headers.get('referer')
|
|
301
|
+
if (origin || referer) {
|
|
302
|
+
const submittedOrigin = origin ?? (referer ? new URL(referer).origin : null)
|
|
303
|
+
if (submittedOrigin && !allowedOrigins.some((allowedOrigin) => isAllowedRequestOrigin(submittedOrigin, allowedOrigin))) {
|
|
304
|
+
return NextResponse.json({ error: 'Invalid request origin' }, { status: 403 })
|
|
305
|
+
}
|
|
246
306
|
}
|
|
247
307
|
}
|
|
248
308
|
|
|
@@ -374,9 +434,9 @@ export async function POST(req: Request, { params }: { params: Promise<{ slug: s
|
|
|
374
434
|
if (!transactionId) {
|
|
375
435
|
throw new CrudHttpError(500, { error: 'Failed to initialize checkout transaction' })
|
|
376
436
|
}
|
|
377
|
-
const
|
|
378
|
-
const successUrl = `${
|
|
379
|
-
const cancelUrl = `${
|
|
437
|
+
const serverOrigin = resolveServerOrigin(req)
|
|
438
|
+
const successUrl = `${serverOrigin}/pay/${encodeURIComponent(link.slug)}/success/${encodeURIComponent(transactionId)}`
|
|
439
|
+
const cancelUrl = `${serverOrigin}/pay/${encodeURIComponent(link.slug)}/cancel/${encodeURIComponent(transactionId)}`
|
|
380
440
|
const transaction = await findOneWithDecryption(em, CheckoutTransaction, {
|
|
381
441
|
id: transactionId,
|
|
382
442
|
organizationId: link.organizationId,
|