Skip to content
Closed
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
51 changes: 44 additions & 7 deletions next.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -33,15 +33,52 @@ const nextConfig = {
],
},
async headers() {
const securityHeaders = [
{
key: "X-Frame-Options",
value: "DENY",
},
{
key: "X-Content-Type-Options",
value: "nosniff",
},
{
key: "Referrer-Policy",
value: "strict-origin-when-cross-origin",
},
{
key: "Permissions-Policy",
value: "camera=(), microphone=(), geolocation=(), payment=()",
},
{
key: "Content-Security-Policy",
value: [
"default-src 'self'",
"base-uri 'self'",
"form-action 'self'",
"frame-ancestors 'none'",
"object-src 'none'",
"img-src 'self' data: blob: https:",
"media-src 'self' data: blob: https:",
"font-src 'self' data: https:",
"style-src 'self' 'unsafe-inline'",
"script-src 'self' 'unsafe-inline'",
"connect-src 'self' https: wss:",
].join("; "),
},
];

if (process.env.NODE_ENV === "production") {
securityHeaders.push({
key: "Strict-Transport-Security",
value: "max-age=31536000; includeSubDomains",
});
}

return [
{
source: "/(.*)",
headers: [
{
key: "X-Frame-Options",
value: "DENY",
},
],
headers: securityHeaders,
},
];
},
Expand All @@ -55,4 +92,4 @@ const nextConfig = {
},
};

export default withPWA(nextConfig);
export default withPWA(nextConfig);
74 changes: 74 additions & 0 deletions src/__tests__/api/video-generate-route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { POST } from "@/app/api/video/generate/route";

jest.mock("@clerk/nextjs/server", () => ({
currentUser: jest.fn().mockResolvedValue({
id: "user_123",
primaryEmailAddress: { emailAddress: "student@example.com" },
}),
}));

jest.mock("@/lib/ratelimit/api-rate-limit", () => ({
enforceApiRateLimit: jest.fn().mockResolvedValue(null),
}));

jest.mock("@/lib/auth/auth-utils", () => ({
checkUserBlock: jest.fn().mockResolvedValue({
isBlocked: false,
errorResponse: undefined,
dbUser: undefined,
}),
}));

jest.mock("@/configs/db", () => ({
db: {
insert: jest.fn(),
},
}));

jest.mock("@/configs/schema", () => ({
videoJobsTable: {},
}));

jest.mock("@/inngest/client", () => ({
inngest: {
send: jest.fn(),
},
}));

jest.mock("@/lib/ratelimit/ratelimit", () => ({
videoLimiter: {
limit: jest.fn(),
},
redisClient: {
set: jest.fn(),
del: jest.fn(),
},
}));

describe("video generate route", () => {
it("rejects malformed image content before queueing OCR work", async () => {
const fetchSpy = jest.spyOn(globalThis, "fetch").mockResolvedValueOnce(
new Response("not an image", {
status: 200,
headers: { "Content-Type": "image/jpeg" },
}),
);

const res = await POST(
new Request("http://localhost/api/video/generate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
imageUrl: "https://example.com/malformed.jpg",
}),
}),
);

expect(fetchSpy).toHaveBeenCalledTimes(1);
expect(res.status).toBe(422);
await expect(res.json()).resolves.toEqual({
error: "Please upload a valid PNG, JPG, or WEBP image.",
code: "INVALID_IMAGE_PAYLOAD",
});
});
});
45 changes: 45 additions & 0 deletions src/__tests__/lib/video-image-validation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import {
detectVideoImageMimeType,
validateVideoImageUrl,
} from "@/lib/video/image-validation";

describe("video image validation", () => {
it("detects PNG, JPEG, and WEBP magic bytes", () => {
expect(
detectVideoImageMimeType(
new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
),
).toBe("image/png");
expect(detectVideoImageMimeType(new Uint8Array([0xff, 0xd8, 0xff, 0x00]))).toBe(
"image/jpeg",
);
expect(
detectVideoImageMimeType(
new Uint8Array([
0x52, 0x49, 0x46, 0x46, 0x00, 0x00, 0x00, 0x00, 0x57, 0x45, 0x42, 0x50,
]),
),
).toBe("image/webp");
});

it("rejects non-image payloads before OCR", async () => {
const fetchSpy = jest.spyOn(globalThis, "fetch").mockResolvedValueOnce(
new Response("not an image", {
status: 200,
headers: { "Content-Type": "image/jpeg" },
}),
);

const result = await validateVideoImageUrl("https://example.com/malformed.jpg");

expect(fetchSpy).toHaveBeenCalledWith("https://example.com/malformed.jpg", {
cache: "no-store",
});
expect(result).toEqual({
ok: false,
status: 422,
code: "INVALID_IMAGE_PAYLOAD",
error: "Please upload a valid PNG, JPG, or WEBP image.",
});
});
});
14 changes: 14 additions & 0 deletions src/app/api/video/generate/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import { enforceApiRateLimit } from "@/lib/ratelimit/api-rate-limit";
import { parseAndValidateRequest } from "@/lib/validations/validate";
import { generateVideoSchema } from "@/lib/validations/video";
import { validateVideoImageUrl } from "@/lib/video/image-validation";
import { db } from "@/configs/db";
import { videoJobsTable } from "@/configs/schema";
import { inngest } from "@/inngest/client";
Expand Down Expand Up @@ -42,6 +43,19 @@
const { errorResponse, data } = await parseAndValidateRequest(req, generateVideoSchema);
if (errorResponse) return errorResponse;

