-
Notifications
You must be signed in to change notification settings - Fork 173
Add missing security headers to next config #883
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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", | ||
| }); | ||
| }); | ||
| }); |
| 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.", | ||
| }); | ||
| }); | ||
| }); |
| 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' }); | ||
| 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()); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 Severity Level: Major
|
||
| 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.`, | ||
| }; | ||
| } | ||
| } | ||
There was a problem hiding this comment.
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 🚨
Steps of Reproduction ✅
(Use Cmd/Ctrl + Click for best experience)
Prompt for AI Agent 🤖