Skip to content
Merged
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
18 changes: 18 additions & 0 deletions apps/dashboard/next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,24 @@ const baseNextConfig: NextConfig = {
],
source: "/bridge/swap-widget/:path*",
},
{
headers: [
{
key: "Content-Security-Policy",
value: EmbedContentSecurityPolicy.replace(/\s{2,}/g, " ").trim(),
},
],
source: "/bridge/buy-widget",
},
{
headers: [
{
key: "Content-Security-Policy",
value: EmbedContentSecurityPolicy.replace(/\s{2,}/g, " ").trim(),
},
],
source: "/bridge/buy-widget/:path*",
},
];
},
images: {
Expand Down
3 changes: 3 additions & 0 deletions apps/dashboard/src/@/constants/public-envs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,6 @@ export const NEXT_PUBLIC_CHECKOUT_IFRAME_CLIENT_ID =

export const NEXT_PUBLIC_SWAP_IFRAME_CLIENT_ID =
process.env.NEXT_PUBLIC_SWAP_IFRAME_CLIENT_ID;

export const NEXT_PUBLIC_BUY_IFRAME_CLIENT_ID =
process.env.NEXT_PUBLIC_BUY_IFRAME_CLIENT_ID;
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
"use client";

import { useMemo } from "react";
import { type Address, defineChain } from "thirdweb";
import { BuyWidget, type SupportedFiatCurrency } from "thirdweb/react";
import { NEXT_PUBLIC_BUY_IFRAME_CLIENT_ID } from "@/constants/public-envs";
import { getConfiguredThirdwebClient } from "@/constants/thirdweb.server";
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Avoid importing .server modules in client components.

Importing getConfiguredThirdwebClient from @/constants/thirdweb.server in a client component violates the server/client boundary. The .server suffix indicates this module is intended for server-side use only and may contain server-specific logic or configurations.

Consider either:

  1. Creating a client-specific version of the Thirdweb client initialization (e.g., getConfiguredThirdwebClient.client.ts)
  2. Moving the client initialization logic to a shared utility that's explicitly safe for both environments
  3. Initializing the client directly in this component using createThirdwebClient from the thirdweb SDK
Alternative: Direct client initialization
-import { getConfiguredThirdwebClient } from "@/constants/thirdweb.server";
+import { createThirdwebClient } from "thirdweb";

 export function BuyWidgetEmbed({
   // ...props
 }) {
   const client = useMemo(
     () =>
-      getConfiguredThirdwebClient({
+      createThirdwebClient({
         clientId: NEXT_PUBLIC_BUY_IFRAME_CLIENT_ID,
-        secretKey: undefined,
-        teamId: undefined,
       }),
     [],
   );

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In @apps/dashboard/src/app/bridge/buy-widget/BuyWidgetEmbed.client.tsx at line
7, The client component BuyWidgetEmbed.client.tsx imports
getConfiguredThirdwebClient from a .server module which breaks the server/client
boundary; replace that import by either creating a client-safe initializer
(e.g., getConfiguredThirdwebClient.client.ts that exports a browser-safe
function) and import that, move shared safe logic into a non-.server utility
used by both, or initialize the Thirdweb client directly inside
BuyWidgetEmbed.client.tsx using createThirdwebClient from the thirdweb SDK;
update references to getConfiguredThirdwebClient in this file to use the new
client-safe function name or direct initialization and remove any server-only
imports.


export function BuyWidgetEmbed({
chainId,
tokenAddress,
amount,
showThirdwebBranding,
theme,
currency,
title,
description,
image,
paymentMethods,
buttonLabel,
receiverAddress,
country,
}: {
chainId?: number;
tokenAddress?: Address;
amount?: string;
showThirdwebBranding?: boolean;
theme: "light" | "dark";
currency?: SupportedFiatCurrency;
title?: string;
description?: string;
image?: string;
paymentMethods?: ("crypto" | "card")[];
buttonLabel?: string;
receiverAddress?: Address;
country?: string;
}) {
const client = useMemo(
() =>
getConfiguredThirdwebClient({
clientId: NEXT_PUBLIC_BUY_IFRAME_CLIENT_ID,
secretKey: undefined,
teamId: undefined,
}),
[],
);

const chain = useMemo(() => {
if (!chainId) return undefined;
// eslint-disable-next-line no-restricted-syntax
return defineChain(chainId);
}, [chainId]);

return (
<BuyWidget
className="shadow-xl"
client={client}
chain={chain}
tokenAddress={tokenAddress}
amount={amount}
showThirdwebBranding={showThirdwebBranding}
theme={theme}
currency={currency}
title={title}
description={description}
image={image}
paymentMethods={paymentMethods}
buttonLabel={buttonLabel}
receiverAddress={receiverAddress}
country={country}
onSuccess={() => {
sendMessageToParent({
source: "buy-widget",
type: "success",
});
}}
onError={(error) => {
sendMessageToParent({
source: "buy-widget",
type: "error",
message: error.message,
});
}}
/>
);
}

function sendMessageToParent(content: object) {
try {
window.parent.postMessage(content, "*");
} catch (error) {
console.error("Failed to send post message to parent window");
console.error(error);
}
}
Comment on lines +88 to +95
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Security: Restrict postMessage target origin.

Using a wildcard origin "*" in window.parent.postMessage() is a security risk. It allows any parent window from any origin to receive the success/error messages, which could leak sensitive information about user transactions to malicious iframes or parent windows.

🔒 Recommended fix: Validate and restrict target origin
-function sendMessageToParent(content: object) {
+function sendMessageToParent(content: object) {
+  // Only send to expected parent origins
+  const allowedOrigins = [
+    'https://thirdweb.com',
+    'https://dashboard.thirdweb.com',
+    // Add other allowed origins
+  ];
+  
+  const parentOrigin = document.referrer ? new URL(document.referrer).origin : '*';
+  
   try {
-    window.parent.postMessage(content, "*");
+    if (allowedOrigins.includes(parentOrigin) || parentOrigin === window.location.origin) {
+      window.parent.postMessage(content, parentOrigin);
+    } else {
+      console.warn('Parent origin not in allowlist:', parentOrigin);
+    }
   } catch (error) {
     console.error("Failed to send post message to parent window");
     console.error(error);
   }
 }

Alternatively, if the expected parent origin is known at build time or can be passed as a query parameter, use that instead of document.referrer.

🤖 Prompt for AI Agents
In @apps/dashboard/src/app/bridge/buy-widget/BuyWidgetEmbed.client.tsx around
lines 88 - 95, The sendMessageToParent function currently posts messages with
"*" which is insecure; change it to validate and use a restricted target origin:
determine the allowed parent origin (prefer a configured constant or prop, else
parse and validate document.referrer against an allowlist) and pass that origin
to window.parent.postMessage; if the origin is missing or not allowed, do not
send and log a warning. Ensure you update the sendMessageToParent function name
reference and its error handling to include the origin used and any validation
failures in the console or logger.

27 changes: 27 additions & 0 deletions apps/dashboard/src/app/bridge/buy-widget/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { Inter } from "next/font/google";
import { cn } from "@/lib/utils";

const fontSans = Inter({
display: "swap",
subsets: ["latin"],
variable: "--font-sans",
});

export default function BuyWidgetLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en" suppressHydrationWarning>
<body
className={cn(
"min-h-dvh bg-background font-sans antialiased flex flex-col",
fontSans.variable,
)}
>
{children}
</body>
</html>
);
}
Comment on lines +10 to +27
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Nested layouts must not declare <html> and <body> tags.

In Next.js App Router, only the root app/layout.tsx should contain <html> and <body> elements. Nested route layouts should only wrap {children} directly. This structure will cause hydration errors and conflicts with the parent layout.

Additionally, per coding guidelines, layouts should reuse SidebarLayout or FullWidthSidebarLayout from @/components/blocks/SidebarLayout.

🔧 Proposed fix: Remove html/body wrapper
-export default function BuyWidgetLayout({
+export default async function BuyWidgetLayout({
   children,
 }: {
   children: React.ReactNode;
 }) {
-  return (
-    <html lang="en" suppressHydrationWarning>
-      <body
-        className={cn(
-          "min-h-dvh bg-background font-sans antialiased flex flex-col",
-          fontSans.variable,
-        )}
-      >
-        {children}
-      </body>
-    </html>
-  );
+  return <>{children}</>;
 }

If custom styling is needed, wrap {children} in a <div> with appropriate classes instead of redefining the document structure.

Based on coding guidelines: "Reuse SidebarLayout or FullWidthSidebarLayout from @/components/blocks/SidebarLayout for all layouts" and "Export default async functions without 'use client'; in server components"

📝 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
export default function BuyWidgetLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en" suppressHydrationWarning>
<body
className={cn(
"min-h-dvh bg-background font-sans antialiased flex flex-col",
fontSans.variable,
)}
>
{children}
</body>
</html>
);
}
export default async function BuyWidgetLayout({
children,
}: {
children: React.ReactNode;
}) {
return <>{children}</>;
}

97 changes: 97 additions & 0 deletions apps/dashboard/src/app/bridge/buy-widget/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import type { Metadata } from "next";
import "@workspace/ui/global.css";
import type { SupportedFiatCurrency } from "thirdweb/react";
import { isValidCurrency } from "../_common/isValidCurrency";
import {
onlyAddress,
onlyNumber,
parseQueryParams,
} from "../_common/parseQueryParams";
import { BridgeProvidersLite } from "../(general)/components/client/Providers.client";
import { BuyWidgetEmbed } from "./BuyWidgetEmbed.client";

const title = "thirdweb Buy: Purchase Crypto with Ease";
const description =
"Buy crypto tokens with credit card or other payment methods. Simple and secure purchases with thirdweb.";

export const metadata: Metadata = {
description,
openGraph: {
description,
title,
},
title,
};

type SearchParams = {
[key: string]: string | string[] | undefined;
};

export default async function Page(props: {
searchParams: Promise<SearchParams>;
}) {
const searchParams = await props.searchParams;

// Token params
const chainId = parseQueryParams(searchParams.chain, onlyNumber);
const tokenAddress = parseQueryParams(searchParams.tokenAddress, onlyAddress);
const amount = parseQueryParams(searchParams.amount, (v) => v);

// Optional params
const showThirdwebBranding = parseQueryParams(
searchParams.showThirdwebBranding,
// biome-ignore lint/complexity/noUselessTernary: this is easier to understand
(v) => (v === "false" ? false : true),
);

const theme =
parseQueryParams(searchParams.theme, (v) =>
v === "light" ? "light" : "dark",
) || "dark";

const currency = parseQueryParams(searchParams.currency, (v) =>
isValidCurrency(v) ? (v as SupportedFiatCurrency) : undefined,
);

// Metadata params
const widgetTitle = parseQueryParams(searchParams.title, (v) => v);
const widgetDescription = parseQueryParams(
searchParams.description,
(v) => v,
);
const image = parseQueryParams(searchParams.image, (v) => v);

// Payment params
const paymentMethods = parseQueryParams(searchParams.paymentMethods, (v) => {
if (v === "crypto" || v === "card") {
return [v] as ("crypto" | "card")[];
}
return undefined;
});

const buttonLabel = parseQueryParams(searchParams.buttonLabel, (v) => v);
const receiverAddress = parseQueryParams(searchParams.receiver, onlyAddress);
const country = parseQueryParams(searchParams.country, (v) => v);

return (
<BridgeProvidersLite forcedTheme={theme}>
<div className="flex min-h-screen items-center justify-center bg-background px-4 py-8">
<BuyWidgetEmbed
chainId={chainId}
tokenAddress={tokenAddress}
amount={amount}
showThirdwebBranding={showThirdwebBranding}
theme={theme}
currency={currency}
title={widgetTitle}
description={widgetDescription}
image={image}
paymentMethods={paymentMethods}
buttonLabel={buttonLabel}
receiverAddress={receiverAddress}
country={country}
/>
</div>
</BridgeProvidersLite>
);
}
Loading