Skip to content

Commit 6bd7ad5

Browse files
committed
fix(security): reduce client-side exposure and harden CI
1 parent c6a3a6b commit 6bd7ad5

6 files changed

Lines changed: 105 additions & 24 deletions

File tree

.github/workflows/ci.yml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@ on:
66
pull_request:
77
branches: [main]
88

9+
permissions:
10+
contents: read
11+
912
env:
1013
NEXT_PUBLIC_SUPABASE_URL: ${{ secrets.NEXT_PUBLIC_SUPABASE_URL || 'https://placeholder.supabase.co' }}
1114
NEXT_PUBLIC_SUPABASE_ANON_KEY: ${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY || 'placeholder-key' }}
@@ -14,6 +17,8 @@ jobs:
1417
lint-and-build:
1518
name: Lint, Type Check & Build
1619
runs-on: ubuntu-latest
20+
permissions:
21+
contents: read
1722
steps:
1823
- uses: actions/checkout@v4
1924

@@ -40,6 +45,9 @@ jobs:
4045
name: E2E Tests (Playwright)
4146
runs-on: ubuntu-latest
4247
needs: lint-and-build
48+
permissions:
49+
contents: read
50+
actions: read
4351
steps:
4452
- uses: actions/checkout@v4
4553

@@ -68,6 +76,8 @@ jobs:
6876
security-scan:
6977
name: Security Scan
7078
runs-on: ubuntu-latest
79+
permissions:
80+
contents: read
7181
steps:
7282
- uses: actions/checkout@v4
7383
with:

app/api/auth/signup/route.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,14 @@ export async function POST(request: NextRequest) {
3838
}
3939