if (data.imageUrl) {
const imageValidation = await validateVideoImageUrl(data.imageUrl);
if (!imageValidation.ok) {
return NextResponse.json(
{
error: imageValidation.error,

Check failure on line 51 in src/app/api/video/generate/route.ts

View workflow job for this annotation

GitHub Actions / TypeScript Check

Property 'error' does not exist on type 'VideoImageValidationResult'.
code: imageValidation.code,

Check failure on line 52 in src/app/api/video/generate/route.ts

View workflow job for this annotation

GitHub Actions / TypeScript Check

Property 'code' does not exist on type 'VideoImageValidationResult'.
},
{ status: imageValidation.status },

Check failure on line 54 in src/app/api/video/generate/route.ts

View workflow job for this annotation

GitHub Actions / TypeScript Check

Property 'status' does not exist on type 'VideoImageValidationResult'.
);
}
}

// One active generation per user. The background job releases this lock when it
// finishes (success or failure); a 5-minute TTL guards against leaked locks.
const lockKey = `video_lock:${user.id}`;
Expand Down
82 changes: 82 additions & 0 deletions src/lib/video/image-validation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
const JPEG_SIGNATURE = [0xff, 0xd8, 0xff];
const WEBP_SIGNATURE_PREFIX = [0x52, 0x49, 0x46, 0x46];
const WEBP_SIGNATURE_SUFFIX = [0x57, 0x45, 0x42, 0x50];

export const VIDEO_IMAGE_ALLOWED_MIME_TYPES = [
'image/png',
'image/jpeg',
'image/webp',
] as const;

export const VIDEO_IMAGE_ALLOWED_TYPES_LABEL = 'PNG, JPG, or WEBP';

export type VideoImageValidationResult =
| { ok: true; mimeType: string }
| {
ok: false;
status: 422 | 500;
code: string;
error: string;
};

export function isAllowedVideoImageMimeType(mimeType: string) {
return (VIDEO_IMAGE_ALLOWED_MIME_TYPES as readonly string[]).includes(
mimeType.toLowerCase(),
);
}

export function detectVideoImageMimeType(bytes: Uint8Array): string | null {
if (bytes.length >= PNG_SIGNATURE.length && PNG_SIGNATURE.every((byte, index) => bytes[index] === byte)) {
return 'image/png';
}

if (bytes.length >= JPEG_SIGNATURE.length && JPEG_SIGNATURE.every((byte, index) => bytes[index] === byte)) {
return 'image/jpeg';
}

if (
bytes.length >= 12 &&
WEBP_SIGNATURE_PREFIX.every((byte, index) => bytes[index] === byte) &&
WEBP_SIGNATURE_SUFFIX.every((byte, index) => bytes[index + 8] === byte)
) {
return 'image/webp';
}

return null;
}

