Skip to content

Commit f29aa75

Browse files
committed
Security audit
1 parent 83716dd commit f29aa75

33 files changed

Lines changed: 452 additions & 240 deletions

File tree

app/actions/blur-requests.ts

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -172,17 +172,6 @@ export async function submitBlurRequests(submissions: BlurSubmission[]) {
172172
blurredS3Key,
173173
blurredThumbnailS3Key: thumbnailS3Key,
174174
});
175-
await db
176-
.update(media)
177-
.set({
178-
blurStatus: "pending",
179-
originalS3Key: item.originalS3Key ?? item.s3Key,
180-
originalThumbnailS3Key:
181-
item.originalThumbnailS3Key ?? item.thumbnailS3Key,
182-
blurredS3Key,
183-
blurredThumbnailS3Key: thumbnailS3Key,
184-
})
185-
.where(eq(media.id, item.id));
186175
await auditLog(user.id, "create", "blur_request", requestId, {
187176
mediaId: item.id,
188177
});

app/actions/bulk.ts

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,12 @@ import { parseSlackIds } from "@/lib/slack-id";
1919
export async function getBulkMediaUrls(s3Keys?: string[], mediaIds?: string[]) {
2020
try {
2121
const urls: Record<string, string> = {};
22+
const session = await getSession();
23+
const user = await getUserContext(session?.id);
24+
if (!user) return { success: false, error: "Unauthorized" };
25+
if ((s3Keys?.length ?? 0) > 500 || (mediaIds?.length ?? 0) > 500) {
26+
return { success: false, error: "Too many media requested" };
27+
}
2228
if (s3Keys && s3Keys.length > 0) {
2329
const uniqueKeys = Array.from(new Set(s3Keys));
2430
const mediaItems = await db.query.media.findMany({
@@ -27,24 +33,22 @@ export async function getBulkMediaUrls(s3Keys?: string[], mediaIds?: string[]) {
2733
id: true,
2834
thumbnailS3Key: true,
2935
},
36+
with: { event: true },
3037
});
31-
const mediaByThumbnailKey = new Map(
32-
mediaItems
33-
.filter((item) => item.thumbnailS3Key)
34-
.map((item) => [item.thumbnailS3Key!, item.id]),
35-
);
38+
const mediaByThumbnailKey = new Map<string, string>();
39+
for (const item of mediaItems) {
40+
if (item.thumbnailS3Key && (await can(user, "view", "media", item))) {
41+
mediaByThumbnailKey.set(item.thumbnailS3Key, item.id);
42+
}
43+
}
3644
for (const key of uniqueKeys) {
37-
const mediaId =
38-
mediaByThumbnailKey.get(key) ??
39-
key.match(/^media\/([^/]+)\/thumbnail\.jpg$/)?.[1];
45+
const mediaId = mediaByThumbnailKey.get(key);
4046
if (mediaId) {
4147
urls[key] = getMediaProxyUrl(mediaId, "thumbnail");
4248
}
4349
}
4450
}
4551
if (mediaIds && mediaIds.length > 0) {
46-
const session = await getSession();
47-
const user = await getUserContext(session?.id);
4852
const mediaItems = await db.query.media.findMany({
4953
where: inArray(media.id, mediaIds),
5054
with: {

app/actions/data-export.ts

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"use server";
22
import { randomBytes } from "node:crypto";
3-
import { createWriteStream } from "node:fs";
4-
import { readFile, unlink } from "node:fs/promises";
3+
import { createReadStream, createWriteStream } from "node:fs";
4+
import { stat, unlink } from "node:fs/promises";
55
import { tmpdir } from "node:os";
66
import { join } from "node:path";
77
import { ZipArchive } from "archiver";
@@ -22,6 +22,8 @@ const DATA_EXPORT_REDACTED_KEYS = new Set([
2222
"hcaRefreshToken",
2323
"inviteCode",
2424
]);
25+
const MAX_EXPORT_MEDIA_FILES = 5000;
26+
const MAX_EXPORT_MEDIA_BYTES = 20 * 1024 * 1024 * 1024;
2527

2628
function redactDataExportValue(key: string, value: unknown) {
2729
if (DATA_EXPORT_REDACTED_KEYS.has(key)) return undefined;
@@ -172,6 +174,18 @@ async function processDataExport(exportId: string, userId: string) {
172174
const jsonContent = JSON.stringify(safeUserData, null, 2);
173175
archive.append(jsonContent, { name: "user-data.json" });
174176
const mediaItems = userData.uploadedMedia || [];
177+
const totalMediaBytes = mediaItems.reduce(
178+
(sum, item) => sum + (item.fileSize || 0),
179+
0,
180+
);
181+
if (
182+
mediaItems.length > MAX_EXPORT_MEDIA_FILES ||
183+
totalMediaBytes > MAX_EXPORT_MEDIA_BYTES
184+
) {
185+
archive.abort();
186+
await unlink(tempPath).catch(() => {});
187+
throw new Error("Data export too large");
188+
}
175189
for (const [index, item] of mediaItems.entries()) {
176190
if (index % 5 === 0) {
177191
const currentExport = await db.query.dataExports.findFirst({
@@ -193,12 +207,11 @@ async function processDataExport(exportId: string, userId: string) {
193207
});
194208
const response = await s3Client.send(command);
195209
if (response.Body) {
196-
const bytes = await response.Body.transformToByteArray();
197210
const folder = item.mimeType.startsWith("image/")
198211
? "photos"
199212
: "videos";
200213
const zipPath = `media/${folder}/${safeFilename(item.filename)}`;
201-
archive.append(Buffer.from(bytes), {
214+
archive.append(response.Body as NodeJS.ReadableStream & any, {
202215
name: zipPath,
203216
date:
204217
item.uploadedAt instanceof Date
@@ -217,14 +230,14 @@ async function processDataExport(exportId: string, userId: string) {
217230
archive.on("error", reject);
218231
});
219232
const s3Key = `exports/${exportId}/archive.zip`;
220-
const fileBuffer = await readFile(tempPath);
233+
const fileStats = await stat(tempPath);
221234
await uploadToS3(
222-
fileBuffer,
235+
createReadStream(tempPath),
223236
s3Key,
224237
"application/zip",
225238
undefined,
226239
undefined,
227-
fileBuffer.length,
240+
fileStats.size,
228241
);
229242
try {
230243
await db

app/actions/events.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -400,6 +400,11 @@ export async function createEvent(data: EventInput) {
400400
}
401401
}
402402
export async function checkSlugAvailability(slug: string) {
403+
const session = await getSession();
404+
const user = await getUserContext(session?.id);
405+
if (!user || !(await can(user, "create", "event", null))) {
406+
return { available: true };
407+
}
403408
const existingEvent = await db.query.events.findFirst({
404409
where: eq(events.slug, slug),
405410
});

app/actions/feed.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,10 @@ export async function getSeriesFeed(seriesId: string, limit = 50, offset = 0) {
8383
if (!seriesData) {
8484
return { success: false, error: "Series not found" };
8585
}
86+
if (!(await can(user, "view", "series", seriesData))) {
87+
if (!user) return { success: false, error: "Unauthorized" };
88+
return { success: false, error: "Forbidden" };
89+
}
8690
const accessibleEventIdsSet = await getAccessibleEventIds(
8791
user?.id,
8892
seriesData.events,

app/actions/map.ts

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { getSession } from "@/lib/auth";
44
import { db } from "@/lib/db";
55
import { events, media } from "@/lib/db/schema";
66
import { logger } from "@/lib/logger";
7+
import { getMediaProxyUrl } from "@/lib/media/s3";
78
import { getAccessibleEventIds, getUserContext } from "@/lib/policy";
89
export async function getMapData(eventSlug?: string | null) {
910
try {
@@ -23,8 +24,6 @@ export async function getMapData(eventSlug?: string | null) {
2324
id: media.id,
2425
filename: media.filename,
2526
mimeType: media.mimeType,
26-
thumbnailS3Key: media.thumbnailS3Key,
27-
s3Key: media.s3Key,
2827
latitude: media.latitude,
2928
longitude: media.longitude,
3029
uploadedAt: media.uploadedAt,
@@ -77,8 +76,7 @@ export async function getMapData(eventSlug?: string | null) {
7776
id: item.id,
7877
filename: item.filename,
7978
mimeType: item.mimeType,
80-
thumbnailS3Key: item.thumbnailS3Key,
81-
s3Key: item.s3Key,
79+
thumbnailUrl: getMediaProxyUrl(item.id, "thumbnail"),
8280
lat: lat,
8381
lng: lng,
8482
uploadedAt: item.uploadedAt,
@@ -130,8 +128,7 @@ export async function getMapData(eventSlug?: string | null) {
130128
id: media.id,
131129
filename: media.filename,
132130
mimeType: media.mimeType,
133-
thumbnailS3Key: media.thumbnailS3Key,
134-
s3Key: media.s3Key,
131+
thumbnailUrl: sql<string>`'/media/' || ${media.id} || '/thumbnail'`,
135132
uploadedAt: media.uploadedAt,
136133
})
137134
.from(media)

app/actions/media.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ import { extractVideoMetadata } from "@/lib/media/video-metadata";
2020
import { can, getUserContext } from "@/lib/policy";
2121
import { publicMedia } from "@/lib/public-data";
2222
import { checkStorageLimit } from "@/lib/storage";
23+
24+
const MAX_DIRECT_ACTION_UPLOAD_BYTES = 100 * 1024 * 1024;
2325
export async function uploadBanner(
2426
entityType: "event" | "series",
2527
entityId: string,
@@ -291,6 +293,13 @@ export async function uploadMedia(formData: FormData) {
291293
if (!validation.valid) {
292294
return { success: false, error: validation.error };
293295
}
296+
if (file.size > MAX_DIRECT_ACTION_UPLOAD_BYTES) {
297+
return {
298+
success: false,
299+
error:
300+
"Direct uploads are limited to 100MB. Use the normal uploader for larger files.",
301+
};
302+
}
294303
const event = await db.query.events.findFirst({
295304
where: eq(events.id, eventId),
296305
});

app/actions/reports.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { revalidatePath } from "next/cache";
44
import { auditLog } from "@/lib/audit";
55
import { getSession } from "@/lib/auth";
66
import { db } from "@/lib/db";
7-
import { reports } from "@/lib/db/schema";
7+
import { media, reports } from "@/lib/db/schema";
88
import { logger } from "@/lib/logger";
99
import { can, getUserContext } from "@/lib/policy";
1010
export async function createReport(mediaId: string, reason: string) {
@@ -16,7 +16,20 @@ export async function createReport(mediaId: string, reason: string) {
1616
if (!reason.trim()) {
1717
return { success: false, error: "Reason is required" };
1818
}
19+
if (reason.length > 2000) {
20+
return { success: false, error: "Reason is too long" };
21+
}
1922
try {
23+
const mediaItem = await db.query.media.findFirst({
24+
where: eq(media.id, mediaId),
25+
with: { event: true },
26+
});
27+
if (!mediaItem) {
28+
return { success: false, error: "Media not found" };
29+
}
30+
if (!(await can(user, "view", "media", mediaItem))) {
31+
return { success: false, error: "Forbidden" };
32+
}
2033
const [newReport] = await db
2134
.insert(reports)
2235
.values({

app/actions/search.ts

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
users,
1313
} from "@/lib/db/schema";
1414
import { logger } from "@/lib/logger";
15+
import { getMediaProxyUrl } from "@/lib/media/s3";
1516
import {
1617
augmentMediaWithPermissions,
1718
can,
@@ -20,6 +21,28 @@ import {
2021
} from "@/lib/policy";
2122
import { type PublicUser, toPublicUser } from "@/lib/user-display";
2223

24+
function toSafeMedia(item: any) {
25+
const {
26+
s3Key: _s3Key,
27+
s3Url: _s3Url,
28+
thumbnailS3Key: _thumbnailS3Key,
29+
originalS3Key: _originalS3Key,
30+
originalThumbnailS3Key: _originalThumbnailS3Key,
31+
blurredS3Key: _blurredS3Key,
32+
blurredThumbnailS3Key: _blurredThumbnailS3Key,
33+
exifData: _exifData,
34+
latitude: _latitude,
35+
longitude: _longitude,
36+
...safe
37+
} = item;
38+
return {
39+
...safe,
40+
url: getMediaProxyUrl(item.id),
41+
thumbnailUrl: getMediaProxyUrl(item.id, "thumbnail"),
42+
uploadedBy: item.uploadedBy ? toPublicUser(item.uploadedBy) : undefined,
43+
};
44+
}
45+
2346
type PublicUserRow = {
2447
id: string;
2548
handle: string | null;
@@ -201,10 +224,7 @@ export async function globalSearch(query: string): Promise<{
201224
users: userResults.map(toPublicUser),
202225
events: eventResults,
203226
series: seriesResults,
204-
media: finalMedia.map((item) => ({
205-
...item,
206-
uploadedBy: toPublicUser(item.uploadedBy),
207-
})),
227+
media: finalMedia.map(toSafeMedia),
208228
tags: tagResults,
209229
},
210230
};
@@ -488,10 +508,7 @@ export async function advancedSearch(
488508
users: userResults.map(toPublicUser),
489509
events: eventResults,
490510
series: [],
491-
media: finalMedia.map((item) => ({
492-
...item,
493-
uploadedBy: toPublicUser(item.uploadedBy),
494-
})),
511+
media: finalMedia.map(toSafeMedia),
495512
tags: tagResults,
496513
},
497514
};

app/actions/sharing.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,9 @@ export async function createShareLink(
3030
if (!(await can(user, "create", "share_link", mediaItem))) {
3131
return { success: false, error: "Unauthorized" };
3232
}
33+
if (mediaItem.blurStatus === "pending") {
34+
return { success: false, error: "Media is under review" };
35+
}
3336
const existingLink = await db.query.shareLinks.findFirst({
3437
where: and(
3538
eq(shareLinks.mediaId, mediaId),
@@ -124,6 +127,9 @@ export async function getSharedMedia(token: string) {
124127
error: "Public sharing is disabled for this event",
125128
};
126129
}
130+
if (link.media.blurStatus === "pending") {
131+
return { success: false, error: "Media is under review" };
132+
}
127133
db.update(shareLinks)
128134
.set({ views: link.views + 1 })
129135
.where(eq(shareLinks.token, token))

0 commit comments

Comments
 (0)