Skip to content

Commit 62b5ea4

Browse files
authored
Release/v0.2.31 (#188)
1 parent b01c5dc commit 62b5ea4

21 files changed

Lines changed: 548 additions & 56 deletions

CHANGELOG.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,19 @@
11
# Changelog
22

3+
## Version 0.2.31
4+
5+
**This Release contains important security patches, please update as soon as possible**
6+
7+
### Fixed
8+
9+
* Security: Updated Next.js from 15.5.7 to 15.5.10
10+
* Security: Hardened avatar route against path traversal attacks
11+
* Security: Sanitized user data in client-facing payloads
12+
* Security: Hardened debug actions and avatar validation
13+
* Fixed missing English translations for DrawingModal
14+
15+
Thank you @1ARdotNO for the security audit!
16+
317
## Version 0.2.30
418

519
### Fixed

app/actions/data.test.ts

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import { describe, expect, test } from 'bun:test'
2+
import { sanitizeUserData } from '@/lib/user-sanitizer'
3+
import { UserData } from '@/lib/types'
4+
5+
describe('sanitizeUserData', () => {
6+
test('removes password field from every user', () => {
7+
const input: UserData = {
8+
users: [
9+
{
10+
id: 'u1',
11+
username: 'admin',
12+
password: 'abcd1234:ef567890',
13+
isAdmin: true,
14+
},
15+
{
16+
id: 'u2',
17+
username: 'no-pass',
18+
isAdmin: false,
19+
},
20+
],
21+
}
22+
23+
const output = sanitizeUserData(input)
24+
25+
expect(output.users).toHaveLength(2)
26+
expect(output.users[0]).not.toHaveProperty('password')
27+
expect(output.users[1]).not.toHaveProperty('password')
28+
})
29+
30+
test('adds hasPassword metadata based on stored password', () => {
31+
const input: UserData = {
32+
users: [
33+
{
34+
id: 'u1',
35+
username: 'with-hash',
36+
password: 'abcd1234:ef567890',
37+
isAdmin: false,
38+
},
39+
{
40+
id: 'u2',
41+
username: 'empty-pass',
42+
password: '',
43+
isAdmin: false,
44+
},
45+
{
46+
id: 'u3',
47+
username: 'no-pass',
48+
isAdmin: false,
49+
},
50+
],
51+
}
52+
53+
const output = sanitizeUserData(input)
54+
55+
expect(output.users[0].hasPassword).toBe(true)
56+
expect(output.users[1].hasPassword).toBe(false)
57+
expect(output.users[2].hasPassword).toBe(false)
58+
})
59+
60+
test('preserves other user properties', () => {
61+
const input: UserData = {
62+
users: [
63+
{
64+
id: 'u1',
65+
username: 'user',
66+
password: 'hash',
67+
avatarPath: '/data/avatars/u1.png',
68+
isAdmin: false,
69+
permissions: [
70+
{
71+
habit: { write: true, interact: true },
72+
wishlist: { write: true, interact: true },
73+
coins: { write: true, interact: true },
74+
},
75+
],
76+
},
77+
],
78+
}
79+
80+
const output = sanitizeUserData(input)
81+
82+
expect(output.users[0].id).toBe('u1')
83+
expect(output.users[0].username).toBe('user')
84+
expect(output.users[0].avatarPath).toBe('/data/avatars/u1.png')
85+
expect(output.users[0].isAdmin).toBe(false)
86+
expect(output.users[0].permissions?.[0].habit.write).toBe(true)
87+
})
88+
})

app/actions/data.ts

Lines changed: 44 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ import {
1616
UserData,
1717
getDefaultUsersData,
1818
User,
19+
PublicUser,
20+
PublicUserData,
1921
getDefaultWishlistData,
2022
getDefaultHabitsData,
2123
getDefaultCoinsData,
@@ -29,13 +31,14 @@ import { signInSchema } from '@/lib/zod';
2931
import _ from 'lodash';
3032
import { getCurrentUser } from '@/lib/server-helpers'
3133
import { prepareDataForHashing, generateCryptoHash } from '@/lib/utils';
34+
import { sanitizeUserData } from '@/lib/user-sanitizer'
35+
import { ALLOWED_AVATAR_EXTENSIONS, ALLOWED_AVATAR_MIME_TYPES } from '@/lib/avatar'
3236

3337

3438

3539
type ResourceType = 'habit' | 'wishlist' | 'coins'
3640
type ActionType = 'write' | 'interact'
3741

38-
3942
async function verifyPermission(
4043
resource: ResourceType,
4144
action: ActionType
@@ -66,22 +69,28 @@ async function ensureDataDir() {
6669

6770
// --- Backup Debug Action ---
6871
export async function triggerManualBackup(): Promise<{ success: boolean; message: string }> {
69-
// Optional: Add extra permission check if needed for debug actions
70-
// const user = await getCurrentUser();
71-
// if (!user?.isAdmin) {
72-
// return { success: false, message: "Permission denied." };
73-
// }
72+
if (process.env.NODE_ENV !== 'development') {
73+
return { success: false, message: 'Permission denied.' }
74+
}
75+
76+
const user = await getCurrentUser()
77+
if (!user?.isAdmin) {
78+
return { success: false, message: 'Permission denied.' }
79+
}
7480

75-
console.log("Manual backup trigger requested...");
81+
console.log('Manual backup trigger requested...')
7682
try {
7783
// Import runBackup locally to avoid potential circular dependencies if moved
78-
const { runBackup } = await import('@/lib/backup');
79-
await runBackup();
80-
console.log("Manual backup trigger completed successfully.");
81-
return { success: true, message: "Backup process completed successfully." };
84+
const { runBackup } = await import('@/lib/backup')
85+
await runBackup()
86+
console.log('Manual backup trigger completed successfully.')
87+
return { success: true, message: 'Backup process completed successfully.' }
8288
} catch (error) {
83-
console.error("Manual backup trigger failed:", error);
84-
return { success: false, message: `Backup failed: ${error instanceof Error ? error.message : 'Unknown error'}` };
89+
console.error('Manual backup trigger failed:', error)
90+
return {
91+
success: false,
92+
message: `Backup failed: ${error instanceof Error ? error.message : 'Unknown error'}`,
93+
}
8594
}
8695
}
8796

@@ -133,7 +142,7 @@ async function calculateServerFreshnessToken(): Promise<string | null> {
133142
const habits = await loadHabitsData();
134143
const coins = await loadCoinsData();
135144
const wishlist = await loadWishlistData();
136-
const users = await loadUsersData();
145+
const users = await loadUsersPublicData();
137146

138147
const dataString = prepareDataForHashing(
139148
settings,
@@ -362,13 +371,22 @@ export async function uploadAvatar(formData: FormData): Promise<string> {
362371
throw new Error('File size must be less than 5MB')
363372
}
364373

374+
const mimeType = file.type.toLowerCase()
375+
if (!ALLOWED_AVATAR_MIME_TYPES.has(mimeType)) {
376+
throw new Error('Unsupported avatar MIME type')
377+
}
378+
379+
const ext = path.extname(file.name).toLowerCase()
380+
if (!ALLOWED_AVATAR_EXTENSIONS.has(ext)) {
381+
throw new Error('Unsupported avatar file extension')
382+
}
383+
365384
// Create avatars directory if it doesn't exist
366385
const avatarsDir = path.join(process.cwd(), 'data', 'avatars')
367386
await fs.mkdir(avatarsDir, { recursive: true })
368387

369388
// Generate unique filename
370-
const ext = file.name.split('.').pop()
371-
const filename = `${Date.now()}.${ext}`
389+
const filename = `${Date.now()}-${uuid()}${ext}`
372390
const filePath = path.join(avatarsDir, filename)
373391

374392
// Save file
@@ -389,14 +407,19 @@ export async function getChangelog(): Promise<string> {
389407
}
390408

391409
// user logic
392-
export async function loadUsersData(): Promise<UserData> {
410+
async function loadUsersData(): Promise<UserData> {
393411
try {
394412
return await loadData<UserData>('auth')
395413
} catch {
396414
return getDefaultUsersData()
397415
}
398416
}
399417

418+
export async function loadUsersPublicData(): Promise<PublicUserData> {
419+
const data = await loadUsersData()
420+
return sanitizeUserData(data)
421+
}
422+
400423
export async function saveUsersData(data: UserData): Promise<void> {
401424
return saveData('auth', data)
402425
}
@@ -414,7 +437,7 @@ export async function getUser(username: string, plainTextPassword?: string): Pro
414437
return user
415438
}
416439

417-
export async function createUser(formData: FormData): Promise<User> {
440+
export async function createUser(formData: FormData): Promise<PublicUser> {
418441
const username = formData.get('username') as string;
419442
let password = formData.get('password') as string | undefined;
420443
const avatarPath = formData.get('avatarPath') as string;
@@ -451,10 +474,10 @@ export async function createUser(formData: FormData): Promise<User> {
451474
};
452475

453476
await saveUsersData(newData);
454-
return newUser;
477+
return sanitizeUserData({ users: [newUser] }).users[0]
455478
}
456479

457-
export async function updateUser(userId: string, updates: Partial<Omit<User, 'id' | 'password'>>): Promise<User> {
480+
export async function updateUser(userId: string, updates: Partial<Omit<User, 'id' | 'password'>>): Promise<PublicUser> {
458481
const data = await loadUsersData()
459482
const userIndex = data.users.findIndex(user => user.id === userId)
460483

@@ -486,7 +509,7 @@ export async function updateUser(userId: string, updates: Partial<Omit<User, 'id
486509
}
487510

488511
await saveUsersData(newData)
489-
return updatedUser
512+
return sanitizeUserData({ users: [updatedUser] }).users[0]
490513
}
491514

492515
export async function updateUserPassword(userId: string, newPassword?: string): Promise<void> {

0 commit comments

Comments
 (0)