Skip to content

Commit d9a1e66

Browse files
authored
Merge pull request dubinc#2337 from dubinc/fix-upload-security
Add base64 image validation
2 parents d343651 + 9dd8816 commit d9a1e66

7 files changed

Lines changed: 93 additions & 17 deletions

File tree

apps/web/app/api/user/route.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { DubApiError } from "@/lib/api/errors";
22
import { hashToken, withSession } from "@/lib/auth";
33
import { storage } from "@/lib/storage";
44
import { ratelimit, redis } from "@/lib/upstash";
5+
import { base64ImageSchema } from "@/lib/zod/schemas/misc";
56
import { sendEmail } from "@dub/email";
67
import { unsubscribe } from "@dub/email/resend/unsubscribe";
78
import ConfirmEmailChange from "@dub/email/templates/confirm-email-change";
@@ -15,7 +16,7 @@ import { z } from "zod";
1516
const updateUserSchema = z.object({
1617
name: z.preprocess(trim, z.string().min(1).max(64)).optional(),
1718
email: z.preprocess(trim, z.string().email()).optional(),
18-
image: z.string().url().optional(),
19+
image: base64ImageSchema.nullish(),
1920
source: z.preprocess(trim, z.string().min(1).max(32)).optional(),
2021
defaultWorkspace: z.preprocess(trim, z.string().min(1)).optional(),
2122
});

apps/web/lib/actions/partners/update-partner-profile.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,13 @@ import { PartnerProfileType } from "@prisma/client";
1313
import { waitUntil } from "@vercel/functions";
1414
import { stripe } from "../../stripe";
1515
import z from "../../zod";
16+
import { base64ImageSchema } from "../../zod/schemas/misc";
1617
import { authPartnerActionClient } from "../safe-action";
1718

