Open Redirect in Stripe Payment and Feishu Calendar OAuth Callbacks
- Product
- Cal.com
- Vendor
- Cal.com, Inc. (calcom/cal.com)
- Affected
- < v6.2.0
- Fixed in
- v6.2.0 (commit 14c151be)
- CVSS
- 6.1
- Published
- 2026-03-04
CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:N
Summary
User-controlled redirect URLs from the OAuth state parameter are passed directly to res.redirect() without getSafeRedirectUrl() validation in Stripe Payment and Feishu Calendar callback handlers, allowing redirection to arbitrary external domains.
Description
The Stripe Payment OAuth callback handler at packages/app-store/stripepayment/api/callback.ts has two unvalidated redirect paths. At line 29, state.onErrorReturnTo is used directly in res.redirect() when a user cancels the OAuth flow (error === "access_denied"). At lines 57-58, the getReturnToValueFromQueryState() helper extracts returnTo from req.query.state JSON without URL validation and passes it directly to res.redirect().
The Feishu Calendar OAuth callback at packages/app-store/feishucalendar/api/callback.ts correctly uses getSafeRedirectUrl() on the success path (lines 119-122), but the catch block at line 125 bypasses it entirely, redirecting to state?.returnTo without validation. Triggering the error path is straightforward — providing an invalid or expired authorization code causes the token exchange to fail.
The identical larkcalendar/api/callback.ts was previously patched for this same issue, but the fix was never propagated to feishucalendar.
Technical Details
Stripe Payment — Line 29 (`onErrorReturnTo`)
typescript
if (error === "access_denied") {
state?.onErrorReturnTo
? res.redirect(state.onErrorReturnTo) // no validation
: res.redirect("/apps/installed/payment");
}
Stripe Payment — Lines 12-19, 57-58 (`returnTo`)
typescript
function getReturnToValueFromQueryState(req: NextApiRequest) {
let returnTo = "";
try {
returnTo = JSON.parse(${req.query.state}`).returnTo;
} catch (error) {
console.info("No 'returnTo' in req.query.state");
}
return returnTo;
}
const returnTo = getReturnToValueFromQueryState(req); res.redirect(returnTo || getInstalledAppPath({ variant: "payment", slug: "stripe" })); ```
Feishu Calendar — Line 125 (catch block)
```typescript // line 119 — safe: res.redirect(getSafeRedirectUrl(state?.returnTo) ?? getInstalledAppPath({ variant: "calendar", slug: "feishu-calendar" }));
// line 125 — vulnerable: } catch (error) { log.error("handle callback error", error); res.redirect(state?.returnTo ?? "/apps/installed"); } ```
Remediation
Apply the existing getSafeRedirectUrl() utility to all three unvalidated redirect locations, consistent with 20+ other OAuth callbacks in the codebase.
Stripe Payment — Line 29
typescript
const redirectUrl = getSafeRedirectUrl(state?.onErrorReturnTo) ?? "/apps/installed/payment";
res.redirect(redirectUrl);
Stripe Payment — Line 58
typescript
const returnTo = getReturnToValueFromQueryState(req);
res.redirect(getSafeRedirectUrl(returnTo) ?? getInstalledAppPath({ variant: "payment", slug: "stripe" }));
Feishu Calendar — Line 125
typescript
res.redirect(getSafeRedirectUrl(state?.returnTo) ?? "/apps/installed");
Timeline
- 2026-02-12Reported to Cal.com Security over email
- 2026-02-20Fixed by Cal.com team in commit 14c151be
- 2026-03-02v6.2.0 released with fix
- 2026-03-04Advisory published
References
Credit: Ananay Arora at OpenHack Security Research