Skip to content

Commit 27bcf61

Browse files
Fix invalid link preview images (dubinc#3682)
Co-authored-by: Steven Tey <stevensteel97@gmail.com>
1 parent b18b3ba commit 27bcf61

12 files changed

Lines changed: 142 additions & 127 deletions

File tree

apps/web/app/api/links/metatags/utils.ts

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { recordMetatags } from "@/lib/upstash";
2+
import { linkPreviewImageBase64PrefixRegex } from "@/lib/zod/schemas/images";
23
import { fetchWithTimeout, isValidUrl } from "@dub/utils";
34
import { waitUntil } from "@vercel/functions";
45
import he from "he";
@@ -62,12 +63,25 @@ export const getRelativeUrl = (url: string, imageUrl: string) => {
6263
if (!imageUrl) {
6364
return null;
6465
}
65-
if (isValidUrl(imageUrl)) {
66-
return imageUrl;
66+
let resolved: string;
67+
try {
68+
resolved = isValidUrl(imageUrl)
69+
? new URL(imageUrl).toString()
70+
: (() => {
71+
const { protocol, host } = new URL(url);
72+
const baseURL = `${protocol}//${host}`;
73+
return new URL(imageUrl, baseURL).toString();
74+
})();
75+
} catch {
76+
return null;
77+
}
78+
if (
79+
/^https?:\/\//i.test(resolved) ||
80+
linkPreviewImageBase64PrefixRegex.test(resolved)
81+
) {
82+
return resolved;
6783
}
68-
const { protocol, host } = new URL(url);
69-
const baseURL = `${protocol}//${host}`;
70-
return new URL(imageUrl, baseURL).toString();
84+
return null;
7185
};
7286

7387
const generateFallbackMetadata = (url: string) => {

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { DubApiError } from "@/lib/api/errors";
22
import { withSession } from "@/lib/auth";
33
import { confirmEmailChange } from "@/lib/auth/confirm-email-change";
44
import { storage } from "@/lib/storage";
5-
import { uploadedImageSchema } from "@/lib/zod/schemas/misc";
5+
import { uploadedImageSchema } from "@/lib/zod/schemas/images";
66
import { prisma } from "@dub/prisma";
77
import {
88
APP_DOMAIN,

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import {
2121
} from "@dub/utils";
2222
import { waitUntil } from "@vercel/functions";
2323
import * as z from "zod/v4";
24-
import { uploadedImageSchema } from "../../zod/schemas/misc";
24+
import { uploadedImageSchema } from "../../zod/schemas/images";
2525
import { authPartnerActionClient } from "../safe-action";
2626

2727
const updatePartnerProfileSchema = z

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

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,7 @@
11
import { normalizeWorkspaceId } from "@/lib/api/workspaces/workspace-id";
22
import * as z from "zod/v4";
3-
import {
4-
booleanQuerySchema,
5-
getPaginationQuerySchema,
6-
uploadedImageSchema,
7-
} from "./misc";
3+
import { uploadedImageSchema } from "./images";
4+
import { booleanQuerySchema, getPaginationQuerySchema } from "./misc";
85
import { parseUrlSchemaAllowEmpty } from "./utils";
96

107
export const RegisteredDomainSchema = z.object({

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

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import { GOOGLE_FAVICON_URL, R2_URL } from "@dub/utils";
2+
import { fileTypeFromBuffer } from "file-type";
3+
import * as z from "zod/v4";
4+
5+
/** Raster data-URL prefix for link preview images (base64ImageSchema, preprocess, metatags). */
6+
export const linkPreviewImageBase64PrefixRegex =
7+
/^data:image\/(png|jpeg|jpg|gif|webp);base64,/i;
8+
9+
const allowedImageTypes = [
10+
"image/png",
11+
"image/jpeg",
12+
"image/jpg",
13+
"image/gif",
14+
"image/webp",
15+
];
16+
17+
// Base64 encoded image
18+
export const base64ImageSchema = z
19+
.string()
20+
.trim()
21+
.regex(linkPreviewImageBase64PrefixRegex, {
22+
message: "Invalid image format, supports only png, jpeg, jpg, gif, webp.",
23+
})
24+
.refine(
25+
async (str) => {
26+
const base64Data = str.split(",")[1];
27+
28+
if (!base64Data) {
29+
return false;
30+
}
31+
32+
try {
33+
const buffer = new Uint8Array(Buffer.from(base64Data, "base64"));
34+
const fileType = await fileTypeFromBuffer(buffer);
35+
36+
return fileType && allowedImageTypes.includes(fileType.mime);
37+
} catch (e) {
38+
return false;
39+
}
40+
},
41+
{
42+
message: "Invalid image format, supports only png, jpeg, jpg, gif, webp.",
43+
},
44+
)
45+
.transform((v) => v || null);
46+
47+
export const storedR2ImageUrlSchema = z
48+
.url()
49+
.trim()
50+
.refine((url) => url.startsWith(R2_URL), {
51+
message: `URL must start with ${R2_URL}`,
52+
});
53+
54+
// Google favicon URL schema - supports URLs starting with GOOGLE_FAVICON_URL
55+
export const googleFaviconUrlSchema = z
56+
.url()
57+
.trim()
58+
.refine((url) => url.startsWith(GOOGLE_FAVICON_URL), {
59+
message: `Image URL must start with ${GOOGLE_FAVICON_URL}`,
60+
});
61+
62+
// Google user content URL schema - supports URLs like https://lh3.googleusercontent.com/...
63+
// This is needed when users sign up via Google OAuth and want to use their Google profile image
64+
// as their workspace logo or avatar
65+
export const googleUserContentUrlSchema = z
66+
.url()
67+
.trim()
68+
.refine((url) => url.startsWith("https://lh3.googleusercontent.com/"), {
69+
message: "Image URL must be a valid Google user content URL",
70+
});
71+
72+
// Uploaded image could be any of the following:
73+
// - Base64 encoded image
74+
// - R2_URL
75+
// - Special case for GOOGLE_FAVICON_URL
76+
// This schema contains an async refinement check for base64 image validation,
77+
// which requires using parseAsync() instead of parse() when validating
78+
export const uploadedImageSchema = z
79+
.union([base64ImageSchema, storedR2ImageUrlSchema, googleFaviconUrlSchema])
80+
.transform((v) => v || null);
81+
82+
export const publicHostedImageSchema = z
83+
.url()
84+
.trim()
85+
.refine((url) => url.startsWith("http://") || url.startsWith("https://"), {
86+
message: "Image URL must start with http:// or https://",
87+
});
88+
89+
/** Coerce unusable preview strings (e.g. data:favicons) to null before create/update link validation. */
90+
export function preprocessLinkPreviewImage(
91+
val: unknown,
92+
): string | null | undefined {
93+
if (val === undefined) return undefined;
94+
if (val === null || val === "" || typeof val !== "string") return null;
95+
const s = val.trim();
96+
if (
97+
s.startsWith("http://") ||
98+
s.startsWith("https://") ||
99+
linkPreviewImageBase64PrefixRegex.test(s)
100+
)
101+
return s;
102+
return null;
103+
}

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { R2_URL } from "@dub/utils";
22
import * as z from "zod/v4";
3-
import { uploadedImageSchema } from "./misc";
3+
import { uploadedImageSchema } from "./images";
44

55
export const integrationSchema = z.object({
66
id: z.string(),

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

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,13 @@ import { DUB_FOUNDING_DATE, formatDate, validDomainRegex } from "@dub/utils";
33
import * as z from "zod/v4";
44
import {
55
base64ImageSchema,
6+
preprocessLinkPreviewImage,
7+
publicHostedImageSchema,
8+
} from "./images";
9+
import {
610
booleanQuerySchema,
711
getCursorPaginationQuerySchema,
812
getPaginationQuerySchema,
9-
publicHostedImageSchema,
1013
} from "./misc";
1114
import { LinkTagSchema } from "./tags";
1215
import {
@@ -550,7 +553,10 @@ export const createLinkBodySchema = z.object({
550553
});
551554

552555
export const createLinkBodySchemaAsync = createLinkBodySchema.extend({
553-
image: z.union([base64ImageSchema, publicHostedImageSchema]).nullish(),
556+
image: z.preprocess(
557+
preprocessLinkPreviewImage,
558+
z.union([base64ImageSchema, publicHostedImageSchema]).nullish(),
559+
),
554560
});
555561

556562
export const updateLinkBodySchema = createLinkBodySchemaAsync

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

Lines changed: 0 additions & 100 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
11
import { plans } from "@/lib/types";
22
import { WorkspaceRole } from "@dub/prisma/client";
3-
import { GOOGLE_FAVICON_URL, R2_URL } from "@dub/utils";
4-
import { fileTypeFromBuffer } from "file-type";
53
import * as z from "zod/v4";
64

75
export const RECURRING_MAX_DURATIONS = [0, 1, 3, 6, 12, 18, 24, 36, 48];
@@ -12,14 +10,6 @@ export const roleSchema = z
1210
.enum(WorkspaceRole)
1311
.describe("The role of the authenticated user in the workspace.");
1412

15-
const allowedImageTypes = [
16-
"image/png",
17-
"image/jpeg",
18-
"image/jpg",
19-
"image/gif",
20-
"image/webp",
21-
];
22-
2313
export const booleanQuerySchema = z
2414
.stringbool({
2515
truthy: ["true"],
@@ -96,93 +86,3 @@ export const maxDurationSchema = z.coerce
9686
message: `Max duration must be ${RECURRING_MAX_DURATIONS.join(", ")}`,
9787
})
9888
.nullish();
99-
100-
// Base64 encoded image
101-
export const base64ImageSchema = z
102-
.string()
103-
.trim()
104-
.regex(/^data:image\/(png|jpeg|jpg|gif|webp);base64,/, {
105-
message: "Invalid image format, supports only png, jpeg, jpg, gif, webp.",
106-
})
107-
.refine(
108-
async (str) => {
109-
const base64Data = str.split(",")[1];
110-
111-
if (!base64Data) {
112-
return false;
113-
}
114-
115-
try {
116-
const buffer = new Uint8Array(Buffer.from(base64Data, "base64"));
117-
const fileType = await fileTypeFromBuffer(buffer);
118-
119-
return fileType && allowedImageTypes.includes(fileType.mime);
120-
} catch (e) {
121-
return false;
122-
}
123-
},
124-
{
125-
message: "Invalid image format, supports only png, jpeg, jpg, gif, webp.",
126-
},
127-
)
128-
.transform((v) => v || null);
129-
130-
// Base64 encoded raster image or SVG
131-
export const base64ImageAllowSVGSchema = z
132-
.string()
133-
.trim()
134-
.regex(/^data:image\/(png|jpeg|jpg|gif|webp|svg\+xml);base64,/, {
135-
message:
136-
"Invalid image format, supports only png, jpeg, jpg, gif, webp, svg.",
137-
})
138-
.transform((v) => v || null);
139-
140-
export const storedR2ImageUrlSchema = z
141-
.url()
142-
.trim()
143-
.refine((url) => url.startsWith(R2_URL), {
144-
message: `URL must start with ${R2_URL}`,
145-
});
146-
147-
// Google user content URL schema - supports URLs like https://lh3.googleusercontent.com/...
148-
// This is needed when users sign up via Google OAuth and want to use their Google profile image
149-
// as their workspace logo or avatar
150-
export const googleUserContentUrlSchema = z
151-
.url()
152-
.trim()
153-
.refine((url) => url.startsWith("https://lh3.googleusercontent.com/"), {
154-
message: "Image URL must be a valid Google user content URL",
155-
});
156-
157-
// Google favicon URL schema - supports URLs starting with GOOGLE_FAVICON_URL
158-
export const googleFaviconUrlSchema = z
159-
.url()
160-
.trim()
161-
.refine((url) => url.startsWith(GOOGLE_FAVICON_URL), {
162-
message: `Image URL must start with ${GOOGLE_FAVICON_URL}`,
163-
});
164-
165-
// Uploaded image could be any of the following:
166-
// - Base64 encoded image
167-
// - R2_URL
168-
// - Special case for GOOGLE_FAVICON_URL
169-
// - Google user content URLs (e.g., https://lh3.googleusercontent.com/...)
170-
// This schema contains an async refinement check for base64 image validation,
171-
// which requires using parseAsync() instead of parse() when validating
172-
export const uploadedImageSchema = z
173-
.union([base64ImageSchema, storedR2ImageUrlSchema, googleFaviconUrlSchema])
174-
.transform((v) => v || null);
175-
176-
// Base64 encoded image/SVG or R2_URL
177-
// This schema contains an async refinement check for base64 image validation,
178-
// which requires using parseAsync() instead of parse() when validating
179-
export const uploadedImageAllowSVGSchema = z
180-
.union([base64ImageAllowSVGSchema, storedR2ImageUrlSchema])
181-
.transform((v) => v || null);
182-
183-
export const publicHostedImageSchema = z
184-
.url()
185-
.trim()
186-
.refine((url) => url.startsWith("http://") || url.startsWith("https://"), {
187-
message: "Image URL must start with http:// or https://",
188-
});

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

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,15 +15,14 @@ import { COUNTRY_CODES } from "@dub/utils";
1515
import * as z from "zod/v4";
1616
import { analyticsQuerySchema } from "./analytics";
1717
import { analyticsResponse } from "./analytics-response";
18-
import { createLinkBodySchema } from "./links";
1918
import {
2019
base64ImageSchema,
21-
booleanQuerySchema,
22-
getPaginationQuerySchema,
2320
googleFaviconUrlSchema,
2421
publicHostedImageSchema,
2522
storedR2ImageUrlSchema,
26-
} from "./misc";
23+
} from "./images";
24+
import { createLinkBodySchema } from "./links";
25+
import { booleanQuerySchema, getPaginationQuerySchema } from "./misc";
2726
import { ProgramEnrollmentSchema } from "./programs";
2827
import { centsSchema, centsSchemaWithDefault, parseUrlSchema } from "./utils";
2928

apps/web/lib/zod/schemas/program-application-form.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import * as z from "zod/v4";
2-
import { storedR2ImageUrlSchema } from "./misc";
2+
import { storedR2ImageUrlSchema } from "./images";
33

44
// Common schema for all fields
55
export const programApplicationFormFieldCommonSchema = z.object({

0 commit comments

Comments
 (0)