Skip to content

Commit c667490

Browse files
authored
Merge pull request #313 from xko2x/fix-callback-security
Fix callback security
2 parents 19934fd + b7dca68 commit c667490

File tree

5 files changed

+122
-46
lines changed

5 files changed

+122
-46
lines changed

components/dashboard.tsx

Lines changed: 84 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,19 @@
11
"use client";
2+
23
import { User } from "@supabase/supabase-js";
34
import { useRouter, useSearchParams } from "next/navigation";
4-
import { useEffect, useState } from "react";
5+
import { useEffect, useState, useCallback } from "react";
56
import { Subscription } from "@/types/subscription";
67
import { toast } from "sonner";
78
import ProfileCard from "@/components/dashboard/profile-card";
89
import SubscriptionCard from "@/components/dashboard/subscription-card";
910
import FreeTrialCard from "@/components/dashboard/freetrial-card";
11+
import { isAllowedUrl } from "@/lib/utils";
12+
import { UnsafeUrlError } from "@/types/url";
1013

1114
type DashboardPageProps = {
1215
subscription: Subscription | null;
13-
openAppQueryParams: string;
16+
openAppQueryParams: string | URLSearchParams;
1417
user: User;
1518
};
1619

@@ -30,54 +33,92 @@ export default function DashboardPage({
3033
percent_credit_used: null,
3134
});
3235

