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
5 changes: 4 additions & 1 deletion .jules/sentinel.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,7 @@
## 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.## 2024-05-24 - File Extension Validation in Presigned URLs
**Vulnerability:** The presigned upload endpoint (`app/api/upload/presigned/route.ts`) verified `fileType` strings but failed to check file extensions against a known denylist. This mismatch could theoretically allow an attacker to upload an executable or script (.exe, .php, .html) with a spoofed image MIME type, bypassing security on the CDN.
**Learning:** Upload APIs that broker presigned direct-to-cloud (B2/S3) uploads frequently lack the deep validation checks implemented in direct form-data upload APIs because they never touch the file body. Validation must be duplicated exactly or shared via constants.
**Prevention:** Centralize file validation into a shared function (e.g., `validateFile` in `lib/constants/upload.ts`) that strictly evaluates sizes, MIME types, AND explicitly blocks dangerous file extensions (`DANGEROUS_EXTENSIONS`), then enforce this helper unconditionally across all upload paths.
30 changes: 17 additions & 13 deletions app/api/upload/presigned/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@ 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 {
Expand All @@ -20,21 +26,19 @@ export async function POST(request: NextRequest) {
const validUploadTypes = ["submission", "map-content"];
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 (!fileType || typeof fileType !== "string") {
return NextResponse.json({ error: "Valid fileType is required" }, { status: 400 });
}
const allowedTypes = finalUploadType === "map-content" ? ALLOWED_DOCUMENT_TYPES : ALLOWED_GENERAL_TYPES;

if (!fileSize || typeof fileSize !== "number" || fileSize <= 0) {
return NextResponse.json({ error: "Valid fileSize is required" }, { status: 400 });
}
// Use shared validation logic to enforce secure file types and extensions
const fileValidation = validateFile(
fileName || "",
fileSize || 0,
fileType || "",
allowedTypes,
MAX_FILE_SIZE
Comment on lines +31 to +37
);
Comment on lines +31 to +38

const MAX_FILE_SIZE = 40 * 1024 * 1024;
if (fileSize > MAX_FILE_SIZE) {
return NextResponse.json({ error: "File too large" }, { status: 400 });
if (!fileValidation.valid) {
return NextResponse.json({ error: fileValidation.error }, { status: 400 });
}

const presignedData = await b2.getPresignedUploadUrl(
Expand Down