1819
const updatePartnerProfileSchema = z
1920
.object({
2021
name: z.string(),
21-
image: z.string().nullable(),
22+
image: base64ImageSchema.nullish(),
2223
description: z.string().nullable(),
2324
country: z.enum(Object.keys(COUNTRIES) as [string, ...string[]]).nullable(),
2425
profileType: z.nativeEnum(PartnerProfileType),

apps/web/lib/zod/schemas/domains.ts

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
import { normalizeWorkspaceId } from "@/lib/api/workspace-id";
22
import z from "@/lib/zod";
3-
import { booleanQuerySchema, getPaginationQuerySchema } from "./misc";
3+
import {
4+
base64ImageSchema,
5+
booleanQuerySchema,
6+
getPaginationQuerySchema,
7+
} from "./misc";
48
import { parseUrlSchemaAllowEmpty } from "./utils";
59

610
export const RegisteredDomainSchema = z.object({
@@ -140,12 +144,7 @@ export const createDomainBodySchema = z.object({
140144
"Provide context to your teammates in the link creation modal by showing them an example of a link to be shortened.",
141145
)
142146
.openapi({ example: "https://dub.co/help/article/what-is-dub" }),
143-
logo: z
144-
.string()
145-
.trim()
146-
.nullish()
147-
.transform((v) => v || null)
148-
.describe("The logo of the domain."),
147+
logo: base64ImageSchema.nullish().describe("The logo of the domain."),
149148
assetLinks: z
150149
.string()
151150
.nullish()

apps/web/lib/zod/schemas/integration.ts

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { R2_URL } from "@dub/utils";
22
import { z } from "zod";
3+
import { base64ImageSchema } from "./misc";
34

45
export const integrationSchema = z.object({
56
id: z.string(),
@@ -37,12 +38,7 @@ export const createIntegrationSchema = z.object({
3738
message: "installUrl must be a valid URL",
3839
})
3940
.nullish(),
40-
logo: z
41-
.string()
42-
.url({
43-
message: "Please provide a valid URL for the logo",
44-
})
45-
.nullish(),
41+
logo: base64ImageSchema.nullish().describe("The logo of the integration."),
4642
description: z
4743
.string()
4844
.max(120, {

apps/web/lib/zod/schemas/misc.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,3 +49,29 @@ export const maxDurationSchema = z.coerce
4949
message: `Max duration must be ${RECURRING_MAX_DURATIONS.join(", ")}`,
5050
})
5151
.nullish();
52+
53+
// Base64 encoded image
54+
export const base64ImageSchema = z
55+
.string()
56+
.regex(/^data:image\/(png|jpeg|jpg|gif|webp);base64,/, {
57+
message:
58+
"Invalid image format. Must be a base64 encoded image with valid image type (png, jpeg, jpg, gif, webp).",
59+
})
60+
.refine(
61+
(str) => {
62+
const base64Data = str.split(",")[1];
63+
64+
if (!base64Data) {
65+
return false;
66+
}
67+
68+
return /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(
69+
base64Data,
70+
);
71+
},
72+
{
73+
message:
74+
"Invalid base64 content. The image data is not properly base64 encoded.",
75+
},
76+
)
77+
.transform((v) => v || null);

apps/web/lib/zod/schemas/workspaces.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import z from "@/lib/zod";
22
import { DEFAULT_REDIRECTS, RESERVED_SLUGS, validSlugRegex } from "@dub/utils";
33
import slugify from "@sindresorhus/slugify";
44
import { DomainSchema } from "./domains";
5-
import { planSchema, roleSchema } from "./misc";
5+
import { base64ImageSchema, planSchema, roleSchema } from "./misc";
66

77
export const workspaceIdSchema = z.object({
88
workspaceId: z
@@ -139,7 +139,7 @@ export const createWorkspaceSchema = z.object({
139139
message: "Cannot use reserved slugs",
140140
},
141141
),
142-
logo: z.string().optional(),
142+
logo: base64ImageSchema.nullish(),
143143
conversionEnabled: z.boolean().optional(),
144144
});
145145

apps/web/tests/misc/base64.test.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { base64ImageSchema } from "@/lib/zod/schemas/misc";
2+
import { describe, expect, it } from "vitest";
3+
4+
describe("base64ImageSchema", () => {
5+
it("should validate a correct base64 PNG image", () => {
6+
const validBase64Image =
7+
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==";
8+
expect(() => base64ImageSchema.parse(validBase64Image)).not.toThrow();
9+
});
10+
11+
it("should reject an invalid image type", () => {
12+
const invalidImageType =
13+
"data:image/invalid;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==";
14+
expect(() => base64ImageSchema.parse(invalidImageType)).toThrow(
15+
"Invalid image format",
16+
);
17+
});
18+
19+
it("should reject malformed base64 data", () => {
20+
const malformedBase64 = "data:image/png;base64,invalid-base64-data";
21+
expect(() => base64ImageSchema.parse(malformedBase64)).toThrow(
22+
"Invalid base64 content",
23+
);
24+
});
25+
26+
it("should reject a string without data URI prefix", () => {
27+
const noPrefix =
28+
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==";
29+
expect(() => base64ImageSchema.parse(noPrefix)).toThrow(
30+
"Invalid image format",
31+
);
32+
});
33+
34+
it("should reject URLs", () => {
35+
const url = "https://github.com/dubinc/dub";
36+
expect(() => base64ImageSchema.parse(url)).toThrow("Invalid image format");
37+
});
38+
39+
it("should reject URLs with image extensions", () => {
40+
const imageUrl = "https://example.com/image.png";
41+
expect(() => base64ImageSchema.parse(imageUrl)).toThrow(
42+
"Invalid image format",
43+
);
44+
});
45+
46+
it("should reject URLs with data URI prefix but invalid format", () => {
47+
const invalidDataUri =
48+
"data:image/png;base64,https://example.com/image.png";
49+
expect(() => base64ImageSchema.parse(invalidDataUri)).toThrow(
50+
"Invalid base64 content",
51+
);
52+
});
53+
});

0 commit comments

Comments
 (0)