{"record":{"id":"71a5f2298a25b780","repo":"koala73/worldmonitor","slug":"invalid-returnurl-must-use-a-trusted-worldmonitor","errorCode":null,"errorMessage":"Invalid returnUrl: must use a trusted worldmonitor.app origin","messagePattern":"Invalid returnUrl: must use a trusted worldmonitor\\.app origin","errorType":"validation","errorClass":"ConvexError","httpStatus":null,"severity":"error","filePath":"convex/payments/checkout.ts","lineNumber":290,"sourceCode":"  user: UserInfo,\n): Promise<\n  | (Awaited<ReturnType<typeof createDodoCheckoutSession>> & { anonymous_claim_token?: string })\n  | CheckoutRateLimitedOutcome\n  | CheckoutTimedOutOutcome\n> {\n  // Validate returnUrl to prevent open-redirect attacks.\n  const siteUrl = process.env.SITE_URL ?? \"https://worldmonitor.app\";\n  let returnUrl = siteUrl;\n  if (args.returnUrl) {\n    let parsedReturnUrl: URL;\n    try {\n      parsedReturnUrl = new URL(args.returnUrl);\n    } catch {\n      throw new ConvexError(\"Invalid returnUrl: must be a valid absolute URL\");\n    }\n\n    if (!isTrustedReturnUrlOrigin(parsedReturnUrl.origin, new URL(siteUrl).origin)) {\n      throw new ConvexError(\n        \"Invalid returnUrl: must use a trusted worldmonitor.app origin\",\n      );\n    }\n    returnUrl = parsedReturnUrl.toString();\n  }\n\n  // Completed edge idempotency replays return before reaching this boundary.\n  // Consume once per creation, outside the provider retry ladder. A failed\n  // admission mutation must propagate: unknown capacity cannot authorize work.\n  const denied: CheckoutRateLimitedOutcome | null = await ctx.runMutation(\n    internal.payments.checkout.admitCheckout, { userId: user.userId },\n  );\n  if (denied) return denied;\n\n  // Record Terms assent (#6976). Both checkout paths — the /pro pricing page\n  // and every dashboard CTA — funnel through here, so one call covers them all\n  // and no client can skip it: the buyer clicked a button that sits directly\n  // under \"By subscribing you agree to the Terms of Service and Privacy Policy\".","sourceCodeStart":272,"sourceCodeEnd":308,"githubUrl":"https://github.com/koala73/worldmonitor/blob/e586b8b4b80f595aa7ece295eec10d76f2921240/convex/payments/checkout.ts#L272-L308","documentation":"Second returnUrl guard: the parsed origin must exactly match an entry of TRUSTED_RETURN_URL_ORIGINS in convex/payments/returnUrlOrigin.ts (worldmonitor.app plus the www/app/api/tech/finance/commodity/happy/energy subdomains) or the deployment's SITE_URL origin. It is deliberately NOT a *.worldmonitor.app suffix match, because vendor-owned CNAME subdomains (clerk., abacus.) must never become post-payment redirect targets; the file header documents real 500s (WORLDMONITOR-K7/-Q4) caused by allowlist drift when api.worldmonitor.app was missing.","triggerScenarios":"returnUrl with http:// scheme, a non-standard port (https://worldmonitor.app:8443), a look-alike host (https://worldmonitor.app.evil.com), a vendor subdomain like https://clerk.worldmonitor.app, or any third-party origin. Also self-hosted/preview deployments where SITE_URL doesn't match the host the user is actually on.","commonSituations":"Local dev on http://localhost:5173 with SITE_URL unset; a new first-party subdomain attached in Vercel but never added to the allowlist (the exact K7/Q4 drift); security tests probing open redirects.","solutions":["Use an exact trusted origin, e.g. https://app.worldmonitor.app/dashboard","For preview/self-hosted deployments set SITE_URL to the deployment's own origin — isTrustedReturnUrlOrigin accepts it as extraOrigin","If a new first-party host genuinely serves the app, add it to TRUSTED_RETURN_URL_ORIGINS in convex/payments/returnUrlOrigin.ts and extend tests/checkout-return-url-origin.test.mts in both directions","Never relax this to a suffix match to 'fix' the error"],"exampleFix":"// before\ncreateCheckout({ productId, returnUrl: \"http://worldmonitor.app/pro\" });\n// after\ncreateCheckout({ productId, returnUrl: \"https://www.worldmonitor.app/pro\" });","handlingStrategy":"validation","validationCode":"import { TRUSTED_RETURN_URL_ORIGINS } from \"../convex/payments/returnUrlOrigin\";\nfunction trustedReturn(u: string): string | undefined {\n  try {\n    const { origin } = new URL(u);\n    return TRUSTED_RETURN_URL_ORIGINS.includes(origin) ? u : undefined;\n  } catch { return undefined; }\n}","typeGuard":"function isTrustedReturn(u: string): boolean {\n  try { return TRUSTED_RETURN_URL_ORIGINS.includes(new URL(u).origin); } catch { return false; }\n}","tryCatchPattern":"try {\n  await createCheckout({ productId, returnUrl });\n} catch (e) {\n  if (e instanceof ConvexError && String(e.message).includes(\"trusted worldmonitor.app origin\")) {\n    return createCheckout({ productId, returnUrl: \"https://worldmonitor.app\" });\n  }\n  throw e;\n}","preventionTips":["Pin the client to the same enumerated origins as the server instead of deriving hosts dynamically","Set SITE_URL correctly on every non-production deployment so its origin is accepted as extraOrigin","When adding a first-party host, update TRUSTED_RETURN_URL_ORIGINS and tests/checkout-return-url-origin.test.mts together"],"tags":["convex","payments","checkout","open-redirect","security","allowlist","cors-like"],"backgroundTag":"open-redirect-blocked","analyzedSha":"e586b8b4b80f595aa7ece295eec10d76f2921240","analyzedAt":"2026-08-21T16:51:25.751Z","contentChangedAt":"2026-08-21T16:51:25.751Z","schemaVersion":2},"datasetVersion":"2026-09-23T08:17:48.524Z"}