Skip to content

Commit f38b97c

Browse files
committed
Harden impersonation and close high-risk exposure paths
Use signed HttpOnly impersonation state and restore the original admin session safely to reduce account takeover risk during admin impersonation. Also scope job listings to the authenticated user and remove unsafe HTML injection paths in resume UI rendering.
1 parent a99137c commit f38b97c

File tree

10 files changed

+273
-75
lines changed

10 files changed

+273
-75
lines changed

src/app/admin/components/users-table.tsx

Lines changed: 35 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,13 @@ interface UserData {
7979
resume_count: number;
8080
}
8181

82+
interface TrialMeta {
83+
hasTrial: boolean;
84+
isTrialing: boolean;
85+
isExpired: boolean;
86+
displayDate: string;
87+
}
88+
8289
type SortableColumns =
8390
| 'email'
8491
| 'created_at'
@@ -94,6 +101,34 @@ type SortableColumns =
94101
| 'trial_end'
95102
| 'resume_count'; // Added resume_count
96103

104+
function formatDate(dateString?: string) {
105+
if (!dateString) return 'N/A';
106+
return new Date(dateString).toLocaleDateString('en-US', {
107+
year: 'numeric',
108+
month: 'short',
109+
day: 'numeric',
110+
});
111+
}
112+
113+
function getTrialMeta(trialEnd?: string | null): TrialMeta {
114+
if (!trialEnd) {
115+
return { hasTrial: false, isTrialing: false, isExpired: false, displayDate: 'N/A' };
116+
}
117+
118+
const parsed = new Date(trialEnd);
119+
if (Number.isNaN(parsed.getTime())) {
120+
return { hasTrial: true, isTrialing: false, isExpired: false, displayDate: 'Invalid date' };
121+
}
122+
123+
const isTrialing = parsed.getTime() > Date.now();
124+
return {
125+
hasTrial: true,
126+
isTrialing,
127+
isExpired: !isTrialing,
128+
displayDate: formatDate(trialEnd),
129+
};
130+
}
131+
97132
export default function UsersTable() {
98133
const router = useRouter(); // Initialize router
99134
const [users, setUsers] = useState<UserData[]>([]);
@@ -124,10 +159,6 @@ export default function UsersTable() {
124159
try {
125160
setLoading(true);
126161
const data = await getUsersWithProfilesAndSubscriptions();
127-
console.log('DEBUG (browser) | users fetched from server action:', {
128-
total: data?.length,
129-
sample: (data as unknown as UserData[])?.slice?.(0, 3) // show first 3
130-
});
131162
setUsers(data as unknown as UserData[]);
132163
} catch (err) {
133164
console.error('Error fetching users:', err);
@@ -140,34 +171,6 @@ export default function UsersTable() {
140171
fetchUsers();
141172
}, []);
142173

143-
function formatDate(dateString?: string) {
144-
if (!dateString) return 'N/A';
145-
return new Date(dateString).toLocaleDateString('en-US', {
146-
year: 'numeric',
147-
month: 'short',
148-
day: 'numeric',
149-
});
150-
}
151-
152-
const getTrialMeta = (trialEnd?: string | null) => {
153-
if (!trialEnd) {
154-
return { hasTrial: false, isTrialing: false, isExpired: false, displayDate: 'N/A' };
155-
}
156-
157-
const parsed = new Date(trialEnd);
158-
if (Number.isNaN(parsed.getTime())) {
159-
return { hasTrial: true, isTrialing: false, isExpired: false, displayDate: 'Invalid date' };
160-
}
161-
162-
const isTrialing = parsed.getTime() > Date.now();
163-
return {
164-
hasTrial: true,
165-
isTrialing,
166-
isExpired: !isTrialing,
167-
displayDate: formatDate(trialEnd),
168-
};
169-
};
170-
171174
const handleSort = (column: SortableColumns) => {
172175
setSortDescriptor((prev) => ({
173176
column,

src/app/admin/impersonate/[user-id]/route.ts

Lines changed: 38 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
import { NextRequest, NextResponse } from 'next/server'
2-
import { createServiceClient } from '@/utils/supabase/server'
2+
import { createClient, createServiceClient } from '@/utils/supabase/server'
33
import { ensureAdmin } from '../../actions' // relative to this file
4+
import {
5+
createImpersonationStateCookieValue,
6+
IMPERSONATION_STATE_COOKIE_NAME,
7+
IMPERSONATION_STATE_MAX_AGE_SECONDS,
8+
} from '@/lib/impersonation'
49

510
/**
611
* GET /admin/impersonate/:user-id
@@ -29,25 +34,41 @@ export async function GET(
2934
// 1. Re-authenticate that the current caller is an admin
3035
await ensureAdmin()
3136

32-
// 2. Fetch the target user's email so we can generate a magic-link
37+
// 2. Read the currently authenticated admin user ID
38+
const supabase = await createClient()
39+
const {
40+
data: { user: adminUser },
41+
error: adminUserError,
42+
} = await supabase.auth.getUser()
43+
44+
if (adminUserError || !adminUser) {
45+
return NextResponse.json({ error: 'Admin authentication required' }, { status: 401 })
46+
}
47+
48+
if (adminUser.id === targetUserId) {
49+
return NextResponse.json({ error: 'Cannot impersonate current user' }, { status: 400 })
50+
}
51+
52+
// 3. Fetch the target user's email so we can generate a magic-link
3353
const supabaseAdmin = await createServiceClient()
3454
const { data: authUser, error: fetchError } = await supabaseAdmin.auth.admin.getUserById(
3555
targetUserId
3656
)
3757

38-
if (fetchError || !authUser) {
58+
if (fetchError || !authUser?.user?.email) {
3959
return NextResponse.json(
4060
{ error: fetchError?.message || 'User not found' },
4161
{ status: 404 }
4262
)
4363
}
4464

45-
// 3. Generate a magic-link that signs the admin in **as the target user**.
65+
// 4. Generate a magic-link that signs the admin in **as the target user**.
66+
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? request.nextUrl.origin
4667
const { data: linkData, error: linkError } = await supabaseAdmin.auth.admin.generateLink({
4768
type: 'magiclink',
48-
email: authUser.user.email!, // email can be safely asserted – Supabase guarantees it on users
69+
email: authUser.user.email,
4970
options: {
50-
redirectTo: `${process.env.NEXT_PUBLIC_SITE_URL}/auth/callback`,
71+
redirectTo: `${siteUrl}/auth/callback?next=/home`,
5172
},
5273
})
5374

@@ -60,19 +81,20 @@ export async function GET(
6081

6182
const actionLink = linkData.properties.action_link
6283

63-
// 4. Set helper cookies so we can show an indicator while impersonating
64-
const response = NextResponse.redirect(actionLink)
65-
response.cookies.set('is_impersonating', 'true', {
66-
path: '/',
67-
httpOnly: false,
68-
sameSite: 'lax',
69-
maxAge: 60 * 60, // 1 hour is plenty – adjust as needed
84+
const impersonationState = createImpersonationStateCookieValue({
85+
adminUserId: adminUser.id,
86+
impersonatedUserId: targetUserId,
87+
maxAgeSeconds: IMPERSONATION_STATE_MAX_AGE_SECONDS,
7088
})
71-
response.cookies.set('impersonated_user_id', targetUserId, {
89+
90+
// 5. Set secure helper cookie so we can safely restore the admin session
91+
const response = NextResponse.redirect(actionLink)
92+
response.cookies.set(IMPERSONATION_STATE_COOKIE_NAME, impersonationState, {
7293
path: '/',
73-
httpOnly: false,
94+
httpOnly: true,
7495
sameSite: 'lax',
75-
maxAge: 60 * 60,
96+
secure: process.env.NODE_ENV === 'production',
97+
maxAge: IMPERSONATION_STATE_MAX_AGE_SECONDS,
7698
})
7799

78100
return response

src/app/layout.tsx

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@ import { Metadata } from "next";
88
import { Analytics } from "@vercel/analytics/react";
99
import Link from "next/link";
1010
import { cookies } from "next/headers";
11+
import {
12+
IMPERSONATION_STATE_COOKIE_NAME,
13+
parseImpersonationStateCookieValue,
14+
} from "@/lib/impersonation";
1115

1216
// Only enable Vercel Analytics when running on Vercel platform
1317
const isVercel = process.env.VERCEL === '1';
@@ -82,9 +86,12 @@ export default async function RootLayout({
8286
const supabase = await createClient();
8387
const { data: { user } } = await supabase.auth.getUser();
8488

85-
// Detect impersonation via cookie set during /admin/impersonate flow
89+
// Detect impersonation via signed cookie set during /admin/impersonate flow
8690
const cookieStore = await cookies();
87-
const isImpersonating = cookieStore.get('is_impersonating')?.value === 'true';
91+
const impersonationState = parseImpersonationStateCookieValue(
92+
cookieStore.get(IMPERSONATION_STATE_COOKIE_NAME)?.value
93+
);
94+
const isImpersonating = Boolean(impersonationState);
8895

8996

9097
let showUpgradeButton = false;
Lines changed: 59 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,72 @@
11
import { NextRequest, NextResponse } from 'next/server'
2-
import { createClient } from '@/utils/supabase/server'
2+
import { createClient, createServiceClient } from '@/utils/supabase/server'
3+
import {
4+
IMPERSONATION_STATE_COOKIE_NAME,
5+
parseImpersonationStateCookieValue,
6+
} from '@/lib/impersonation'
7+
8+
function clearImpersonationCookie(response: NextResponse) {
9+
response.cookies.set(IMPERSONATION_STATE_COOKIE_NAME, '', {
10+
path: '/',
11+
httpOnly: true,
12+
sameSite: 'lax',
13+
secure: process.env.NODE_ENV === 'production',
14+
maxAge: 0,
15+
})
16+
}
317

418
/**
519
* GET /stop-impersonation
620
*
7-
* Signs out the current (impersonated) session and removes helper cookies.
8-
* Afterward, the user is redirected to the login page.
21+
* Restores the original admin session after impersonation ends.
22+
*
23+
* Flow:
24+
* - Read and verify the signed impersonation cookie
25+
* - Sign out the currently impersonated session
26+
* - Generate a magic link for the original admin
27+
* - Redirect through Supabase callback to restore admin session
928
*/
1029
export async function GET(request: NextRequest) {
30+
const stateCookie = request.cookies.get(IMPERSONATION_STATE_COOKIE_NAME)?.value
31+
const state = parseImpersonationStateCookieValue(stateCookie)
32+
33+
if (!state) {
34+
const response = NextResponse.redirect(new URL('/', request.url))
35+
clearImpersonationCookie(response)
36+
return response
37+
}
38+
1139
const supabase = await createClient()
1240
await supabase.auth.signOut()
1341

14-
const response = NextResponse.redirect(new URL('/auth/login', request.url))
15-
response.cookies.delete('is_impersonating')
16-
response.cookies.delete('impersonated_user_id')
42+
const supabaseAdmin = await createServiceClient()
43+
const { data: adminAuthUser, error: adminUserError } = await supabaseAdmin.auth.admin.getUserById(
44+
state.adminUserId
45+
)
46+
47+
if (adminUserError || !adminAuthUser?.user?.email) {
48+
const response = NextResponse.redirect(new URL('/auth/login', request.url))
49+
clearImpersonationCookie(response)
50+
return response
51+
}
52+
53+
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? request.nextUrl.origin
54+
const { data: linkData, error: linkError } = await supabaseAdmin.auth.admin.generateLink({
55+
type: 'magiclink',
56+
email: adminAuthUser.user.email,
57+
options: {
58+
redirectTo: `${siteUrl}/auth/callback?next=/admin`,
59+
},
60+
})
61+
62+
if (linkError || !linkData?.properties?.action_link) {
63+
const response = NextResponse.redirect(new URL('/auth/login', request.url))
64+
clearImpersonationCookie(response)
65+
return response
66+
}
67+
68+
const response = NextResponse.redirect(linkData.properties.action_link)
69+
clearImpersonationCookie(response)
1770

1871
return response
1972
}

src/components/cover-letter/cover-letter.tsx

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import CoverLetterEditor from "./cover-letter-editor";
2-
import { useRef, useCallback } from 'react';
2+
import { useRef, useCallback, useMemo } from 'react';
33
import { Button } from "@/components/ui/button";
44
import { Plus } from "lucide-react";
55
import { useResumeContext } from '@/components/resume/editor/resume-editor-context';
@@ -10,10 +10,21 @@ interface CoverLetterProps {
1010

1111
}
1212

13+
function sanitizeHtmlForPreview(html: string): string {
14+
return html
15+
.replace(/<script[\s\S]*?>[\s\S]*?<\/script>/gi, '')
16+
.replace(/\son\w+=("[^"]*"|'[^']*'|[^\s>]+)/gi, '')
17+
.replace(/\s(href|src)=("|')\s*javascript:[^"']*("|')/gi, '');
18+
}
19+
1320

1421
export default function CoverLetter({ containerWidth }: CoverLetterProps) {
1522
const { state, dispatch } = useResumeContext();
1623
const contentRef = useRef<HTMLDivElement>(null);
24+
const sanitizedCoverLetterContent = useMemo(
25+
() => sanitizeHtmlForPreview((state.resume.cover_letter?.content as string) || ''),
26+
[state.resume.cover_letter?.content]
27+
);
1728

1829
const handleContentChange = useCallback((data: Record<string, unknown>) => {
1930
dispatch({
@@ -57,7 +68,7 @@ export default function CoverLetter({ containerWidth }: CoverLetterProps) {
5768
>
5869
<div
5970
className="p-16 prose prose-sm !max-w-none"
60-
dangerouslySetInnerHTML={{ __html: (state.resume.cover_letter?.content as string) || '' }}
71+
dangerouslySetInnerHTML={{ __html: sanitizedCoverLetterContent }}
6172
/>
6273
</div>
6374

@@ -82,4 +93,3 @@ export default function CoverLetter({ containerWidth }: CoverLetterProps) {
8293
</div>
8394
);
8495
}
85-

src/components/resume/assistant/work-experience-suggestion.tsx

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,11 @@ interface WorkExperienceSuggestionProps {
1313
newValue: WorkExperience[keyof WorkExperience];
1414
}
1515

16+
interface DescriptionPoint {
17+
text: string;
18+
isNew: boolean;
19+
}
20+
1621
function getHighlightClass<T>(currentValue: T, newValue: T, fieldValue: T): string {
1722
return JSON.stringify(currentValue) !== JSON.stringify(newValue) && fieldValue === newValue
1823
? DIFF_HIGHLIGHT_CLASSES
@@ -33,18 +38,17 @@ export function WorkExperienceSuggestion({
3338
};
3439

3540
// Safe description comparison
36-
const descriptionComparison = (current: string[] = [], suggested: string[] = []) => {
41+
const descriptionComparison = (current: string[] = [], suggested: string[] = []): DescriptionPoint[] => {
3742
// Handle undefined/null cases and ensure arrays
3843
const safeCurrent = Array.isArray(current) ? current : [];
3944
const safeSuggested = Array.isArray(suggested) ? suggested : [];
4045

4146
return safeSuggested.map((point = '', index) => { // Add default for point
4247
const currentPoint = safeCurrent[index] || '';
43-
const isNew = point !== currentPoint;
44-
45-
return isNew
46-
? `<span class="${DIFF_HIGHLIGHT_CLASSES}">${point}</span>`
47-
: point;
48+
return {
49+
text: point,
50+
isNew: point !== currentPoint,
51+
};
4852
});
4953
};
5054

@@ -53,7 +57,10 @@ export function WorkExperienceSuggestion({
5357
currentWork?.description ?? [],
5458
(newValue as string[]) // Type assertion since we know modifiedField='description'
5559
)
56-
: currentWork.description ?? [];
60+
: (currentWork.description ?? []).map((point) => ({
61+
text: point,
62+
isNew: false,
63+
}));
5764

5865
return (
5966
<Card className={cn(
@@ -106,10 +113,7 @@ export function WorkExperienceSuggestion({
106113
{highlightedDescription.map((point, index) => (
107114
<div key={index} className="flex items-start gap-1.5">
108115
<span className="text-gray-800 mt-0.5 text-xs"></span>
109-
<p
110-
className="text-xs text-gray-800"
111-
dangerouslySetInnerHTML={{ __html: point || 'No description provided' }}
112-
/>
116+
<p className={cn("text-xs text-gray-800", point.isNew ? DIFF_HIGHLIGHT_CLASSES : "")}>{point.text || 'No description provided'}</p>
113117
</div>
114118
))}
115119
</div>

0 commit comments

Comments
 (0)