export async function validateVideoImageUrl(imageUrl: string): Promise<VideoImageValidationResult> {
try {
const response = await fetch(imageUrl, { cache: 'no-store' });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: This fetches a user-controlled URL directly from the server without any SSRF guardrails (no scheme restriction to HTTPS only, no private-network/localhost/IP blocking, no allowlist). An attacker can submit internal URLs and make the backend probe internal services/metadata endpoints. Add strict outbound URL validation (protocol + hostname/IP checks) before issuing the request. [ssrf]

Severity Level: Critical 🚨
❌ /api/video/generate can fetch attacker-chosen internal URLs.
❌ Backend may reach cloud metadata or admin services.
⚠️ Potential pivot to internal network reconnaissance.
Steps of Reproduction ✅
1. Start the DoubtDesk Next.js app so that the API route handler in
`src/app/api/video/generate/route.ts:23-110` is active, exposing `POST
/api/video/generate`.

2. From a client, send `POST /api/video/generate` with JSON body `{ "content": null,
"imageUrl": "http://127.0.0.1:8080/internal-status" }`. The body is parsed and validated
by `parseAndValidateRequest` in `src/lib/validations/validate.ts:5-27` using
`generateVideoSchema` in `src/lib/validations/video.ts:4-7`, which applies `safeUrl` from
`src/lib/validations/common.ts:3-5` (only syntax + length checks, no SSRF filtering).

3. In `src/app/api/video/generate/route.ts:46-48`, the handler sees a non-null
`data.imageUrl` and calls `validateVideoImageUrl(data.imageUrl)`, passing the
attacker-controlled URL to `validateVideoImageUrl` implemented in
`src/lib/video/image-validation.ts:49-82`.

4. Inside `validateVideoImageUrl`, line `51 const response = await fetch(imageUrl, {
cache: 'no-store' });` issues a server-side `fetch` to
`http://127.0.0.1:8080/internal-status` (or any attacker-chosen internal/metadata
endpoint). There are no scheme, hostname, or private-network restrictions, so the backend
will attempt to reach internal services, demonstrating an SSRF capability even though only
a validation result (not the full response body) is returned to the client.

Fix in Cursor Fix in VSCode Claude

(Use Cmd/Ctrl + Click for best experience)

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** src/lib/video/image-validation.ts
**Line:** 51:51
**Comment:**
	*Ssrf: This fetches a user-controlled URL directly from the server without any SSRF guardrails (no scheme restriction to HTTPS only, no private-network/localhost/IP blocking, no allowlist). An attacker can submit internal URLs and make the backend probe internal services/metadata endpoints. Add strict outbound URL validation (protocol + hostname/IP checks) before issuing the request.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

if (!response.ok) {
return {
ok: false,
status: 422,
code: 'INVALID_IMAGE_PAYLOAD',
error: `Please upload a valid ${VIDEO_IMAGE_ALLOWED_TYPES_LABEL} image.`,
};
}

const bytes = new Uint8Array(await response.arrayBuffer());

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: This reads the entire remote response into memory with no size cap, so a large payload can cause excessive memory usage or process instability before validation completes. Enforce a strict maximum download size (via Content-Length checks plus streamed byte limits) and reject oversized payloads early. [performance]

Severity Level: Major ⚠️
❌ Large remote images can OOM Next.js video API.
⚠️ Video generation requests can stall under resource exhaustion.
⚠️ Resource spikes may degrade other API endpoints.
Steps of Reproduction ✅
1. Run the DoubtDesk Next.js backend so that the video generation API handler in
`src/app/api/video/generate/route.ts:23-110` is exposed as `POST /api/video/generate`.

2. Host a very large file (e.g., >500MB) at an attacker-controlled URL such as
`https://attacker.example/huge.bin`, and send `POST /api/video/generate` with body `{
"content": null, "imageUrl": "https://attacker.example/huge.bin" }`. The request body is
accepted by `parseAndValidateRequest` (`src/lib/validations/validate.ts:5-27`) and
`generateVideoSchema` (`src/lib/validations/video.ts:4-7`) as long as the URL is
syntactically valid.

3. In `src/app/api/video/generate/route.ts:46-48`, the handler calls
`validateVideoImageUrl(data.imageUrl)`. Inside `validateVideoImageUrl`
(`src/lib/video/image-validation.ts:49-82`), line `51 const response = await
fetch(imageUrl, { cache: 'no-store' });` starts downloading the large file from the remote
server.

4. At `src/lib/video/image-validation.ts:61`, `const bytes = new Uint8Array(await
response.arrayBuffer());` reads the entire response body into an `ArrayBuffer` with no
`Content-Length` check and no byte-size cap, unlike the explicit `AI_IMAGE_MAX_BYTES`
limit enforced in `src/lib/ai/ai-image-validation.ts:1-3,102-110`. For very large
payloads, this can cause excessive memory usage or process instability (OOM or severe GC
thrashing) in the Next.js server handling `/api/video/generate`.

Fix in Cursor Fix in VSCode Claude

(Use Cmd/Ctrl + Click for best experience)

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** src/lib/video/image-validation.ts
**Line:** 61:61
**Comment:**
	*Performance: This reads the entire remote response into memory with no size cap, so a large payload can cause excessive memory usage or process instability before validation completes. Enforce a strict maximum download size (via `Content-Length` checks plus streamed byte limits) and reject oversized payloads early.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

const mimeType = detectVideoImageMimeType(bytes);

if (!mimeType || !isAllowedVideoImageMimeType(mimeType)) {
return {
ok: false,
status: 422,
code: 'INVALID_IMAGE_PAYLOAD',
error: `Please upload a valid ${VIDEO_IMAGE_ALLOWED_TYPES_LABEL} image.`,
};
}

return { ok: true, mimeType };
} catch {
return {
ok: false,
status: 422,
code: 'INVALID_IMAGE_PAYLOAD',
error: `Please upload a valid ${VIDEO_IMAGE_ALLOWED_TYPES_LABEL} image.`,
};
}
}
Loading