4040
// 4. Validate email format
41-
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
42-
if (!emailRegex.test(email)) {
41+
const atIndex = email.lastIndexOf('@')
42+
const hasValidEmailShape =
43+
atIndex > 0 &&
44+
atIndex < email.length - 3 &&
45+
!email.includes(' ') &&
46+
email.slice(atIndex + 1).includes('.')
47+
48+
if (!hasValidEmailShape) {
4349
return NextResponse.json(
4450
{ error: 'Invalid email format' },
4551
{ status: 400 }

app/dashboard/layout.tsx

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,8 +59,24 @@ export default async function DashboardLayout({
5959
}
6060
: null
6161

62+
const dashboardUserSnapshot = sidebarUser
63+
? Buffer.from(
64+
JSON.stringify({
65+
id: sidebarUser.id,
66+
email: sidebarUser.email,
67+
name: sidebarUser.name,
68+
avatar: sidebarUser.avatar,
69+
createdAt: user?.created_at || "",
70+
language: "en",
71+
}),
72+
).toString("base64")
73+
: ""
74+
6275
return (
63-
<div className="min-h-screen bg-background text-foreground transition-colors duration-300">
76+
<div
77+
className="min-h-screen bg-background text-foreground transition-colors duration-300"
78+
data-lab68-user={dashboardUserSnapshot}
79+
>
6480
<SkipNav />
6581
<DashboardSidebar user={sidebarUser} />
6682

lib/config/i18n.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1764,7 +1764,12 @@ export function getTranslations(lang: Language): Translations {
17641764
const clone = JSON.parse(JSON.stringify(base)) as Translations
17651765

17661766
const merge = (target: Record<string, any>, source: Record<string, any>) => {
1767+
const blockedKeys = new Set(["__proto__", "constructor", "prototype"])
17671768
Object.keys(source).forEach((key) => {
1769+
if (blockedKeys.has(key)) {
1770+
return
1771+
}
1772+
17681773
const value = source[key]
17691774
if (value === undefined || value === null) {
17701775
return

lib/features/auth/auth-service.ts

Lines changed: 61 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,60 @@ export interface AuthState {
1818
isAuthenticated: boolean
1919
}
2020

21-
// Get current user from localStorage cache (for immediate UI rendering)
21+
type MinimalCachedUser = Pick<User, "id" | "email" | "name" | "language" | "avatar">
22+
23+
let inMemoryUser: User | null = null
24+
25+
function readDashboardUserSnapshot(): User | null {
26+
if (typeof document === "undefined") return null
27+
const root = document.querySelector("[data-lab68-user]")
28+
const encoded = root?.getAttribute("data-lab68-user")
29+
if (!encoded) return null
30+
31+
try {
32+
const decoded = window.atob(encoded)
33+
const parsed = JSON.parse(decoded) as Partial<User>
34+
if (!parsed?.id) return null
35+
36+
return {
37+
id: parsed.id,
38+
email: parsed.email || "",
39+
name: parsed.name || "User",
40+
createdAt: parsed.createdAt || "",
41+
language: parsed.language,
42+
avatar: parsed.avatar,
43+
}
44+
} catch {
45+
return null
46+
}
47+
}
48+
49+
function toCachedUser(user: User): MinimalCachedUser {
50+
return {
51+
id: user.id,
52+
email: user.email,
53+
name: user.name,
54+
language: user.language,
55+
avatar: user.avatar,
56+
}
57+
}
58+
59+
export function setCachedUser(user: User | null) {
60+
inMemoryUser = user
61+
}
62+
63+
// Get current user from memory or dashboard server snapshot
2264
export function getCurrentUser(): User | null {
65+
if (inMemoryUser) return inMemoryUser
2366
if (typeof window === "undefined") return null
24-
const session = localStorage.getItem("lab68_session")
25-
return session ? JSON.parse(session) : null
67+
68+
const snapshot = readDashboardUserSnapshot()
69+
if (snapshot) {
70+
inMemoryUser = snapshot
71+
return snapshot
72+
}
73+
74+
return null
2675
}
2776

2877
// Get current user session from Supabase (authoritative source)
@@ -51,7 +100,7 @@ export async function getCurrentUserAsync(): Promise<User | null> {
51100
createdAt: authUser.created_at,
52101
language: 'en'
53102
}
54-
localStorage.setItem("lab68_session", JSON.stringify(user))
103+
setCachedUser(user)
55104
return user
56105
}
57106

@@ -67,8 +116,7 @@ export async function getCurrentUserAsync(): Promise<User | null> {
67116
avatar: profile.avatar
68117
}
69118

70-
// Cache user in localStorage for immediate UI rendering
71-
localStorage.setItem("lab68_session", JSON.stringify(user))
119+
setCachedUser(user)
72120
return user
73121
} catch (error) {
74122
console.error('Error getting current user:', error)
@@ -116,8 +164,7 @@ export async function signUp(
116164
language: language || 'en',
117165
}
118166

119-
// Cache user in localStorage
120-
localStorage.setItem("lab68_session", JSON.stringify(newUser))
167+
setCachedUser(newUser)
121168

122169
return { success: true, user: newUser }
123170
} catch (error: any) {
@@ -172,8 +219,7 @@ export async function signIn(
172219
language: 'en'
173220
}
174221

175-
// Cache user in localStorage
176-
localStorage.setItem("lab68_session", JSON.stringify(user))
222+
setCachedUser(user)
177223

178224
if (rememberMe) {
179225
localStorage.setItem("lab68_remember", "true")
@@ -266,8 +312,7 @@ export async function verifyOtp(
266312
language: 'en'
267313
}
268314

269-
// Cache user in localStorage
270-
localStorage.setItem("lab68_session", JSON.stringify(user))
315+
setCachedUser(user)
271316

272317
if (rememberMe) {
273318
localStorage.setItem("lab68_remember", "true")
@@ -305,12 +350,11 @@ export async function signOut(): Promise<void> {
305350
try {
306351
const supabase = createClient()
307352
await supabase.auth.signOut()
308-
localStorage.removeItem("lab68_session")
353+
setCachedUser(null)
309354
localStorage.removeItem("lab68_remember")
310355
} catch (error) {
311356
console.error('Error signing out:', error)
312-
// Still clear localStorage even if Supabase signout fails
313-
localStorage.removeItem("lab68_session")
357+
setCachedUser(null)
314358
localStorage.removeItem("lab68_remember")
315359
}
316360
}
@@ -357,7 +401,7 @@ export async function updateUserProfile(
357401
const currentUser = await getCurrentUserAsync()
358402

359403
if (currentUser && currentUser.id === userId) {
360-
localStorage.setItem("lab68_session", JSON.stringify(currentUser))
404+
setCachedUser(currentUser)
361405
}
362406

363407
return { success: true, user: currentUser || undefined }
@@ -385,6 +429,6 @@ export async function checkRememberMe(): Promise<User | null> {
385429
// Clear all auth data (useful for debugging)
386430
export function clearAuthData(): void {
387431
if (typeof window === "undefined") return
388-
localStorage.removeItem("lab68_session")
432+
setCachedUser(null)
389433
localStorage.removeItem("lab68_remember")
390434
}

lib/hooks/useAuth.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import { useEffect, useState } from 'react'
44
import { createClient } from '@/lib/database/supabase-client'
5-
import type { User } from '@/lib/features/auth/auth-service'
5+
import { setCachedUser, type User } from '@/lib/features/auth/auth-service'
66

77
export function useAuth() {
88
const [user, setUser] = useState<User | null>(null)
@@ -36,7 +36,7 @@ export function useAuth() {
3636
avatar: profile.avatar
3737
}
3838
setUser(userData)
39-
localStorage.setItem("lab68_session", JSON.stringify(userData))
39+
setCachedUser(userData)
4040
}
4141
}
4242
} catch (error) {
@@ -70,11 +70,11 @@ export function useAuth() {
7070
avatar: profile.avatar
7171
}
7272
setUser(userData)
73-
localStorage.setItem("lab68_session", JSON.stringify(userData))
73+
setCachedUser(userData)
7474
}
7575
} else {
7676
setUser(null)
77-
localStorage.removeItem("lab68_session")
77+
setCachedUser(null)
7878
}
7979
})
8080

0 commit comments

Comments
 (0)