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.
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.
Stripe Payment — Line 29 (`onErrorReturnTo`):
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`):
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):
// 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");
}
Apply the existing getSafeRedirectUrl() utility to all three unvalidated redirect locations, consistent with 20+ other OAuth callbacks in the codebase.
Stripe Payment — Line 29:
const redirectUrl = getSafeRedirectUrl(state?.onErrorReturnTo) ?? "/apps/installed/payment";
res.redirect(redirectUrl);
Stripe Payment — Line 58:
const returnTo = getReturnToValueFromQueryState(req);
res.redirect(getSafeRedirectUrl(returnTo) ?? getInstalledAppPath({ variant: "payment", slug: "stripe" }));
Feishu Calendar — Line 125:
res.redirect(getSafeRedirectUrl(state?.returnTo) ?? "/apps/installed");
Ananay Arora at Titan Security Research