From bfc89d50baffdd6d48770c40075128561d2915ce Mon Sep 17 00:00:00 2001 From: wendypetersondev Date: Tue, 29 Sep 2026 14:52:50 -0700 Subject: [PATCH] Work on #487: [High] Add an anti-phishing origin guard and trusted-domai Closes #487 --- next.config.mjs | 3 + src/app/layout.tsx | 2 + src/components/OriginTrustBanner.test.tsx | 79 +++++++++++++++++ src/components/OriginTrustBanner.tsx | 61 +++++++++++++ .../TrustedDomainIndicator.test.tsx | 74 ++++++++++++++++ src/components/TrustedDomainIndicator.tsx | 62 +++++++++++++ src/hooks/useOriginTrust.ts | 41 +++++++++ src/hooks/useSolverRegistration.test.ts | 12 +++ src/hooks/useSolverRegistration.ts | 5 ++ src/hooks/useSwapSubmission.test.ts | 35 ++++++++ src/hooks/useSwapSubmission.ts | 7 +- src/lib/config.test.ts | 76 ++++++++++++++++ src/lib/config.ts | 86 +++++++++++++++++++ src/lib/envValidation.ts | 31 +++++++ src/lib/i18n/messages/en.ts | 20 +++++ src/lib/i18n/messages/es.ts | 20 +++++ src/lib/wallet/assertWalletReady.ts | 40 +++++++++ src/lib/wallet/index.ts | 2 + 18 files changed, 655 insertions(+), 1 deletion(-) create mode 100644 src/components/OriginTrustBanner.test.tsx create mode 100644 src/components/OriginTrustBanner.tsx create mode 100644 src/components/TrustedDomainIndicator.test.tsx create mode 100644 src/components/TrustedDomainIndicator.tsx create mode 100644 src/hooks/useOriginTrust.ts create mode 100644 src/lib/config.test.ts create mode 100644 src/lib/config.ts create mode 100644 src/lib/wallet/assertWalletReady.ts diff --git a/next.config.mjs b/next.config.mjs index 2ccf798..f317266 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -59,6 +59,8 @@ const WS_ORIGIN = (() => { * 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 @@ -130,6 +132,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 [ diff --git a/src/app/layout.tsx b/src/app/layout.tsx index e1887dc..f72ac15 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -3,6 +3,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 } from "@/lib/i18n"; @@ -111,6 +112,7 @@ export default function RootLayout({ + diff --git a/src/components/OriginTrustBanner.test.tsx b/src/components/OriginTrustBanner.test.tsx new file mode 100644 index 0000000..b8bfd67 --- /dev/null +++ b/src/components/OriginTrustBanner.test.tsx @@ -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) => { + 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(); + expect(container.firstChild).toBeNull(); + }); + + it("renders nothing when the origin is unknown", () => { + mockUseOriginTrust.trustLevel = "unknown"; + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + it("shows a banner when the origin is untrusted", () => { + const { getByTestId } = render(); + expect(getByTestId("origin-trust-banner")).toBeInTheDocument(); + }); + + it("includes a link to the canonical site", () => { + render(); + 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(); + 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(); + expect(screen.queryByRole("button")).not.toBeInTheDocument(); + }); +}); diff --git a/src/components/OriginTrustBanner.tsx b/src/components/OriginTrustBanner.tsx new file mode 100644 index 0000000..732c3dd --- /dev/null +++ b/src/components/OriginTrustBanner.tsx @@ -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 ( +
+ {/* Warning icon */} + + + + {t("originTrust.banner.description")} + + + + {t("originTrust.banner.canonicalUrl", { url: canonicalBase })} + + + {isFramed && ( + + {t("originTrust.banner.framed")} + + )} +
+ ); +} diff --git a/src/components/TrustedDomainIndicator.test.tsx b/src/components/TrustedDomainIndicator.test.tsx new file mode 100644 index 0000000..d87c4a1 --- /dev/null +++ b/src/components/TrustedDomainIndicator.test.tsx @@ -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) => { + 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(); + 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(); + expect(screen.getByText("trusted")).toHaveClass("text-vx-sage"); + }); + + it("shows untrusted state in red", () => { + mockUseOriginTrust.trustLevel = "untrusted"; + render(); + expect(screen.getByText("untrusted")).toHaveClass("text-rose-400"); + }); + + it("shows unknown state in muted color", () => { + mockUseOriginTrust.trustLevel = "unknown"; + render(); + expect(screen.getByText("unknown")).toHaveClass("text-vx-muted"); + }); + + it("shows a framing warning when the page is framed", () => { + mockUseOriginTrust.isFramed = true; + render(); + 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(); + expect(screen.getByText(/this signing request was initiated from an untrusted origin/i)).toBeInTheDocument(); + }); +}); diff --git a/src/components/TrustedDomainIndicator.tsx b/src/components/TrustedDomainIndicator.tsx new file mode 100644 index 0000000..6f3810c --- /dev/null +++ b/src/components/TrustedDomainIndicator.tsx @@ -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 ( +
+
+ {t("originTrust.review.hostname", { hostname })} + + {trustLevel} + +
+ +
+ {t("originTrust.review.network", { network })} +
+ + {isFramed && ( +
+ {t("originTrust.framing.breakout")} +
+ )} + + {trustLevel === "untrusted" && ( +
+ {t("originTrust.review.untrustedWarning")} +
+ )} +
+ ); +} diff --git a/src/hooks/useOriginTrust.ts b/src/hooks/useOriginTrust.ts new file mode 100644 index 0000000..c7d617b --- /dev/null +++ b/src/hooks/useOriginTrust.ts @@ -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({ + 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; +} diff --git a/src/hooks/useSolverRegistration.test.ts b/src/hooks/useSolverRegistration.test.ts index 3855de7..4c45499 100644 --- a/src/hooks/useSolverRegistration.test.ts +++ b/src/hooks/useSolverRegistration.test.ts @@ -10,6 +10,7 @@ const { apiErrorMock, decodeXdrMock, validateRegistrationXdrMock, + assertWalletReadyMock, } = vi.hoisted(() => ({ signTransactionMock: vi.fn(), registerSolverMock: vi.fn(), @@ -27,6 +28,7 @@ const { }, decodeXdrMock: vi.fn(), validateRegistrationXdrMock: vi.fn(), + assertWalletReadyMock: vi.fn(), })); vi.mock("@stellar/freighter-api", () => ({ @@ -62,6 +64,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"; diff --git a/src/hooks/useSolverRegistration.ts b/src/hooks/useSolverRegistration.ts index a32345d..e02888d 100644 --- a/src/hooks/useSolverRegistration.ts +++ b/src/hooks/useSolverRegistration.ts @@ -1,6 +1,7 @@ import { useCallback, useRef, useState } from "react"; import { mutate } from "swr"; import { walletAdapter } from "@/lib/wallet"; +import { assertWalletReady } from "@/lib/wallet/assertWalletReady"; import { registerSolver, submitSolverRegistration } from "@/lib/api"; import { ApiError } from "@/lib/api"; import { verifySignedXdrMatches } from "@/lib/xdrReview"; @@ -86,6 +87,10 @@ export function useSolverRegistration() { // ────────────────────────────────────────────────────────────────────── setStatus("awaiting-signature"); + + // Origin-trust guard: refuse to sign on untrusted origins. + assertWalletReady(); + const signedXdr = await walletAdapter.signTransaction(unsignedXdr, { network: wallet.network ?? undefined, }); diff --git a/src/hooks/useSwapSubmission.test.ts b/src/hooks/useSwapSubmission.test.ts index e64aa18..873297d 100644 --- a/src/hooks/useSwapSubmission.test.ts +++ b/src/hooks/useSwapSubmission.test.ts @@ -8,6 +8,7 @@ const { addToastMock, decodeXdrMock, validateSwapXdrMock, + assertWalletReadyMock, } = vi.hoisted(() => ({ signTransactionMock: vi.fn(), createIntentMock: vi.fn(), @@ -15,6 +16,7 @@ const { addToastMock: vi.fn(), decodeXdrMock: vi.fn(), validateSwapXdrMock: vi.fn(), + assertWalletReadyMock: vi.fn(), })); vi.mock("@stellar/freighter-api", () => ({ @@ -38,6 +40,16 @@ vi.mock("@/store/toast", () => ({ useToastStore: { getState: () => ({ addToast: addToastMock }) }, })); +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"; + } + }, +})); + // Mock xdrReview so unit tests don't need real Stellar SDK XDR fixtures. vi.mock("@/lib/xdrReview", () => { class XdrMismatchError extends Error { @@ -274,6 +286,29 @@ describe("useSwapSubmission", () => { expect(result.current.intentId).toBeNull(); }); + it("blocks signing and surfaces an error when the origin is untrusted", async () => { + const { UntrustedOriginError } = await import("@/lib/wallet/assertWalletReady"); + useWalletStore.setState({ isConnected: true, address: "GXYZ999", network: "TESTNET" }); + createIntentMock.mockResolvedValue({ intentId: "intent-ut", unsignedXdr: "unsigned-xdr" }); + assertWalletReadyMock.mockRejectedValue( + new UntrustedOriginError("evil-vortex.app"), + ); + + const { result } = renderHook(() => useSwapSubmission()); + await act(async () => { + await result.current.submit(params); + }); + + expect(result.current.status).toBe("error"); + expect(result.current.error).toMatch(/untrusted origin/i); + expect(signTransactionMock).not.toHaveBeenCalled(); + expect(submitIntentMock).not.toHaveBeenCalled(); + expect(addToastMock).toHaveBeenCalledWith( + expect.stringMatching(/untrusted origin/i), + "error", + ); + }); + // Issue #308: XDR structural integrity verification it("rejects a swap when the signed XDR fails verification", async () => { useWalletStore.setState({ isConnected: true, address: "GXYZ999", network: "TESTNET" }); diff --git a/src/hooks/useSwapSubmission.ts b/src/hooks/useSwapSubmission.ts index d0e465f..8562529 100644 --- a/src/hooks/useSwapSubmission.ts +++ b/src/hooks/useSwapSubmission.ts @@ -1,5 +1,6 @@ -import { useCallback, useState } from "react"; +import { useCallback, useRef, useState } from "react"; import { walletAdapter } from "@/lib/wallet"; +import { assertWalletReady } from "@/lib/wallet/assertWalletReady"; import { createIntent, submitIntent } from "@/lib/api"; import { verifySignedXdrMatches } from "@/lib/xdrReview"; import { useWalletStore } from "@/store/wallet"; @@ -137,6 +138,10 @@ export function useSwapSubmission() { // ────────────────────────────────────────────────────────────────────── setStatus("awaiting-signature"); + + // Origin-trust guard: refuse to sign on untrusted origins. + assertWalletReady(); + const signedXdr = await walletAdapter.signTransaction(unsignedXdr, { network: wallet.network ?? undefined, }); diff --git a/src/lib/config.test.ts b/src/lib/config.test.ts new file mode 100644 index 0000000..eecb389 --- /dev/null +++ b/src/lib/config.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from "vitest"; +import { evaluateOrigin } from "./config"; + +describe("evaluateOrigin", () => { + const trusted = ["localhost", "127.0.0.1", "vortex.app", "*.vercel.app"]; + + it("returns 'unknown' when the trusted list is empty", () => { + expect(evaluateOrigin("localhost", "https", [])).toBe("unknown"); + }); + + it("returns 'trusted' for an exact hostname match", () => { + expect(evaluateOrigin("localhost", "https", trusted)).toBe("trusted"); + expect(evaluateOrigin("vortex.app", "https", trusted)).toBe("trusted"); + }); + + it("is case-insensitive for exact matches", () => { + expect(evaluateOrigin("VORTEX.APP", "https", trusted)).toBe("trusted"); + expect(evaluateOrigin("LocalHost", "https", trusted)).toBe("trusted"); + }); + + it("strips port before comparing", () => { + expect(evaluateOrigin("localhost:3000", "https", trusted)).toBe("trusted"); + expect(evaluateOrigin("vortex.app:443", "https", trusted)).toBe("trusted"); + }); + + it("returns 'trusted' for a wildcard suffix match", () => { + expect(evaluateOrigin("my-project.vercel.app", "https", trusted)).toBe("trusted"); + expect(evaluateOrigin("pr-42.my-project.vercel.app", "https", trusted)).toBe("trusted"); + }); + + it("does NOT match a wildcard against a non-matching suffix", () => { + expect(evaluateOrigin("evil.vercel.app", "https", ["*.example.com"])).toBe("untrusted"); + }); + + it("does NOT match a lookalike domain (evil-vortex.app vs vortex.app)", () => { + expect(evaluateOrigin("evil-vortex.app", "https", trusted)).toBe("untrusted"); + }); + + it("does NOT match a suffix attack (vortex.app.evil.com vs vortex.app)", () => { + expect(evaluateOrigin("vortex.app.evil.com", "https", trusted)).toBe("untrusted"); + }); + + it("does NOT match a deeper subdomain against a wildcard with a single label", () => { + // "*.vercel.app" should match "foo.vercel.app" but not "foo.bar.vercel.app" + // because the wildcard only covers one label + expect(evaluateOrigin("foo.vercel.app", "https", trusted)).toBe("trusted"); + expect(evaluateOrigin("foo.bar.vercel.app", "https", trusted)).toBe("untrusted"); + }); + + it("returns 'untrusted' for a hostname not in the allowlist", () => { + expect(evaluateOrigin("evil.com", "https", trusted)).toBe("untrusted"); + expect(evaluateOrigin("vortex-swap.example", "https", trusted)).toBe("untrusted"); + }); + + it("returns 'untrusted' for an IP address not in the allowlist", () => { + expect(evaluateOrigin("192.168.1.1", "https", trusted)).toBe("untrusted"); + }); + + it("returns 'trusted' for 127.0.0.1 when it is in the allowlist", () => { + expect(evaluateOrigin("127.0.0.1", "https", trusted)).toBe("trusted"); + }); + + it("handles punycode hostnames correctly (IDN)", () => { + // Punycode for "münchen.de" is "xn--mnchen-3ya.de" + const idnTrusted = ["xn--mnchen-3ya.de"]; + expect(evaluateOrigin("xn--mnchen-3ya.de", "https", idnTrusted)).toBe("trusted"); + }); + + it("treats a single wildcard '*' as matching everything", () => { + expect(evaluateOrigin("anything.com", "https", ["*"])).toBe("trusted"); + }); + + it("ignores empty entries in the trusted list", () => { + expect(evaluateOrigin("localhost", "https", ["", "localhost", " "])).toBe("trusted"); + }); +}); diff --git a/src/lib/config.ts b/src/lib/config.ts new file mode 100644 index 0000000..629ad44 --- /dev/null +++ b/src/lib/config.ts @@ -0,0 +1,86 @@ +/** + * Trusted-origin configuration and evaluation utilities. + * + * `evaluateOrigin` is a pure function that determines whether a given + * hostname is trusted, untrusted, or unknown based on an allowlist. + * It is used by `useOriginTrust` and by signing-flow guards. + */ + +export type TrustLevel = "trusted" | "untrusted" | "unknown"; + +/** + * Evaluate whether a hostname is trusted based on an allowlist. + * + * - Exact match → "trusted" + * - Wildcard suffix match (`*.example.com`) → "trusted" for direct subdomains + * - No trusted origins configured → "unknown" + * - Otherwise → "untrusted" + * + * The comparison is case-insensitive and strips any port from the hostname. + * Punycode (IDN) hostnames are compared in their Unicode form, matching + * how `window.location.hostname` presents them. + */ +export function evaluateOrigin( + hostname: string, + _protocol: string, + trustedOrigins: string[], +): TrustLevel { + if (trustedOrigins.length === 0) return "unknown"; + + const normalizedHostname = hostname.toLowerCase().replace(/:\d+$/, ""); + + for (const trusted of trustedOrigins) { + const pattern = trusted.toLowerCase().trim(); + if (!pattern) continue; + + // Exact match + if (normalizedHostname === pattern) return "trusted"; + + // Wildcard suffix match: "*.example.com" matches "sub.example.com" + // but NOT "sub.sub.example.com" (deep subdomain) and NOT "evil-example.com" + if (pattern.startsWith("*.")) { + const suffix = pattern.slice(1); // ".example.com" + if ( + normalizedHostname.endsWith(suffix) && + normalizedHostname.length > suffix.length && + normalizedHostname[normalizedHostname.length - suffix.length - 1] !== "." + ) { + return "trusted"; + } + } + } + + return "untrusted"; +} + +/** + * Parse the NEXT_PUBLIC_TRUSTED_ORIGINS env var into a list of patterns. + * Falls back to sensible defaults for development and preview environments. + * + * The env var should be a comma-separated list of exact hostnames or + * wildcard patterns (e.g. "localhost,127.0.0.1,*.vercel.app"). + */ +export function getTrustedOrigins(): string[] { + const envOrigins = process.env.NEXT_PUBLIC_TRUSTED_ORIGINS; + + if (envOrigins) { + const parsed = envOrigins + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + + if (parsed.length === 0) return []; + return parsed; + } + + // No env var configured — use environment-appropriate defaults. + const vercelEnv = process.env.NEXT_PUBLIC_VERCEL_ENV; + if (vercelEnv === "production") { + // In production, no defaults are safe — the deployer must set + // NEXT_PUBLIC_TRUSTED_ORIGINS explicitly. + return []; + } + + // Development and preview: trust localhost, loopback, and Vercel preview domains. + return ["localhost", "127.0.0.1", "*.vercel.app"]; +} diff --git a/src/lib/envValidation.ts b/src/lib/envValidation.ts index 269bf6d..71c1f57 100644 --- a/src/lib/envValidation.ts +++ b/src/lib/envValidation.ts @@ -78,6 +78,37 @@ export function enforcePublicEnvValidation(env: NodeJS.ProcessEnv): void { } } +/** + * Validate NEXT_PUBLIC_TRUSTED_ORIGINS format. + * Returns an array of errors for invalid entries. + */ +export function validateTrustedOrigins(env: NodeJS.ProcessEnv): EnvValidationError[] { + const value = env.NEXT_PUBLIC_TRUSTED_ORIGINS; + if (!value) return []; + + const errors: EnvValidationError[] = []; + const entries = value.split(",").map(s => s.trim()).filter(Boolean); + + for (const entry of entries) { + if (entry.includes(" ")) { + errors.push({ + variable: "NEXT_PUBLIC_TRUSTED_ORIGINS", + pattern: /\s/, + message: `Trusted origin entry "${entry}" contains whitespace. Use comma-separated values without spaces.`, + }); + } + if (entry.includes("*") && !entry.startsWith("*.")) { + errors.push({ + variable: "NEXT_PUBLIC_TRUSTED_ORIGINS", + pattern: /\*/g, + message: `Wildcard "${entry}" must start with "*.", e.g. "*.vercel.app".`, + }); + } + } + + return errors; +} + /** * Get a list of all NEXT_PUBLIC_* variables for audit purposes. */ diff --git a/src/lib/i18n/messages/en.ts b/src/lib/i18n/messages/en.ts index bf3af6c..7f16357 100644 --- a/src/lib/i18n/messages/en.ts +++ b/src/lib/i18n/messages/en.ts @@ -193,4 +193,24 @@ export const en = { // solve/[address] — fill history empty "solverDetail.fillHistory.empty.title": "No fills yet", "solverDetail.fillHistory.empty.message": "Once this solver starts accepting and filling intents, their history will appear here.", + + // ── Origin trust / anti-phishing ────────────────────────────────── + + "originTrust.banner.title": "Untrusted origin", + "originTrust.banner.description": + "This site is not on the trusted origins list. Signing is disabled to protect your funds.", + "originTrust.banner.canonicalUrl": "Visit the canonical site: {url}", + "originTrust.banner.framed": + "This page is being loaded inside a frame. Verify the URL in your browser's address bar.", + + "originTrust.review.hostname": "Hostname: {hostname}", + "originTrust.review.network": "Network: {network}", + "originTrust.review.untrustedWarning": + "This signing request was initiated from an untrusted origin. Cancel and verify the URL before proceeding.", + + "originTrust.error.untrusted": + "Signing is not allowed on untrusted origins. Please visit the canonical site to continue.", + + "originTrust.framing.breakout": + "This page is being loaded inside a frame. This may be an attack — verify the URL in your browser's address bar.", } as const; diff --git a/src/lib/i18n/messages/es.ts b/src/lib/i18n/messages/es.ts index 14deb59..bec8dac 100644 --- a/src/lib/i18n/messages/es.ts +++ b/src/lib/i18n/messages/es.ts @@ -151,4 +151,24 @@ export const es = { // solve/[address] — historial de llenados vacío "solverDetail.fillHistory.empty.title": "Sin llenados aún", "solverDetail.fillHistory.empty.message": "Una vez que este solver empiece a aceptar y llenar intenciones, su historial aparecerá aquí.", + + // ── Origin trust / anti-phishing ────────────────────────── + + "originTrust.banner.title": "Origen no confiable", + "originTrust.banner.description": + "Este sitio no está en la lista de orígenes confiables. La firma está desactivada para proteger sus fondos.", + "originTrust.banner.canonicalUrl": "Visite el sitio canónico: {url}", + "originTrust.banner.framed": + "Esta página se está cargando dentro de un marco. Verifique la URL en la barra de direcciones del navegador.", + + "originTrust.review.hostname": "Nombre de host: {hostname}", + "originTrust.review.network": "Red: {network}", + "originTrust.review.untrustedWarning": + "Esta solicitud de firma se originó desde un origen no confiable. Cancelar y verificar la URL antes de continuar.", + + "originTrust.error.untrusted": + "No se permite firmar en orígenes no confiables. Visite el sitio canónico para continuar.", + + "originTrust.framing.breakout": + "Esta página se está cargando dentro de un marco. Esto podría ser un ataque: verifique la URL en la barra de direcciones del navegador.", } as const; diff --git a/src/lib/wallet/assertWalletReady.ts b/src/lib/wallet/assertWalletReady.ts new file mode 100644 index 0000000..3ff6bcd --- /dev/null +++ b/src/lib/wallet/assertWalletReady.ts @@ -0,0 +1,40 @@ +import { evaluateOrigin, getTrustedOrigins } from "@/lib/config"; +import { useWalletStore } from "@/store/wallet"; + +export class UntrustedOriginError extends Error { + constructor(hostname: string) { + super( + `Signing is not allowed on untrusted origin "${hostname}". Please visit the canonical site to continue.`, + ); + this.name = "UntrustedOriginError"; + } +} + +/** + * Precondition check before any wallet signing operation. + * + * Verifies that: + * 1. The wallet is connected and has an address. + * 2. The current origin is on the trusted list. + * + * Throws `UntrustedOriginError` when the origin is not trusted, + * and a generic `Error` when the wallet is not connected. + */ +export function assertWalletReady(): { address: string; network: string } { + const state = useWalletStore.getState(); + + if (!state.isConnected || !state.address) { + throw new Error(state.error ?? "Connect a wallet to continue."); + } + + const hostname = window.location.hostname; + const protocol = window.location.protocol.replace(":", ""); + const trusted = getTrustedOrigins(); + const trustLevel = evaluateOrigin(hostname, protocol, trusted); + + if (trustLevel === "untrusted") { + throw new UntrustedOriginError(hostname); + } + + return { address: state.address, network: state.network ?? "" }; +} diff --git a/src/lib/wallet/index.ts b/src/lib/wallet/index.ts index 2085d50..826e269 100644 --- a/src/lib/wallet/index.ts +++ b/src/lib/wallet/index.ts @@ -6,3 +6,5 @@ export { freighterAdapter } from "./freighterAdapter"; // Hardcoded to Freighter for now. Swap this to select a different adapter // once wallet-choice UI exists. export const walletAdapter: WalletAdapter = freighterAdapter; + +export { assertWalletReady, UntrustedOriginError } from "./assertWalletReady";