Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .jules/sentinel.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,8 @@
## 2025-03-08 - Unsafe SVG Rendering via dangerouslySetInnerHTML
**Vulnerability:** User-controlled SVG strings (`avatar.svg_data`) were being injected directly into the DOM using `dangerouslySetInnerHTML`. An attacker could inject an SVG containing embedded malicious `<script>` tags or `onload` event handlers, leading to Stored Cross-Site Scripting (XSS).
**Learning:** Rendering raw SVGs inline is inherently dangerous. SVGs are essentially XML documents capable of embedding JavaScript.
**Prevention:** When you need to display user-provided SVGs and you don't need to manipulate their internal paths via CSS/JS, do not use `dangerouslySetInnerHTML`. Instead, render the SVG securely by encoding it as a data URI (`data:image/svg+xml;charset=utf-8,${encodeURIComponent(svgData)}`) and using it as the `src` attribute of a standard `<img>` tag. This forces the browser to treat the SVG strictly as an image, entirely disabling any embedded script execution.
**Prevention:** When you need to display user-provided SVGs and you don't need to manipulate their internal paths via CSS/JS, do not use `dangerouslySetInnerHTML`. Instead, render the SVG securely by encoding it as a data URI (`data:image/svg+xml;charset=utf-8,${encodeURIComponent(svgData)}`) and using it as the `src` attribute of a standard `<img>` tag. This forces the browser to treat the SVG strictly as an image, entirely disabling any embedded script execution.
## 2025-03-08 - Unrestricted File Upload via Presigned URLs
**Vulnerability:** The presigned URL generation endpoint (`app/api/upload/presigned/route.ts`) accepted arbitrary client-provided `fileType` and `fileName` strings and passed them directly to Backblaze B2 to generate an upload URL. This meant an attacker could bypass the strict validation applied in direct upload endpoints (like `api/upload/documents`) and upload malicious files (e.g., scripts, executables, or malicious SVGs) into cloud storage simply by providing a fake MIME type or an unvalidated file extension.
**Learning:** File type validation (MIME types, file extensions, and sizes) must be consistently enforced across *all* upload pathways. Generating a presigned URL is functionally equivalent to authorizing an upload and must undergo the exact same strict validation logic (like checking `DANGEROUS_EXTENSIONS`) as processing a direct file stream.
**Prevention:** Use a centralized, single-source-of-truth validation function (`validateFile` from `lib/constants/upload.ts`) on the server before generating any presigned URLs. Always use a strict whitelist of allowed MIME types and a blacklist of dangerous extensions.
66 changes: 54 additions & 12 deletions app/api/upload/presigned/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,39 +2,78 @@ import { NextRequest, NextResponse } from "next/server";
import { b2 } from "@/lib/backblaze";
import { requireUploadAccess } from "@/lib/security/upload-access";
import { safeServerError } from "@/lib/security/route-guards";
import {
validateFile,
ALLOWED_GENERAL_TYPES,
ALLOWED_DOCUMENT_TYPES,
MAX_FILE_SIZE,
} from "@/lib/constants/upload";

export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { nodeId, fileName, fileType, fileSize, uploadType } = body;

if (!nodeId || typeof nodeId !== "string" || nodeId.trim().length === 0) {
return NextResponse.json({ error: "Valid nodeId is required" }, { status: 400 });
return NextResponse.json(
{ error: "Valid nodeId is required" },
{ status: 400 },
);
}

const access = await requireUploadAccess(nodeId);
if (!access.ok) {
return NextResponse.json({ error: access.error }, { status: access.status });
return NextResponse.json(
{ error: access.error },
{ status: access.status },
);
}

const validUploadTypes = ["submission", "map-content"];
const finalUploadType = uploadType && validUploadTypes.includes(uploadType) ? uploadType : "submission";
const finalUploadType =
uploadType && validUploadTypes.includes(uploadType)
? uploadType
: "submission";

if (!fileName || typeof fileName !== "string" || fileName.trim().length === 0) {
return NextResponse.json({ error: "Valid fileName is required" }, { status: 400 });
if (
!fileName ||
typeof fileName !== "string" ||
fileName.trim().length === 0
) {
return NextResponse.json(
{ error: "Valid fileName is required" },
{ status: 400 },
);
}

if (!fileType || typeof fileType !== "string") {
return NextResponse.json({ error: "Valid fileType is required" }, { status: 400 });
return NextResponse.json(
{ error: "Valid fileType is required" },
{ status: 400 },
);
}

if (!fileSize || typeof fileSize !== "number" || fileSize <= 0) {
return NextResponse.json({ error: "Valid fileSize is required" }, { status: 400 });
return NextResponse.json(
{ error: "Valid fileSize is required" },
{ status: 400 },
);
}
Comment on lines 56 to 61

const MAX_FILE_SIZE = 40 * 1024 * 1024;
if (fileSize > MAX_FILE_SIZE) {
return NextResponse.json({ error: "File too large" }, { status: 400 });
const allowedTypes =
finalUploadType === "map-content"
? ALLOWED_DOCUMENT_TYPES
: ALLOWED_GENERAL_TYPES;
const validation = validateFile(
fileName,
fileSize,
fileType,
allowedTypes,
MAX_FILE_SIZE,
);

if (!validation.valid) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}

const presignedData = await b2.getPresignedUploadUrl(
Expand All @@ -44,7 +83,7 @@ export async function POST(request: NextRequest) {
fileType,
fileSize,
finalUploadType as "submission" | "map-content",
3600
3600,
);

const fileUrl = `https://${process.env.B2_BUCKET_NAME}.${process.env.B2_ENDPOINT || "s3.us-west-000.backblazeb2.com"}/${presignedData.fileKey}`;
Expand All @@ -57,6 +96,9 @@ export async function POST(request: NextRequest) {
fileUrl,
});
} catch (error) {
return safeServerError("Internal server error. Please try again later.", error);
return safeServerError(
"Internal server error. Please try again later.",
error,
);
}
}