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
62 changes: 44 additions & 18 deletions apps/web/app/api/qr/route.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { handleAndReturnErrorResponse } from "@/lib/api/errors";
import { ratelimitOrThrow } from "@/lib/api/utils";
import { getShortLinkViaEdge, getWorkspaceViaEdge } from "@/lib/planetscale";
import { getDomainViaEdge } from "@/lib/planetscale/get-domain-via-edge";
import { getQRAsSVG } from "@/lib/qr/api";
import { DEFAULT_BGCOLOR } from "@/lib/qr/constants";
import { QRCodeSVG } from "@/lib/qr/utils";
import { getQRCodeQuerySchema } from "@/lib/zod/schemas/qr";
import { DUB_QR_LOGO, getSearchParams, isDubDomain } from "@dub/utils";
Expand All @@ -21,29 +23,53 @@ export async function GET(req: NextRequest) {

await ratelimitOrThrow(req, "qr");

const { logo, url, size, level, fgColor, bgColor, margin, hideLogo } =
paramsParsed;
const {
logo,
url,
size,
level,
fgColor,
bgColor,
margin,
hideLogo,
format,
} = paramsParsed;

const qrCodeLogo = await getQRCodeLogo({ url, logo, hideLogo });

const qrProps = {
value: url,
size,
level,
fgColor,
margin,
...(qrCodeLogo
? {
imageSettings: {
src: qrCodeLogo,
height: size / 4,
width: size / 4,
excavate: true,
},
}
: {}),
};

if (format === "svg") {
const svgString = await getQRAsSVG({
...qrProps,
...(bgColor ? { bgColor } : {}),
});
const headers = new Headers(CORS_HEADERS);
headers.set("Content-Type", "image/svg+xml");
headers.set("Content-Disposition", "inline; filename=qr.svg");
return new Response(svgString, { headers });
}

return new ImageResponse(
QRCodeSVG({
value: url,
size,
level,
fgColor,
bgColor,
margin,
...(qrCodeLogo
? {
imageSettings: {
src: qrCodeLogo,
height: size / 4,
width: size / 4,
excavate: true,
},
}
: {}),
...qrProps,
bgColor: bgColor ?? DEFAULT_BGCOLOR,
isOGContext: true,
}),
{
Expand Down
3 changes: 3 additions & 0 deletions apps/web/lib/openapi/qr/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ export const getQRCode: ZodOpenApiOperationObject = {
"image/png": {
schema: z.string(),
},
"image/svg+xml": {
schema: z.string(),
},
},
},
...openApiErrorResponses,
Expand Down
89 changes: 45 additions & 44 deletions apps/web/lib/qr/api.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import qrcodegen from "./codegen";
import {
DEFAULT_BGCOLOR,
DEFAULT_FGCOLOR,
DEFAULT_LEVEL,
DEFAULT_MARGIN,
Expand All @@ -10,21 +9,25 @@ import {
import { QRPropsSVG } from "./types";
import { excavateModules, generatePath, getImageSettings } from "./utils";

export async function getQRAsSVG(props: QRPropsSVG) {
export async function getQRAsSVG(props: QRPropsSVG): Promise<string> {
const {
value,
size = DEFAULT_SIZE,
level = DEFAULT_LEVEL,
bgColor = DEFAULT_BGCOLOR,
bgColor,
fgColor = DEFAULT_FGCOLOR,
margin = DEFAULT_MARGIN,
imageSettings,
...otherProps
} = props;

const shouldUseHigherErrorLevel =
imageSettings?.excavate && (level === "L" || level === "M");

const effectiveLevel = shouldUseHigherErrorLevel ? "Q" : level;

let cells = qrcodegen.QrCode.encodeText(
value,
ERROR_LEVEL_MAP[level],
ERROR_LEVEL_MAP[effectiveLevel],
).getModules();

const numCells = cells.length + margin * 2;
Expand All @@ -35,51 +38,49 @@ export async function getQRAsSVG(props: QRPropsSVG) {
imageSettings,
);

let image = <></>;
let image = "";
if (imageSettings != null && calculatedImageSettings != null) {
if (calculatedImageSettings.excavation != null) {
cells = excavateModules(cells, calculatedImageSettings.excavation);
}
try {
const base64Image = await fetch(
`https://wsrv.nl/?url=${encodeURIComponent(imageSettings.src)}&w=100&h=100&encoding=base64`,
{ signal: AbortSignal.timeout(5_000) },
).then((res) => {
if (!res.ok) {
throw new Error(`Failed to fetch logo: ${res.status}`);
}
return res.text();
});

const base64Image = await fetch(
`https://wsrv.nl/?url=${imageSettings.src}&w=100&h=100&encoding=base64`,
).then((res) => res.text());
if (calculatedImageSettings.excavation != null) {
cells = excavateModules(cells, calculatedImageSettings.excavation);
}

image = (
<image
href={base64Image}
height={calculatedImageSettings.h}
width={calculatedImageSettings.w}
x={calculatedImageSettings.x + margin}
y={calculatedImageSettings.y + margin}
preserveAspectRatio="none"
/>
);
image = [
`<image href="${base64Image}"`,
`height="${calculatedImageSettings.h}"`,
`width="${calculatedImageSettings.w}"`,
`x="${calculatedImageSettings.x + margin}"`,
`y="${calculatedImageSettings.y + margin}"`,
'preserveAspectRatio="none"></image>',
].join(" ");
} catch {
// Omit logo if embedding fails; still return a valid QR SVG.
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// Drawing strategy: instead of a rect per module, we're going to create a
// single path for the dark modules and layer that on top of a light rect,
// for a total of 2 DOM nodes. We pay a bit more in string concat but that's
// way faster than DOM ops.
// For level 1, 441 nodes -> 2
// For level 40, 31329 -> 2
// single path for the dark modules. Background is only painted when bgColor
// is set — otherwise the SVG stays transparent.
const fgPath = generatePath(cells, margin);
const bgPath = bgColor
? `<path fill="${bgColor}" d="M0,0 h${numCells}v${numCells}H0z" shape-rendering="crispEdges"></path>`
: "";

return (
<svg
height={size}
width={size}
viewBox={`0 0 ${numCells} ${numCells}`}
xmlns="http://www.w3.org/2000/svg"
{...otherProps}
>
<path
fill={bgColor}
d={`M0,0 h${numCells}v${numCells}H0z`}
shapeRendering="crispEdges"
/>
<path fill={fgColor} d={fgPath} shapeRendering="crispEdges" />
{image}
</svg>
);
return [
`<svg xmlns="http://www.w3.org/2000/svg" height="${size}" width="${size}" viewBox="0 0 ${numCells} ${numCells}">`,
bgPath,
`<path fill="${fgColor}" d="${fgPath}" shape-rendering="crispEdges"></path>`,
image,
"</svg>",
].join("");
Comment on lines 71 to +85

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🔴 Critical | ⚡ Quick win

Unescaped bgColor/fgColor interpolated into SVG attributes — XSS via crafted query params.

bgColor/fgColor are inserted directly into fill="${...}" attributes of a hand-built SVG string, then returned with Content-Type: image/svg+xml (see apps/web/app/api/qr/route.tsx lines 58-67) from a public, unauthenticated GET endpoint. A value containing "> breaks out of the attribute context and injects arbitrary SVG markup (e.g. <script>), which executes when the SVG is opened directly or embedded via <object>/<iframe>. This is a new attack surface: the PNG path is safe because it's JSX-rendered and rasterized, but this raw string-based SVG output returns attacker-controlled markup verbatim.

Fixing the input at the schema layer (see apps/web/lib/zod/schemas/qr.ts comment) is the primary fix; escaping here as well provides defense-in-depth.

🛡️ Proposed defense-in-depth fix
+const escapeSvgAttr = (value: string) =>
+  value
+    .replace(/&/g, "&amp;")
+    .replace(/"/g, "&quot;")
+    .replace(/</g, "&lt;")
+    .replace(/>/g, "&gt;");
+
   const fgPath = generatePath(cells, margin);
   const bgPath = bgColor
-    ? `<path fill="${bgColor}" d="M0,0 h${numCells}v${numCells}H0z" shape-rendering="crispEdges"></path>`
+    ? `<path fill="${escapeSvgAttr(bgColor)}" d="M0,0 h${numCells}v${numCells}H0z" shape-rendering="crispEdges"></path>`
     : "";

   return [
     `<svg xmlns="http://www.w3.org/2000/svg" height="${size}" width="${size}" viewBox="0 0 ${numCells} ${numCells}">`,
     bgPath,
-    `<path fill="${fgColor}" d="${fgPath}" shape-rendering="crispEdges"></path>`,
+    `<path fill="${escapeSvgAttr(fgColor)}" d="${fgPath}" shape-rendering="crispEdges"></path>`,
     image,
     "</svg>",
   ].join("");
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Drawing strategy: instead of a rect per module, we're going to create a
// single path for the dark modules and layer that on top of a light rect,
// for a total of 2 DOM nodes. We pay a bit more in string concat but that's
// way faster than DOM ops.
// For level 1, 441 nodes -> 2
// For level 40, 31329 -> 2
// single path for the dark modules. Background is only painted when bgColor
// is set — otherwise the SVG stays transparent.
const fgPath = generatePath(cells, margin);
const bgPath = bgColor
? `<path fill="${bgColor}" d="M0,0 h${numCells}v${numCells}H0z" shape-rendering="crispEdges"></path>`
: "";
return (
<svg
height={size}
width={size}
viewBox={`0 0 ${numCells} ${numCells}`}
xmlns="http://www.w3.org/2000/svg"
{...otherProps}
>
<path
fill={bgColor}
d={`M0,0 h${numCells}v${numCells}H0z`}
shapeRendering="crispEdges"
/>
<path fill={fgColor} d={fgPath} shapeRendering="crispEdges" />
{image}
</svg>
);
return [
`<svg xmlns="http://www.w3.org/2000/svg" height="${size}" width="${size}" viewBox="0 0 ${numCells} ${numCells}">`,
bgPath,
`<path fill="${fgColor}" d="${fgPath}" shape-rendering="crispEdges"></path>`,
image,
"</svg>",
].join("");
// Drawing strategy: instead of a rect per module, we're going to create a
// single path for the dark modules. Background is only painted when bgColor
// is set — otherwise the SVG stays transparent.
const escapeSvgAttr = (value: string) =>
value
.replace(/&/g, "&amp;")
.replace(/"/g, "&quot;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
const fgPath = generatePath(cells, margin);
const bgPath = bgColor
? `<path fill="${escapeSvgAttr(bgColor)}" d="M0,0 h${numCells}v${numCells}H0z" shape-rendering="crispEdges"></path>`
: "";
return [
`<svg xmlns="http://www.w3.org/2000/svg" height="${size}" width="${size}" viewBox="0 0 ${numCells} ${numCells}">`,
bgPath,
`<path fill="${escapeSvgAttr(fgColor)}" d="${fgPath}" shape-rendering="crispEdges"></path>`,
image,
"</svg>",
].join("");
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/lib/qr/api.tsx` around lines 71 - 85, Escape or otherwise safely
encode bgColor and fgColor before interpolating them into the fill attributes
generated by the SVG construction in the QR rendering function. Keep the
existing color behavior while ensuring crafted query parameters cannot break out
of SVG attribute context; retain schema-level validation as the primary
protection.

}
2 changes: 1 addition & 1 deletion apps/web/lib/qr/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -386,9 +386,9 @@ export async function getQRAsSVGDataUri(props: QRProps) {
)
.join("");

// Omit the background fill so downloaded SVGs have a transparent background.
const svgData = [
`<svg xmlns="http://www.w3.org/2000/svg" height="${size}" width="${size}" viewBox="0 0 ${numCells} ${numCells}">`,
`<path fill="${bgColor}" d="M0,0 h${numCells}v${numCells}H0z" shape-rendering="crispEdges"></path>`,
`<path fill="${fgColor}" d="${fgPath}" shape-rendering="crispEdges"></path>`,
finderSVG,
image,
Expand Down
15 changes: 7 additions & 8 deletions apps/web/lib/zod/schemas/qr.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,4 @@
import {
DEFAULT_BGCOLOR,
DEFAULT_FGCOLOR,
DEFAULT_MARGIN,
QR_LEVELS,
} from "@/lib/qr/constants";
import { DEFAULT_FGCOLOR, DEFAULT_MARGIN, QR_LEVELS } from "@/lib/qr/constants";
import * as z from "zod/v4";
import { booleanQuerySchema } from "./misc";
import { parseUrlSchema } from "./utils";
Expand Down Expand Up @@ -40,9 +35,8 @@ export const getQRCodeQuerySchema = z.object({
bgColor: z
.string()
.optional()
.default(DEFAULT_BGCOLOR)
.describe(
"The background color of the QR code in hex format. Defaults to `#ffffff` if not provided.",
"The background color of the QR code in hex format. Defaults to `#ffffff` for `png`. When omitted for `svg`, the background is transparent.",
),
Comment on lines 35 to 40

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🔴 Critical | ⚡ Quick win

No format validation on bgColor/fgColor enables SVG attribute-injection.

Both bgColor (here) and fgColor (lines 28-34) accept arbitrary strings. apps/web/lib/qr/api.tsx's new getQRAsSVG interpolates these values unescaped directly into SVG attribute values (fill="${bgColor}") and returns the result with Content-Type: image/svg+xml from a public, unauthenticated endpoint. A value like "><script>...</script><path fill=" breaks out of the attribute and injects arbitrary SVG/script markup. Unlike the PNG path (JSX-rendered → rasterized, immune to this), the SVG string path returns attacker-controlled markup verbatim — this is a well-known SVG XSS vector when served as image/svg+xml (script execution on direct navigation or <object>/<iframe> embedding).

Constrain both fields to the documented "hex format" via a regex so malformed/malicious values are rejected before reaching the renderer.

🛡️ Proposed fix
+const HEX_COLOR_REGEX = /^#?(?:[0-9A-Fa-f]{3,4}|[0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})$/;
+
   fgColor: z
     .string()
+    .regex(HEX_COLOR_REGEX, "fgColor must be a valid hex color (e.g. `#000000`).")
     .optional()
     .default(DEFAULT_FGCOLOR)
     .describe(
       "The foreground color of the QR code in hex format. Defaults to `#000000` if not provided.",
     ),
   bgColor: z
     .string()
+    .regex(HEX_COLOR_REGEX, "bgColor must be a valid hex color (e.g. `#ffffff`).")
     .optional()
     .describe(
       "The background color of the QR code in hex format. Defaults to `#ffffff` for `png`. When omitted for `svg`, the background is transparent.",
     ),
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/lib/zod/schemas/qr.ts` around lines 35 - 40, Constrain both optional
color fields, bgColor and fgColor, in the QR schema to documented hexadecimal
color values using a regex validator. Apply the same validation to each
z.string() definition before values reach getQRAsSVG, while preserving their
optional behavior and existing defaults/descriptions.

hideLogo: booleanQuerySchema
.optional()
Expand All @@ -64,4 +58,9 @@ export const getQRCodeQuerySchema = z.object({
"DEPRECATED: Margin is included by default. Use the `margin` prop to customize the margin size.",
)
.meta({ deprecated: true }),
format: z
.enum(["png", "svg"])
.optional()
.default("png")
.describe("The format of the QR code. Defaults to `png` if not provided."),
});
Loading