{"record":{"id":"c27d60057fd785d1","repo":"koala73/worldmonitor","slug":"invalid-returnurl-must-be-a-valid-absolute-url","errorCode":null,"errorMessage":"Invalid returnUrl: must be a valid absolute URL","messagePattern":"Invalid returnUrl: must be a valid absolute URL","errorType":"validation","errorClass":"ConvexError","httpStatus":null,"severity":"error","filePath":"convex/payments/checkout.ts","lineNumber":286,"sourceCode":"\nasync function _createCheckoutSession(\n  ctx: ActionCtx,\n  args: CheckoutArgs,\n  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","sourceCodeStart":268,"sourceCodeEnd":304,"githubUrl":"https://github.com/koala73/worldmonitor/blob/e586b8b4b80f595aa7ece295eec10d76f2921240/convex/payments/checkout.ts#L268-L304","documentation":"The checkout action's _createCheckoutSession validates args.returnUrl as an open-redirect defense (convex/payments/checkout.ts:230-236). If new URL(args.returnUrl) throws — the string is not an absolute URL — the ConvexError 'Invalid returnUrl: must be a valid absolute URL' fires before any Dodo call. Relative paths like \"/pro\" are the canonical trigger because the URL constructor cannot parse them without a base.","triggerScenarios":"Calling createCheckout with returnUrl: \"/pro?from=cta\" (route path instead of full origin), \"pro\", \"worldmonitor.app/pro\" (missing scheme), or any string with embedded whitespace/control characters that new URL() refuses.","commonSituations":"Frontend passes a router path where another library accepted it; config/env typos dropping the https:// scheme; template strings that begin with ? or #; porting code that previously concatenated base+path manually.","solutions":["Pass a fully-qualified absolute URL such as `${location.origin}/pro` — or omit returnUrl entirely, in which case it defaults to SITE_URL","Validate client-side first with URL.canParse(returnUrl) (or a try/catch around new URL(...)) before invoking the action","For self-hosted/preview deployments confirm SITE_URL is set so the default return target is correct"],"exampleFix":"// before\ncreateCheckout({ productId, returnUrl: \"/pro?from=cta\" });\n// after\ncreateCheckout({ productId, returnUrl: `${location.origin}/pro?from=cta` });","handlingStrategy":"validation","validationCode":"function absoluteUrl(u: string): string {\n  const parsed = new URL(u); // throws on non-absolute input — same rule as the server\n  return parsed.toString();\n}\nconst safe = returnUrl ? absoluteUrl(returnUrl) : undefined;","typeGuard":"function isValidAbsoluteReturnUrl(u: string): boolean {\n  try { new URL(u); return true; } catch { return false; }\n}","tryCatchPattern":"try {\n  await createCheckout({ productId, returnUrl });\n} catch (e) {\n  if (e instanceof ConvexError && String(e.message).includes(\"valid absolute URL\")) {\n    return createCheckout({ productId }); // retry with the SITE_URL default\n  }\n  throw e;\n}","preventionTips":["Always build returnUrl from location.origin plus a route, never a bare route","Run URL.canParse (or a try/new URL) on user- or config-supplied return targets before the action call","Prefer omitting returnUrl over guessing — the server default (SITE_URL) is always valid"],"tags":["convex","payments","checkout","url-validation","open-redirect"],"backgroundTag":"invalid-url-format","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"}