Skip to content

Commit 49f4ea4

Browse files
committed
fix: sanitizing the callback URL
1 parent 9c4cf77 commit 49f4ea4

6 files changed

Lines changed: 104 additions & 14 deletions

File tree

apps/web/src/app/providers.tsx

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,8 @@ export function PostHogAuthTracker() {
2525
if (status === "loading") return;
2626

2727
try {
28-
// Check if PostHog is initialized
29-
if (!posthog.__loaded) return;
28+
// Check if PostHog is initialized using documented pattern
29+
if (!posthog || typeof posthog.capture !== "function") return;
3030

3131
// Detect transition from unauthenticated to authenticated
3232
const wasSignInInitiated =
@@ -46,19 +46,37 @@ export function PostHogAuthTracker() {
4646
if (isNewSignIn && !hasTrackedSignIn.current) {
4747
hasTrackedSignIn.current = true;
4848

49-
// Determine provider from stored value
50-
const provider = storedProvider || "google"; // Default to google if unknown
49+
// Validate provider to avoid skewing analytics
50+
const validProviders = ["google", "github"];
51+
const provider =
52+
storedProvider && validProviders.includes(storedProvider)
53+
? storedProvider
54+
: "unknown";
55+
56+
// Determine if this is a new user based on account creation time
57+
// Consider a user "new" if their account was created within the last 5 minutes
58+
let isNewUser = false;
59+
if (session.user?.createdAt) {
60+
try {
61+
const createdAtTime = new Date(session.user.createdAt).getTime();
62+
const now = Date.now();
63+
const fiveMinutesInMs = 5 * 60 * 1000;
64+
isNewUser = now - createdAtTime < fiveMinutesInMs;
65+
} catch (error) {
66+
console.error("[Analytics] Error parsing createdAt:", error);
67+
}
68+
}
5169

5270
// Track sign-in completed EVENT only (no person properties)
5371
posthog.capture("sign_in_completed", {
5472
provider: provider,
55-
is_new_user: false,
73+
is_new_user: isNewUser,
5674
});
5775

5876
if (process.env.NODE_ENV === "development") {
5977
console.log("[Analytics] Event tracked: sign_in_completed", {
6078
provider,
61-
is_new_user: false,
79+
is_new_user: isNewUser,
6280
});
6381
}
6482

@@ -88,7 +106,7 @@ export function PostHogAuthTracker() {
88106
}
89107

90108
/**
91-
* PostHog Provider
109+
* PostHog Provider
92110
* NOTE: This provider does NOT handle auth tracking.
93111
* Use PostHogAuthTracker inside SessionProvider for that.
94112
*/

apps/web/src/components/login/SignInPage.tsx

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,14 +34,18 @@ const SignInPage = () => {
3434
const safeCallbackUrl = getSafeCallbackUrl(callbackUrl);
3535

3636
const handleSignIn = (provider: "google" | "github") => {
37-
// Track sign-in attempt
38-
trackSignInStarted(provider, safeCallbackUrl);
37+
// Sanitize callback URL to prevent leaking sensitive query params or tokens
38+
const sanitizedCallback = safeCallbackUrl.split("?")[0].split("#")[0];
3939

40-
// Store provider and sign-in initiation for post-callback tracking
40+
// Track sign-in attempt with sanitized callback (no query params or fragments)
41+
trackSignInStarted(provider, sanitizedCallback);
42+
43+
// Store only provider and boolean flag for post-callback tracking
44+
// Do NOT store the full callback URL to avoid leaking tokens/PII
4145
sessionStorage.setItem("posthog_sign_in_initiated", "true");
4246
sessionStorage.setItem("posthog_sign_in_provider", provider);
4347

44-
// Proceed with sign-in
48+
// Proceed with sign-in (use the full safe callback for actual redirect)
4549
signIn(provider, { callbackUrl: safeCallbackUrl });
4650
};
4751

apps/web/src/hooks/useAnalytics.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,9 @@ export function useAnalytics(): UseAnalyticsReturn {
7171

7272
const isReady = useMemo(() => {
7373
try {
74-
return posthog?.__loaded === true;
74+
// Use optional chaining to safely check if PostHog is initialized
75+
// This is the documented pattern instead of using __loaded
76+
return !!posthog && typeof posthog.capture === 'function';
7577
} catch {
7678
return false;
7779
}

apps/web/src/lib/analytics.ts

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,12 +135,55 @@ export function truncateId(id: string): string {
135135
return `...${id.slice(-4)}`;
136136
}
137137

138+
/**
139+
* Sanitizes a callback URL by removing query parameters and fragments.
140+
* This prevents leaking sensitive tokens, secrets, or PII in analytics.
141+
*
142+
* @param url - The URL to sanitize
143+
* @returns The pathname only (e.g., "/dashboard/home")
144+
*
145+
* @example
146+
* ```ts
147+
* sanitizeCallbackUrl("/dashboard?token=abc123#section")
148+
* // Returns: "/dashboard"
149+
* ```
150+
*/
151+
export function sanitizeCallbackUrl(url: string): string {
152+
if (!url || url.trim() === "") {
153+
return "/dashboard/home";
154+
}
155+
156+
try {
157+
// If it's a relative URL (starts with /)
158+
if (url.startsWith("/") && !url.startsWith("//")) {
159+
// Remove query params and fragments
160+
const pathOnly = url.split("?")[0].split("#")[0];
161+
return pathOnly || "/dashboard/home";
162+
}
163+
164+
// If it's an absolute URL, parse it
165+
const parsedUrl = new URL(url, typeof window !== "undefined" ? window.location.origin : "https://example.com");
166+
return parsedUrl.pathname || "/dashboard/home";
167+
} catch {
168+
// If parsing fails, return default
169+
return "/dashboard/home";
170+
}
171+
}
172+
138173
/**
139174
* Checks if PostHog is initialized and ready.
175+
*
176+
* Uses the public API pattern recommended by PostHog: checking if the capture
177+
* method exists. This is safer than using the undocumented __loaded property.
178+
*
179+
* Note: PostHog does not provide a dedicated isReady() or loaded() method.
180+
* The recommended approach is to check for the existence of core methods.
181+
*
182+
* @returns {boolean} True if PostHog is initialized and ready to use
140183
*/
141184
export function isPostHogReady(): boolean {
142185
try {
143-
return typeof posthog !== "undefined" && posthog.__loaded === true;
186+
return typeof posthog !== "undefined" && typeof posthog.capture === "function";
144187
} catch {
145188
return false;
146189
}
@@ -343,6 +386,7 @@ export const analytics = {
343386
// Utility functions
344387
sanitizeAmount,
345388
truncateId,
389+
sanitizeCallbackUrl,
346390
};
347391

348392
export default analytics;

apps/web/src/lib/auth/config.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ export const authConfig: NextAuthOptions = {
2020
callbacks: {
2121
async signIn({ user, profile, account }) {
2222
try {
23-
await serverTrpc.auth.googleAuth.mutate({
23+
const authResult = await serverTrpc.auth.googleAuth.mutate({
2424
email: user.email!,
2525
firstName: user.name ?? (profile as any)?.name,
2626
authMethod: account?.provider ?? "google",
@@ -33,6 +33,11 @@ export const authConfig: NextAuthOptions = {
3333
scope: account?.scope,
3434
});
3535

36+
// Store createdAt in user object for JWT callback
37+
if (authResult?.user?.createdAt) {
38+
(user as any).createdAt = authResult.user.createdAt;
39+
}
40+
3641
return true;
3742
} catch (error) {
3843
console.error("Sign-in error:", error);
@@ -41,6 +46,11 @@ export const authConfig: NextAuthOptions = {
4146
},
4247

4348
async session({ session, token }) {
49+
// Add createdAt from token to session
50+
if (token.createdAt && session.user) {
51+
session.user.createdAt = token.createdAt as string;
52+
}
53+
4454
return {
4555
...session,
4656
accessToken: token.jwtToken,
@@ -56,6 +66,11 @@ export const authConfig: NextAuthOptions = {
5666
});
5767

5868
token.jwtToken = data.token;
69+
70+
// Store createdAt in token if available
71+
if ((user as any).createdAt) {
72+
token.createdAt = new Date((user as any).createdAt).toISOString();
73+
}
5974
} catch (error) {
6075
console.error("JWT token error:", error);
6176
}

apps/web/src/types/next-auth.d.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,18 @@ import "next-auth";
33
declare module "next-auth" {
44
interface Session {
55
accessToken?: string;
6+
user?: {
7+
name?: string | null;
8+
email?: string | null;
9+
image?: string | null;
10+
createdAt?: string;
11+
};
612
}
713
}
814

915
declare module "next-auth/jwt" {
1016
interface JWT {
1117
jwtToken?: string;
18+
createdAt?: string;
1219
}
1320
}

0 commit comments

Comments
 (0)