fix(#55): implement user data isolation to prevent cross-user data access

- Add server-side authentication validation for all protected API endpoints
- Create auth utility with requireAuth() function for session validation
- Update CombinedDataRepository to filter data by authenticated user ID
- Add findByUserId() method to CatchRecordRepository for user-specific queries
- Replace client-side userId parameters with server-side session extraction
- Use MongoDB aggregation with $lookup and $expr for secure user filtering
- Return 401 errors for unauthenticated requests
- Fix critical security vulnerability where users could see others' catch records

Fixes: User data isolation bug where one user's catch records were used for everyone
Security: Prevents unauthorized access to other users' Pokemon tracking data
This commit is contained in:
Josh Creek
2025-07-26 16:34:19 +01:00
parent a4269259f9
commit 3aa716b941
7 changed files with 172 additions and 39 deletions
@@ -9,6 +9,10 @@ class CatchRecordRepository {
return CatchRecordModel.find().exec();
}
async findByUserId(userId: string): Promise<CatchRecord[]> {
return CatchRecordModel.find({ userId }).exec();
}
async create(data: Partial<CatchRecord>): Promise<CatchRecord> {
return CatchRecordModel.create(data);
}
+79 -20
View File
@@ -1,9 +1,10 @@
import PokedexEntryModel, { type PokedexEntry } from '$lib/models/PokedexEntry';
import CatchRecordModel, { type CatchRecord } from '$lib/models/CatchRecord';
import { CombinedData } from '$lib/models/CombinedData';
import { type CombinedData } from '$lib/models/CombinedData';
class CombinedDataRepository {
async findAllCombinedData(
userId: string,
enableForms: boolean = true,
region: string = '',
game: string = ''
@@ -12,8 +13,19 @@ class CombinedDataRepository {
{
$lookup: {
from: 'catchrecords',
localField: '_id',
foreignField: 'pokedexEntryId',
let: { entryId: '$_id' },
pipeline: [
{
$match: {
$expr: {
$and: [
{ $eq: ['$pokedexEntryId', '$$entryId'] },
{ $eq: ['$userId', userId] }
]
}
}
}
],
as: 'catchRecord'
}
},
@@ -58,16 +70,14 @@ class CombinedDataRepository {
const combinedData: CombinedData[] = await PokedexEntryModel.aggregate(pipeline).exec();
// Filter out entries with no catch records
const filteredData = combinedData.filter((data) => data.catchRecord);
return filteredData.map((data) => ({
return combinedData.map((data) => ({
pokedexEntry: data as PokedexEntry,
catchRecord: data.catchRecord as CatchRecord
}));
}
async findCombinedData(
userId: string,
page: number = 1,
limit: number = 20,
enableForms: boolean = true,
@@ -79,8 +89,19 @@ class CombinedDataRepository {
{
$lookup: {
from: 'catchrecords',
localField: '_id',
foreignField: 'pokedexEntryId',
let: { entryId: '$_id' },
pipeline: [
{
$match: {
$expr: {
$and: [
{ $eq: ['$pokedexEntryId', '$$entryId'] },
{ $eq: ['$userId', userId] }
]
}
}
}
],
as: 'catchRecord'
}
},
@@ -127,33 +148,71 @@ class CombinedDataRepository {
const combinedData: CombinedData[] = await PokedexEntryModel.aggregate(pipeline).exec();
// Filter out entries with no catch records
const filteredData = combinedData.filter((data) => data.catchRecord);
return filteredData.map((data) => ({
return combinedData.map((data) => ({
pokedexEntry: data as PokedexEntry,
catchRecord: data.catchRecord as CatchRecord
}));
}
async countCombinedData(enableForms: boolean, region: string, game: string): Promise<number> {
const filter: any = {};
async countCombinedData(userId: string, enableForms: boolean, region: string, game: string): Promise<number> {
const pipeline: any[] = [
{
$lookup: {
from: 'catchrecords',
let: { entryId: '$_id' },
pipeline: [
{
$match: {
$expr: {
$and: [
{ $eq: ['$pokedexEntryId', '$$entryId'] },
{ $eq: ['$userId', userId] }
]
}
}
}
],
as: 'catchRecord'
}
},
{
$unwind: {
path: '$catchRecord',
preserveNullAndEmptyArrays: true
}
},
{
$count: 'total'
}
];
if (!enableForms) {
filter['boxPlacement.box'] = { $ne: null };
pipeline.unshift({
$match: {
'boxPlacement.box': { $ne: null }
}
});
}
if (region.length > 0) {
filter['regionToCatchIn'] = region;
pipeline.unshift({
$match: {
regionToCatchIn: region
}
});
}
if (game.length > 0) {
filter['gamesToCatchIn'] = { $in: [game] };
pipeline.unshift({
$match: {
gamesToCatchIn: { $in: [game] }
}
});
}
try {
const count = await PokedexEntryModel.countDocuments(filter).exec();
return count;
const result = await PokedexEntryModel.aggregate(pipeline).exec();
return result.length > 0 ? result[0].total : 0;
} catch (error) {
console.error('Error counting documents:', error);
throw error;