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
3 changes: 3 additions & 0 deletions next.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ const WS_ORIGIN = env.wsUrl ? new URL(env.wsUrl).origin : null;
* 3. `frame-ancestors 'none'` (and X-Frame-Options: DENY)
* Prevents this app from being embedded in a hostile iframe — critical for
* a wallet-integrated dApp (clickjacking against Freighter sign flows).
* The JS check `window.top !== window.self` in `useOriginTrust` provides
* a secondary defence that shows a break-out notice to the user.
*
* 4. Freighter wallet communication happens via window.postMessage between
* the page and the browser extension — this is NOT a network request and
Expand Down Expand Up @@ -152,6 +154,7 @@ const nextConfig = {
NEXT_PUBLIC_NETWORK: process.env.NEXT_PUBLIC_NETWORK ?? "testnet",
NEXT_PUBLIC_SETTLEMENT_CONTRACT: process.env.NEXT_PUBLIC_SETTLEMENT_CONTRACT ?? "",
NEXT_PUBLIC_SOLVER_REGISTRY_CONTRACT: process.env.NEXT_PUBLIC_SOLVER_REGISTRY_CONTRACT ?? "",
NEXT_PUBLIC_TRUSTED_ORIGINS: process.env.NEXT_PUBLIC_TRUSTED_ORIGINS ?? "",
},
async headers() {
return [
Expand Down
2 changes: 2 additions & 0 deletions src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import "./globals.css";
import { WalletHydrator } from "@/components/WalletHydrator";
import { ToastViewport } from "@/components/ToastViewport";
import { IntentStatusWatcher } from "@/components/IntentStatusWatcher";
import { OriginTrustBanner } from "@/components/OriginTrustBanner";
import { I18nProvider } from "@/lib/i18n/I18nProvider";
import { DEFAULT_LOCALE, LOCALE_HEADER, isLocale, type Locale } from "@/lib/i18n";

Expand Down Expand Up @@ -168,6 +169,7 @@ export default async function RootLayout({
<CommandPalette />
<ToastViewport />
<ConnectivityBanner />
<OriginTrustBanner />
</I18nProvider>
</body>
</html>
Expand Down
79 changes: 79 additions & 0 deletions src/components/OriginTrustBanner.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { act, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { OriginTrustBanner } from "./OriginTrustBanner";

const mockEvaluateOrigin = vi.hoisted(() => vi.fn());
const mockGetTrustedOrigins = vi.hoisted(() => vi.fn());

vi.mock("@/lib/config", () => ({
evaluateOrigin: (...args: unknown[]) => mockEvaluateOrigin(...args),
getTrustedOrigins: () => mockGetTrustedOrigins(),
}));

const mockUseOriginTrust = vi.hoisted(() => ({
trustLevel: "untrusted" as const,
hostname: "evil-vortex.app",
isFramed: false,
}));

vi.mock("@/hooks/useOriginTrust", () => ({
useOriginTrust: () => mockUseOriginTrust,
}));

vi.mock("@/lib/i18n/I18nProvider", () => ({
useTranslation: () => ({
t: (key: string, values?: Record<string, string>) => {
if (key === "originTrust.banner.canonicalUrl") {
return `Visit the canonical site: ${values?.url ?? ""}`;
}
return key;
},
}),
}));

describe("OriginTrustBanner", () => {
beforeEach(() => {
mockEvaluateOrigin.mockReturnValue("untrusted");
mockGetTrustedOrigins.mockReturnValue(["localhost"]);
});

afterEach(() => {
vi.clearAllMocks();
});

it("renders nothing when the origin is trusted", () => {
mockUseOriginTrust.trustLevel = "trusted";
const { container } = render(<OriginTrustBanner />);
expect(container.firstChild).toBeNull();
});

it("renders nothing when the origin is unknown", () => {
mockUseOriginTrust.trustLevel = "unknown";
const { container } = render(<OriginTrustBanner />);
expect(container.firstChild).toBeNull();
});

it("shows a banner when the origin is untrusted", () => {
const { getByTestId } = render(<OriginTrustBanner />);
expect(getByTestId("origin-trust-banner")).toBeInTheDocument();
});

it("includes a link to the canonical site", () => {
render(<OriginTrustBanner />);
const link = screen.getByRole("link", { name: /visit the canonical site/i });
expect(link).toBeInTheDocument();
expect(link).toHaveAttribute("href", "https://vortexprotocol.org");
});

it("has the correct ARIA attributes for an alert", () => {
const { getByTestId } = render(<OriginTrustBanner />);
const banner = getByTestId("origin-trust-banner");
expect(banner).toHaveAttribute("role", "alert");
expect(banner).toHaveAttribute("aria-live", "assertive");
});

it("is non-dismissible (no close button)", () => {
render(<OriginTrustBanner />);
expect(screen.queryByRole("button")).not.toBeInTheDocument();
});
});
61 changes: 61 additions & 0 deletions src/components/OriginTrustBanner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
"use client";

import { useTranslation } from "@/lib/i18n/I18nProvider";
import { useOriginTrust } from "@/hooks/useOriginTrust";

/**
* Persistent, non-dismissible banner shown when the app is served
* from an untrusted origin (look-alike domain, IPFS gateway mirror,
* iframe/proxy). Displays the canonical URL so the user can
* navigate to the legitimate site and explains the risk.
*/
export function OriginTrustBanner() {
const { t } = useTranslation();
const { trustLevel, hostname, isFramed } = useOriginTrust();

if (trustLevel !== "untrusted") return null;

const canonicalBase =
process.env.NEXT_PUBLIC_SITE_URL?.replace(/\/$/, "") ??
"https://vortexprotocol.org";

return (
<div
role="alert"
aria-live="assertive"
data-testid="origin-trust-banner"
className="fixed top-0 left-0 right-0 z-[200] flex items-center justify-center gap-3 px-4 py-3 text-sm font-medium bg-rose-500/95 text-rose-950"
>
{/* Warning icon */}
<svg
aria-hidden="true"
className="w-5 h-5 flex-shrink-0"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fillRule="evenodd"
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z"
clipRule="evenodd"
/>
</svg>

<span className="flex-1 text-center">
{t("originTrust.banner.description")}
</span>

<a
href={canonicalBase}
className="underline font-semibold hover:no-underline"
>
{t("originTrust.banner.canonicalUrl", { url: canonicalBase })}
</a>

{isFramed && (
<span className="sr-only">
{t("originTrust.banner.framed")}
</span>
)}
</div>
);
}
74 changes: 74 additions & 0 deletions src/components/TrustedDomainIndicator.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { act, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { TrustedDomainIndicator } from "./TrustedDomainIndicator";

const mockUseOriginTrust = vi.hoisted(() => ({
trustLevel: "trusted" as const,
hostname: "localhost",
isFramed: false,
}));

vi.mock("@/hooks/useOriginTrust", () => ({
useOriginTrust: () => mockUseOriginTrust,
}));

vi.mock("@/lib/i18n/I18nProvider", () => ({
useTranslation: () => ({
t: (key: string, values?: Record<string, string>) => {
if (values) {
return key.replace(/\{(\w+)\}/g, (_, k) => String(values[k] ?? ""));
}
return key;
},
}),
}));

describe("TrustedDomainIndicator", () => {
beforeEach(() => {
vi.useFakeTimers();
mockUseOriginTrust.trustLevel = "trusted";
mockUseOriginTrust.hostname = "localhost";
mockUseOriginTrust.isFramed = false;
});

afterEach(() => {
vi.useRealTimers();
vi.clearAllMocks();
});

it("renders the hostname and network", () => {
render(<TrustedDomainIndicator />);
expect(screen.getByTestId("trusted-domain-indicator")).toBeInTheDocument();
expect(screen.getByText(/hostname:/i)).toBeInTheDocument();
expect(screen.getByText(/network:/i)).toBeInTheDocument();
});

it("shows trusted state in green", () => {
render(<TrustedDomainIndicator />);
expect(screen.getByText("trusted")).toHaveClass("text-vx-sage");
});

it("shows untrusted state in red", () => {
mockUseOriginTrust.trustLevel = "untrusted";
render(<TrustedDomainIndicator />);
expect(screen.getByText("untrusted")).toHaveClass("text-rose-400");
});

it("shows unknown state in muted color", () => {
mockUseOriginTrust.trustLevel = "unknown";
render(<TrustedDomainIndicator />);
expect(screen.getByText("unknown")).toHaveClass("text-vx-muted");
});

it("shows a framing warning when the page is framed", () => {
mockUseOriginTrust.isFramed = true;
render(<TrustedDomainIndicator />);
expect(screen.getByText(/this page is being loaded inside a frame/i)).toBeInTheDocument();
});

it("shows an untrusted warning when the origin is untrusted", () => {
mockUseOriginTrust.trustLevel = "untrusted";
render(<TrustedDomainIndicator />);
expect(screen.getByText(/this signing request was initiated from an untrusted origin/i)).toBeInTheDocument();
});
});
62 changes: 62 additions & 0 deletions src/components/TrustedDomainIndicator.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
"use client";

import { useTranslation } from "@/lib/i18n/I18nProvider";
import { useOriginTrust } from "@/hooks/useOriginTrust";

/**
* Displays the current hostname (punycode-aware) and network
* beside each other so users can compare with their bookmarks.
* Shows a warning when the origin is untrusted or the page is
* framed.
*/
export function TrustedDomainIndicator() {
const { t } = useTranslation();
const { trustLevel, hostname, isFramed } = useOriginTrust();

const network =
process.env.NEXT_PUBLIC_NETWORK?.toUpperCase() ?? "TESTNET";

return (
<div
data-testid="trusted-domain-indicator"
className="flex flex-col gap-1 rounded border border-vx-border bg-vx-surface px-3 py-2 text-xs"
>
<div className="flex items-center gap-2">
<span className="text-vx-muted">{t("originTrust.review.hostname", { hostname })}</span>
<span
className={
trustLevel === "trusted"
? "text-vx-sage"
: trustLevel === "untrusted"
? "text-rose-400"
: "text-vx-muted"
}
>
{trustLevel}
</span>
</div>

<div className="flex items-center gap-2 text-vx-muted">
{t("originTrust.review.network", { network })}
</div>

{isFramed && (
<div
role="alert"
className="mt-1 rounded bg-rose-500/20 px-2 py-1 text-rose-300"
>
{t("originTrust.framing.breakout")}
</div>
)}

{trustLevel === "untrusted" && (
<div
role="alert"
className="mt-1 rounded bg-rose-500/20 px-2 py-1 text-rose-300"
>
{t("originTrust.review.untrustedWarning")}
</div>
)}
</div>
);
}
41 changes: 41 additions & 0 deletions src/hooks/useOriginTrust.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
"use client";

import { useEffect, useState } from "react";
import { usePathname } from "next/navigation";
import { evaluateOrigin, getTrustedOrigins } from "@/lib/config";
import type { TrustLevel } from "@/lib/config";

export interface OriginTrustResult {
trustLevel: TrustLevel;
hostname: string;
isFramed: boolean;
}

/**
* Evaluates the current origin's trust level on mount and after
* every navigation (via `usePathname` as a navigation signal).
*
* Returns `trusted | untrusted | unknown` and the current hostname
* so that UI can display it for user comparison with bookmarks.
*/
export function useOriginTrust(): OriginTrustResult {
const pathname = usePathname();
const [result, setResult] = useState<OriginTrustResult>({
trustLevel: "unknown",
hostname: "",
isFramed: false,
});

useEffect(() => {
const hostname = window.location.hostname;
const protocol = window.location.protocol.replace(":", "");
const trusted = getTrustedOrigins();
const trustLevel = evaluateOrigin(hostname, protocol, trusted);
const isFramed =
typeof window.top !== "undefined" && window.top !== window.self;

setResult({ trustLevel, hostname, isFramed });
}, [pathname]);

return result;
}
12 changes: 12 additions & 0 deletions src/hooks/useSolverRegistration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ const {
apiErrorMock,
decodeXdrMock,
validateRegistrationXdrMock,
assertWalletReadyMock,
verifySignedXdrMatchesMock,
} = vi.hoisted(() => ({
signTransactionMock: vi.fn(),
Expand All @@ -28,6 +29,7 @@ const {
},
decodeXdrMock: vi.fn(),
validateRegistrationXdrMock: vi.fn(),
assertWalletReadyMock: vi.fn(),
}));

vi.mock("@stellar/freighter-api", () => ({
Expand Down Expand Up @@ -61,6 +63,16 @@ vi.mock("@/lib/xdrReview", () => {
};
});

vi.mock("@/lib/wallet/assertWalletReady", () => ({
assertWalletReady: assertWalletReadyMock,
UntrustedOriginError: class UntrustedOriginError extends Error {
constructor(hostname: string) {
super(`Signing is not allowed on untrusted origin "${hostname}".`);
this.name = "UntrustedOriginError";
}
},
}));

import { useWalletStore } from "@/store/wallet";
import { useSolverRegistration } from "./useSolverRegistration";

Expand Down
Loading
Loading