33-
useEffect(() => {
34-
const handleCallbackForApp = async () => {
35-
// Handle callback
36-
const callback = searchParams?.get("callback");
37-
if (callback) {
38-
const decodedCallback = decodeURIComponent(callback);
39-
const callbackUrl = new URL(decodedCallback);
40-
const newSearchParams = new URLSearchParams(callbackUrl.search);
41-
const openAppParams = new URLSearchParams(openAppQueryParams);
42-
43-
openAppParams.forEach((value, key) => {
44-
newSearchParams.append(key, value);
45-
});
46-
47-
callbackUrl.search = newSearchParams.toString();
48-
const openAppUrl = callbackUrl.toString();
49-
50-
router.push(openAppUrl);
51-
52-
const currentUrl = new URL(window.location.href);
53-
currentUrl.searchParams.delete("callback");
54-
window.history.replaceState({}, "", currentUrl.toString());
36+
const handleCallbackForApp = useCallback(async () => {
37+
const callback = searchParams?.get("callback");
38+
if (!callback) return;
39+
40+
try {
41+
const decodedCallback = decodeURIComponent(callback);
42+
const callbackUrl = new URL(decodedCallback);
43+
44+
if (!isAllowedUrl(callbackUrl)) {
45+
throw new UnsafeUrlError(decodedCallback);
46+
}
47+
48+
const newSearchParams = new URLSearchParams(callbackUrl.search);
49+
const openAppParams =
50+
typeof openAppQueryParams === "string"
51+
? new URLSearchParams(openAppQueryParams)
52+
: openAppQueryParams;
53+
54+
openAppParams.forEach((value, key) => {
55+
newSearchParams.append(key, value);
56+
});
57+
58+
callbackUrl.search = newSearchParams.toString();
59+
const openAppUrl = callbackUrl.toString();
60+
61+
// Double-check the final URL
62+
const finalUrl = new URL(openAppUrl);
63+
if (!isAllowedUrl(finalUrl)) {
64+
throw new UnsafeUrlError(openAppUrl);
5565
}
56-
};
5766

58-
const getUserRequestsUsage = async () => {
67+
router.push(openAppUrl);
68+
69+
const currentUrl = new URL(window.location.href);
70+
currentUrl.searchParams.delete("callback");
71+
window.history.replaceState({}, "", currentUrl.toString());
72+
} catch (error) {
73+
if (error instanceof UnsafeUrlError) {
74+
console.error(error.message);
75+
toast.error(
76+
"Unsafe link detected. Navigation blocked for your security.",
77+
);
78+
} else {
79+
console.error("Error in handleCallbackForApp:", error);
80+
toast.error(
81+
"An error occurred while processing the link. Please try again.",
82+
);
83+
}
84+
}
85+
}, [router, searchParams, openAppQueryParams]);
86+
87+
const getUserRequestsUsage = useCallback(async () => {
88+
try {
89+
const response = await fetch("/api/dashboard-usage", {
90+
method: "GET",
91+
headers: { "Content-Type": "application/json" },
92+
});
93+
94+
if (!response.ok) {
95+
throw new Error("Failed to fetch requests usage.");
96+
}
97+
const usageData: UsageType = await response.json();
98+
setUsage(usageData);
99+
} catch (error) {
100+
console.error("Error fetching requests usage:", error);
101+
toast.error("Failed to fetch usage data. Please try again later.");
102+
} finally {
103+
setLoading(false);
104+
}
105+
}, []);
106+
107+
useEffect(() => {
108+
const runEffects = async () => {
59109
try {
60-
const response = await fetch("/api/dashboard-usage", {
61-
method: "GET",
62-
headers: { "Content-Type": "application/json" },
63-
});
64-
65-
if (!response.ok) {
66-
toast.error("Failed to fetch requests usage.");
67-
return;
68-
}
69-
const usage = await response.json();
70-
setUsage(usage);
110+
await handleCallbackForApp();
111+
await getUserRequestsUsage();
71112
} catch (error) {
72-
toast.error(`Error fetching requests usage: ${error}`);
73-
} finally {
74-
setLoading(false);
113+
console.error("Error in effect:", error);
114+
toast.error(
115+
"An unexpected error occurred. Please try refreshing the page.",
116+
);
75117
}
76118
};
77119

78-
handleCallbackForApp();
79-
getUserRequestsUsage();
80-
}, [router, searchParams, openAppQueryParams]);
120+
runEffects();
121+
}, [handleCallbackForApp, getUserRequestsUsage]);
81122

82123
return (
83124
<section className="relative">
@@ -91,7 +132,6 @@ export default function DashboardPage({
91132
</div>
92133
<div className="grid gap-6 lg:grid-cols-2">
93134
<ProfileCard user={user} />
94-
{/* Below commented out until we implement Free Trial */}
95135
{subscription ? (
96136
<SubscriptionCard
97137
subscription={subscription}

components/dashboard/freetrial-card.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { UsageType } from "../dashboard";
88

99
type FreeTrialCardProps = {
1010
usage: UsageType;
11-
openAppQueryParams: string;
11+
openAppQueryParams: string | URLSearchParams;
1212
loading: boolean;
1313
};
1414

components/dashboard/subscription-card.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ import { useUpgradeSubscription } from "@/hooks/useUpgradeSubscription";
3131
type SubscriptionCardProps = {
3232
subscription: Subscription | null;
3333
usage?: UsageType;
34-
openAppQueryParams?: string;
34+
openAppQueryParams?: string | URLSearchParams;
3535
user: User;
3636
loading: boolean;
3737
};

lib/utils.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { type ClassValue, clsx } from "clsx";
22
import { twMerge } from "tailwind-merge";
3+
import { AllowedProtocol } from "../types/url";
34

45
export function cn(...inputs: ClassValue[]) {
56
return twMerge(clsx(inputs));
@@ -23,6 +24,24 @@ export const normalizeDate = (dateString: string) => {
2324
return `${year}-${month.padStart(2, "0")}-${day.padStart(2, "0")}`;
2425
};
2526

27+
const allowedProtocols: AllowedProtocol[] = [
28+
"http:",
29+
"https:",
30+
"pearai:",
31+
"vscode:",
32+
"code-oss:",
33+
];
34+
35+
export function isAllowedUrl(url: URL): boolean {
36+
if (!allowedProtocols.includes(url.protocol as AllowedProtocol)) return false;
37+
if (url.protocol === "http:" || url.protocol === "https:") {
38+
return (
39+
url.origin === window.location.origin || url.hostname === "localhost"
40+
);
41+
}
42+
return true;
43+
}
44+
2645
export const constructMetadata = ({
2746
title,
2847
description = defaultMetadata.description,

types/url.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
export type AllowedProtocol =
2+
| "http:"
3+
| "https:"
4+
| "pearai:"
5+
| "vscode:"
6+
| "code-oss:";
7+
8+
export interface OpenAppParams {
9+
[key: string]: string;
10+
}
11+
12+
export class UnsafeUrlError extends Error {
13+
constructor(url: string) {
14+
super(`Unsafe URL detected: ${url}`);
15+
this.name = "UnsafeUrlError";
16+
}
17+
}

0 commit comments

Comments
 (0)