diff --git a/src/hooks/useBondManagement.ts b/src/hooks/useBondManagement.ts index 3f35219..c46b482 100644 --- a/src/hooks/useBondManagement.ts +++ b/src/hooks/useBondManagement.ts @@ -1,6 +1,7 @@ import { useCallback, useMemo, useRef, useState } from "react"; import useSWR from "swr"; import { walletAdapter } from "@/lib/wallet"; +import { checkOriginTrusted } from "@/lib/wallet/assertWalletReady"; import { decodeXdr, XdrMismatchError } from "@/lib/xdrReview"; import { getBondAdapter } from "@/lib/bond/adapter"; import { applyBondOperation } from "@/lib/bond/state"; @@ -49,6 +50,7 @@ export function useBondManagement(address: string | null, adapter: BondAdapter = throw new XdrMismatchError("Bond transaction contains no operations — refusing to sign."); } setStatus("awaiting-signature"); + checkOriginTrusted(); signedXdr = await walletAdapter.signTransaction(unsignedXdr, { network: wallet.network ?? undefined }); } diff --git a/src/hooks/useSolverRegistration.test.ts b/src/hooks/useSolverRegistration.test.ts index 72451cc..840c169 100644 --- a/src/hooks/useSolverRegistration.test.ts +++ b/src/hooks/useSolverRegistration.test.ts @@ -64,6 +64,7 @@ vi.mock("@/lib/xdrReview", () => { }); vi.mock("@/lib/wallet/assertWalletReady", () => ({ + checkOriginTrusted: vi.fn(), assertWalletReady: assertWalletReadyMock, UntrustedOriginError: class UntrustedOriginError extends Error { constructor(hostname: string) { diff --git a/src/hooks/useSolverRegistration.ts b/src/hooks/useSolverRegistration.ts index e02888d..bc49f73 100644 --- a/src/hooks/useSolverRegistration.ts +++ b/src/hooks/useSolverRegistration.ts @@ -1,7 +1,7 @@ import { useCallback, useRef, useState } from "react"; import { mutate } from "swr"; import { walletAdapter } from "@/lib/wallet"; -import { assertWalletReady } from "@/lib/wallet/assertWalletReady"; +import { assertWalletReady, checkOriginTrusted } from "@/lib/wallet/assertWalletReady"; import { registerSolver, submitSolverRegistration } from "@/lib/api"; import { ApiError } from "@/lib/api"; import { verifySignedXdrMatches } from "@/lib/xdrReview"; @@ -91,6 +91,7 @@ export function useSolverRegistration() { // Origin-trust guard: refuse to sign on untrusted origins. assertWalletReady(); + checkOriginTrusted(); const signedXdr = await walletAdapter.signTransaction(unsignedXdr, { network: wallet.network ?? undefined, }); diff --git a/src/lib/export/analyticsExport.ts b/src/lib/export/analyticsExport.ts index 37a09a1..25a4b3c 100644 --- a/src/lib/export/analyticsExport.ts +++ b/src/lib/export/analyticsExport.ts @@ -5,6 +5,8 @@ */ import { escapeCsv } from "@/lib/csv"; +import { looksLikeSensitiveBlob } from "@/lib/secureLogging"; +import { isValidStrKey } from "@/lib/stellarAddress"; import type { AnalyticsSummary, AnalyticsVolumePoint, AnalyticsRouteEntry } from "@/lib/analytics"; // ─── CSV export ──────────────────────────────────────────────────────────────── @@ -87,6 +89,12 @@ export function buildAnalyticsCsvBundle( // ─── Shareable URL state ────────────────────────────────────────────────────── +/** + * Audit (#648): the CSV bundle holds only aggregates (KPI totals, daily volume, + * source-chain/destination-token route totals) — no addresses, XDR or + * signatures. Share state is filter/view state only. Every value is bounded and + * must not look like a Stellar address or XDR blob, enforced on decode. + */ export type AnalyticsShareState = { /** Date range start ISO string */ from?: string; @@ -99,6 +107,17 @@ export type AnalyticsShareState = { }; const SHARE_PARAM = "av"; // analytics-view — short to keep URLs tidy +const MAX_SHARE_PARAM_LENGTH = 1024; +const MAX_SHARE_VALUE_LENGTH = 64; + +/** True when a share-state value is bounded and carries no address/XDR-shaped data. */ +function isSafeShareValue(v: string): boolean { + return ( + v.length <= MAX_SHARE_VALUE_LENGTH && + !isValidStrKey(v) && + !looksLikeSensitiveBlob(v) + ); +} /** * Encode analytics filter state into a URL search-param string. @@ -106,10 +125,10 @@ const SHARE_PARAM = "av"; // analytics-view — short to keep URLs tidy */ export function encodeShareState(state: AnalyticsShareState): string { const compact: Record = {}; - if (state.from) compact.f = state.from; - if (state.to) compact.t = state.to; - if (state.chain) compact.c = state.chain; - if (state.token) compact.tk = state.token; + if (state.from && isSafeShareValue(state.from)) compact.f = state.from; + if (state.to && isSafeShareValue(state.to)) compact.t = state.to; + if (state.chain && isSafeShareValue(state.chain)) compact.c = state.chain; + if (state.token && isSafeShareValue(state.token)) compact.tk = state.token; return btoa(JSON.stringify(compact)); } @@ -118,19 +137,20 @@ export function encodeShareState(state: AnalyticsShareState): string { * Returns null if the param is missing, malformed, or contains unexpected keys. */ export function decodeShareState(raw: string | null): AnalyticsShareState | null { - if (!raw) return null; + if (!raw || raw.length > MAX_SHARE_PARAM_LENGTH) return null; try { const decoded = JSON.parse(atob(raw)) as Record; + if (!decoded || typeof decoded !== "object" || Array.isArray(decoded)) return null; const ALLOWED_KEYS = new Set(["f", "t", "c", "tk"]); // Validate — reject if any unexpected key is present for (const key of Object.keys(decoded)) { if (!ALLOWED_KEYS.has(key)) return null; } const state: AnalyticsShareState = {}; - if (typeof decoded.f === "string") state.from = decoded.f; - if (typeof decoded.t === "string") state.to = decoded.t; - if (typeof decoded.c === "string") state.chain = decoded.c; - if (typeof decoded.tk === "string") state.token = decoded.tk; + if (typeof decoded.f === "string" && isSafeShareValue(decoded.f)) state.from = decoded.f; + if (typeof decoded.t === "string" && isSafeShareValue(decoded.t)) state.to = decoded.t; + if (typeof decoded.c === "string" && isSafeShareValue(decoded.c)) state.chain = decoded.c; + if (typeof decoded.tk === "string" && isSafeShareValue(decoded.tk)) state.token = decoded.tk; return state; } catch { return null; diff --git a/src/lib/intentExport.ts b/src/lib/intentExport.ts index 2a40d4e..e57b40a 100644 --- a/src/lib/intentExport.ts +++ b/src/lib/intentExport.ts @@ -1,3 +1,4 @@ +import { looksLikeSensitiveBlob } from "@/lib/secureLogging"; import { sanitizeDisplayText } from "@/lib/textSafety"; import type { IntentDetail } from "@/lib/types"; @@ -8,7 +9,9 @@ const REDACTED_KEY_RE = /xdr|signature|secret/i; /** Deep copy with XDR/signature-like keys removed and strings made display-safe. */ export function redactForExport(value: unknown): unknown { - if (typeof value === "string") return sanitizeDisplayText(value); + if (typeof value === "string") { + return looksLikeSensitiveBlob(value) ? "[redacted]" : sanitizeDisplayText(value); + } if (Array.isArray(value)) return value.map(redactForExport); if (typeof value === "object" && value !== null) { return Object.fromEntries( diff --git a/src/lib/knownAddresses.ts b/src/lib/knownAddresses.ts index 0c4f457..e6557aa 100644 --- a/src/lib/knownAddresses.ts +++ b/src/lib/knownAddresses.ts @@ -5,6 +5,7 @@ * the storage facade; purged after the retention in `STORAGE_KEYS`. */ +import { isValidStellarPublicKey } from "./stellarAddress"; import { STORAGE_KEYS, storage } from "./storage"; export type KnownAddressSource = "wallet" | "destination" | "solver"; @@ -24,7 +25,8 @@ export function getKnownAddressRecords(): KnownAddress[] { if (!Array.isArray(parsed)) return []; return parsed.filter( (e): e is KnownAddress => - !!e && typeof e === "object" && typeof (e as KnownAddress).address === "string", + !!e && typeof e === "object" && typeof (e as KnownAddress).address === "string" && + isValidStellarPublicKey((e as KnownAddress).address), ); } @@ -33,7 +35,7 @@ export function getKnownAddresses(): string[] { } export function recordKnownAddress(address: string, source: KnownAddressSource): void { - if (!address) return; + if (!address || !isValidStellarPublicKey(address)) return; const rest = getKnownAddressRecords().filter((e) => e.address !== address); const next = [{ address, source, lastSeen: Date.now() }, ...rest].slice(0, MAX_KNOWN_ADDRESSES); storage.setJSON(KEY, next); diff --git a/src/lib/secureLogging.ts b/src/lib/secureLogging.ts index e69de29..ffe2cca 100644 --- a/src/lib/secureLogging.ts +++ b/src/lib/secureLogging.ts @@ -0,0 +1,11 @@ +/** Heuristics for strings that look like XDR envelopes or signatures. */ + +// Base64 run long enough to be a transaction envelope or signature, not prose. +const BASE64_BLOB_RE = /^[A-Za-z0-9+/_-]{80,}={0,2}$/; +// Hex run long enough to be a signature (64 bytes = 128 hex chars). +const HEX_BLOB_RE = /^(?:0x)?[0-9a-fA-F]{128,}$/; + +export function looksLikeSensitiveBlob(value: string): boolean { + const v = value.trim(); + return BASE64_BLOB_RE.test(v) || HEX_BLOB_RE.test(v); +} diff --git a/src/lib/securityHardening.test.ts b/src/lib/securityHardening.test.ts new file mode 100644 index 0000000..1445b55 --- /dev/null +++ b/src/lib/securityHardening.test.ts @@ -0,0 +1,67 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { Keypair } from "@stellar/stellar-sdk"; + +const hostname = vi.hoisted(() => ({ value: "app.test" })); +vi.mock("@/lib/config", () => ({ + getTrustedOrigins: () => [], + evaluateOrigin: (h: string) => (h === "app.test" ? "trusted" : "untrusted"), +})); + +vi.mock("@/lib/textSafety", () => ({ sanitizeDisplayText: (v: string) => v })); +vi.mock("@/store/wallet", () => ({ useWalletStore: { getState: () => ({}) } })); +const store = vi.hoisted(() => ({ data: undefined as unknown })); +vi.mock("@/lib/storage", () => ({ + STORAGE_KEYS: { knownAddresses: { key: "known" } }, + storage: { getJSON: () => store.data, setJSON: (_k: string, v: unknown) => void (store.data = v) }, +})); + +import { checkOriginTrusted, UntrustedOriginError } from "@/lib/wallet/assertWalletReady"; +import { getKnownAddressRecords, recordKnownAddress } from "@/lib/knownAddresses"; +import { redactForExport } from "@/lib/intentExport"; +import { decodeShareState, encodeShareState } from "@/lib/export/analyticsExport"; + +const ADDR = Keypair.random().publicKey(); +const BLOB = "A".repeat(120); + +beforeEach(() => { + hostname.value = "app.test"; + store.data = undefined; + vi.stubGlobal("window", { location: { get hostname() { return hostname.value; }, protocol: "https:" } }); +}); + +describe("#645 origin re-check", () => { + it("catches a mid-flow origin change", () => { + expect(() => checkOriginTrusted()).not.toThrow(); + hostname.value = "evil.test"; + expect(() => checkOriginTrusted()).toThrow(UntrustedOriginError); + }); +}); + +describe("#646 known addresses", () => { + it("rejects invalid addresses on write and drops them on read", () => { + recordKnownAddress("not-an-address", "wallet"); + expect(getKnownAddressRecords()).toEqual([]); + recordKnownAddress(ADDR, "wallet"); + expect(getKnownAddressRecords().map((e) => e.address)).toEqual([ADDR]); + }); +}); + +describe("#647 value-shape redaction", () => { + it("redacts blob values under unrelated keys", () => { + expect(redactForExport({ envelope: BLOB, memo: "hello" })).toEqual({ envelope: "[redacted]", memo: "hello" }); + }); +}); + +describe("#648 analytics share state", () => { + it("never encodes address/XDR-shaped values", () => { + const out = atob(encodeShareState({ chain: ADDR, token: BLOB, from: "2026-01-01" })); + expect(out).not.toContain(ADDR); + expect(out).not.toContain(BLOB); + expect(out).toContain("2026-01-01"); + }); + it("rejects oversized or unsafe input on decode", () => { + expect(decodeShareState("A".repeat(2000))).toBeNull(); + expect(decodeShareState(btoa(JSON.stringify({ c: ADDR })))).toEqual({}); + expect(decodeShareState(btoa("[]"))).toBeNull(); + }); +}); diff --git a/src/lib/stellarAddress.ts b/src/lib/stellarAddress.ts index 7a67487..b32b265 100644 --- a/src/lib/stellarAddress.ts +++ b/src/lib/stellarAddress.ts @@ -182,5 +182,3 @@ export function isValidStrKey(value: string): boolean { decoded[decoded.length - 1] === expectedChecksum >>> 8 ); } - -} diff --git a/src/lib/wallet/assertWalletReady.ts b/src/lib/wallet/assertWalletReady.ts index 3ff6bcd..36db92b 100644 --- a/src/lib/wallet/assertWalletReady.ts +++ b/src/lib/wallet/assertWalletReady.ts @@ -10,6 +10,19 @@ export class UntrustedOriginError extends Error { } } +/** + * Throws `UntrustedOriginError` unless the current origin is trusted. Cheap and + * synchronous: call it again immediately before `signTransaction` to close the + * gap between the precondition check and the actual signing call. + */ +export function checkOriginTrusted(): void { + const hostname = window.location.hostname; + const protocol = window.location.protocol.replace(":", ""); + if (evaluateOrigin(hostname, protocol, getTrustedOrigins()) === "untrusted") { + throw new UntrustedOriginError(hostname); + } +} + /** * Precondition check before any wallet signing operation. * @@ -27,14 +40,7 @@ export function assertWalletReady(): { address: string; network: string } { 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); - } + checkOriginTrusted(); return { address: state.address, network: state.network ?? "" }; }