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
2 changes: 2 additions & 0 deletions src/hooks/useBondManagement.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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 });
}

Expand Down
1 change: 1 addition & 0 deletions src/hooks/useSolverRegistration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
3 changes: 2 additions & 1 deletion src/hooks/useSolverRegistration.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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,
});
Expand Down
38 changes: 29 additions & 9 deletions src/lib/export/analyticsExport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -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;
Expand All @@ -99,17 +107,28 @@ 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.
* Only encodes range/filter data — never wallet or personal data.
*/
export function encodeShareState(state: AnalyticsShareState): string {
const compact: Record<string, string> = {};
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));
}

Expand All @@ -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<string, unknown>;
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;
Expand Down
5 changes: 4 additions & 1 deletion src/lib/intentExport.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { looksLikeSensitiveBlob } from "@/lib/secureLogging";
import { sanitizeDisplayText } from "@/lib/textSafety";
import type { IntentDetail } from "@/lib/types";

Expand All @@ -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(
Expand Down
6 changes: 4 additions & 2 deletions src/lib/knownAddresses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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),
);
}

Expand All @@ -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);
Expand Down
11 changes: 11 additions & 0 deletions src/lib/secureLogging.ts
Original file line number Diff line number Diff line change
@@ -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);
}
67 changes: 67 additions & 0 deletions src/lib/securityHardening.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
2 changes: 0 additions & 2 deletions src/lib/stellarAddress.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,5 +182,3 @@ export function isValidStrKey(value: string): boolean {
decoded[decoded.length - 1] === expectedChecksum >>> 8
);
}

}
22 changes: 14 additions & 8 deletions src/lib/wallet/assertWalletReady.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand All @@ -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 ?? "" };
}
Loading