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/extension/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,21 @@ Build-time public env (WXT inlines `WXT_PUBLIC_*` into the client bundle):
- A **dev build** (`wxt dev`) with this unset falls back to `http://localhost:3000` and `http://localhost:5173`.
- `WXT_PUBLIC_ALLOW_ANY_PAIR_ORIGIN` — set to `1` to explicitly disable the pair-origin restriction (any origin may pair). Named escape hatch only; logs a warning on every startup. **Never set this in a production build.**
- `WXT_PUBLIC_MAINNET_RPC_URL` — trusted Soroban RPC used to anchor the device signer's signature-expiration ledger on **mainnet** (L4). The extension never trusts the paired wallet's `rpcUrl` for this. Testnet uses SDF's pinned public endpoint automatically; mainnet has no universal public RPC, so this must be set — **mainnet signing fails closed if it is unset** rather than trusting the caller-supplied endpoint.

## Input Sanitization (#312)

dApp-provided connection payloads (such as dApp names, origins, descriptions, and icon URLs) are sanitized before being processed or rendered in the popup UI:
- **HTML & Script Escaping**: HTML special characters (`<`, `>`, `&`, `"`, `'`, `/`) are escaped using `escapeHtml()`.
- **Tag Stripping**: All HTML tags (`<script>`, `<iframe>`, etc.) are stripped using `sanitizeString()`.
- **URL Protocol Filtering**: Dangerous URI schemes (`javascript:`, `data:`, `vbscript:`) are filtered out by `sanitizeUrl()`.
- **Control Character Scrubbing**: Control characters are stripped to prevent display spoofing.
- **Length Truncation**: Strings are bounded by maximum length ceilings (e.g. 100 chars for names, 500 chars for descriptions).

## Error Reporting Integration (#302)

Uncaught exceptions, background worker rejections, and signing failures are automatically captured and forwarded to the centralized error reporting client via `backgroundErrorReporter`:
- **Context Metadata**: Every reported error automatically embeds `extensionVersion` (from `WXT_PUBLIC_VERSION`) and `browserInfo` (`navigator.userAgent` or worker environment tag).
- **Global Handlers**: Registered on background worker startup (`self.addEventListener('error')`, `self.addEventListener('unhandledrejection')`).
- **Resilience**: Error delivery failures fail safely to console without interrupting service worker routing or user transaction flows.


21 changes: 21 additions & 0 deletions apps/extension/entrypoints/background.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
type PairOriginPolicy,
} from "../lib/pair-origins";
import { addGrant, loadState, revokeGrant, setPairedWallet } from "../lib/state";
import { backgroundErrorReporter } from "../lib/error-reporter";

// Background service worker (technical-doc.md §6.2B, §7.3): routes validated
// dApp requests, holds the pending-approval queue, and opens the approval
Expand All @@ -35,6 +36,20 @@ export default defineBackground(() => {
// Track the open approval window so we don't spawn a new one per request.
let approvalWindowId: number | undefined;

// Global unhandled error handlers for background worker (#302)
if (typeof self !== "undefined") {
self.addEventListener("error", (event) => {
void backgroundErrorReporter.reportError(event.error ?? event.message, {
source: "background_unhandled_error",
});
});
self.addEventListener("unhandledrejection", (event) => {
void backgroundErrorReporter.reportError(event.reason, {
source: "background_unhandled_rejection",
});
});
}

// L3: resolve the pair-origin allowlist once at startup. A misconfigured
// production build (no origins, no escape hatch) throws — we DISABLE pairing
// rather than fall back, so an unconfigured prod artifact can never pair with
Expand All @@ -51,8 +66,10 @@ export default defineBackground(() => {
} catch (err) {
if (err instanceof PairOriginsMisconfiguredError) {
console.error(`[vellar] pairing disabled: ${err.message}`);
void backgroundErrorReporter.reportError(err, { source: "pair_origins_misconfigured" });
pairOrigins = []; // fail closed: no origin may pair
} else {
void backgroundErrorReporter.reportError(err, { source: "pair_origins_init" });
throw err;
}
}
Expand Down Expand Up @@ -176,6 +193,10 @@ export default defineBackground(() => {
});
entry.resolve({ method: "sign_transaction", result: { signedXdr } });
} catch (err) {
void backgroundErrorReporter.reportError(err, {
method: "sign_transaction",
origin: entry.origin,
});
entry.resolve(
errorPayload("internal", err instanceof Error ? err.message : "Signing failed"),
);
Expand Down
10 changes: 6 additions & 4 deletions apps/extension/entrypoints/popup/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,21 +77,23 @@ interface RequestDescription {
address?: string;
}

import { sanitizeString } from "../../lib/sanitization";

function describeRequest(request: PendingApprovalSummary["request"]): RequestDescription {
switch (request.method) {
case "connect":
return { text: "wants to connect: see your address and request transaction approvals." };
case "pair":
return {
text: `wants to pair this extension as a device signer on ${request.params.network}. You'll confirm with your passkey next; the pairing expires automatically.`,
address: request.params.address,
text: `wants to pair this extension as a device signer on ${sanitizeString(request.params.network)}. You'll confirm with your passkey next; the pairing expires automatically.`,
address: sanitizeString(request.params.address),
};
case "sign_transaction":
return {
text: `wants you to sign a transaction on ${request.params.network}. Approving signs it with this device's key — review the site carefully.`,
text: `wants you to sign a transaction on ${sanitizeString(request.params.network)}. Approving signs it with this device's key — review the site carefully.`,
};
default:
return { text: `sent a ${request.method} request.` };
return { text: `sent a ${sanitizeString((request as { method: string }).method)} request.` };
}
}

Expand Down
58 changes: 58 additions & 0 deletions apps/extension/lib/error-reporter.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { describe, it, expect, vi } from "vitest";
import {
ErrorReporter,
DefaultErrorReportClient,
type ErrorReportPayload,
} from "./error-reporter";

describe("extension error reporter integration (#302)", () => {
it("includes extension version and browser info in reported errors", async () => {
const mockClient = new DefaultErrorReportClient();
const reporter = new ErrorReporter({
extensionVersion: "1.2.3",
browserInfo: "Mozilla/5.0 (Windows NT 10.0; Chrome/120.0)",
client: mockClient,
});

const testError = new TypeError("Network request failed");
const payload = await reporter.reportError(testError, {
route: "provider-request",
origin: "https://dapp.example.com",
});

expect(payload.name).toBe("TypeError");
expect(payload.message).toBe("Network request failed");
expect(payload.context.extensionVersion).toBe("1.2.3");
expect(payload.context.browserInfo).toBe("Mozilla/5.0 (Windows NT 10.0; Chrome/120.0)");
expect(payload.context.origin).toBe("https://dapp.example.com");

const reports = mockClient.getReports();
expect(reports.length).toBe(1);
expect(reports[0]).toEqual(payload);
});

it("handles non-Error objects and string throws gracefully", async () => {
const mockClient = new DefaultErrorReportClient();
const reporter = new ErrorReporter({ client: mockClient });

const payload = await reporter.reportError("String error message", {
handler: "handlePairApproval",
});

expect(payload.name).toBe("Error");
expect(payload.message).toBe("String error message");
expect(payload.context.handler).toBe("handlePairApproval");
});

it("catches client delivery errors without crashing the background worker", async () => {
const failingClient = {
send: vi.fn().mockRejectedValue(new Error("Network offline")),
};
const reporter = new ErrorReporter({ client: failingClient });

const payload = await reporter.reportError(new Error("Worker error"));

expect(failingClient.send).toHaveBeenCalledTimes(1);
expect(payload.message).toBe("Worker error");
});
});
92 changes: 92 additions & 0 deletions apps/extension/lib/error-reporter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
/**
* Error reporting service integration for the extension background worker (#302).
* Captures uncaught and operational errors in the background worker and forwards
* them to the centralized reporting endpoint along with extension version and browser metadata.
*/

export interface ErrorReportContext {
extensionVersion: string;
browserInfo: string;
userAgent?: string;
url?: string;
[key: string]: unknown;
}

export interface ErrorReportPayload {
message: string;
name: string;
stack?: string;
context: ErrorReportContext;
timestamp: string;
}

export interface ErrorReportClient {
send(payload: ErrorReportPayload): Promise<void>;
}

/** Default in-memory/console error reporting client fallback. */
export class DefaultErrorReportClient implements ErrorReportClient {
private readonly reports: ErrorReportPayload[] = [];

async send(payload: ErrorReportPayload): Promise<void> {
this.reports.push(payload);
console.error("[vellar-error-reporter]", payload.name, payload.message, payload.context);
}

getReports(): ErrorReportPayload[] {
return [...this.reports];
}
}

export interface ErrorReporterOptions {
extensionVersion?: string;
browserInfo?: string;
client?: ErrorReportClient;
}

export class ErrorReporter {
private readonly extensionVersion: string;
private readonly browserInfo: string;
private readonly client: ErrorReportClient;

constructor(options: ErrorReporterOptions = {}) {
this.extensionVersion =
options.extensionVersion ??
(typeof process !== "undefined" && process.env?.WXT_PUBLIC_VERSION
? process.env.WXT_PUBLIC_VERSION
: "0.1.0");
this.browserInfo =
options.browserInfo ??
(typeof navigator !== "undefined" ? navigator.userAgent : "BackgroundWorker/VellarExtension");
this.client = options.client ?? new DefaultErrorReportClient();
}

async reportError(
error: unknown,
additionalContext: Record<string, unknown> = {},
): Promise<ErrorReportPayload> {
const errObj = error instanceof Error ? error : new Error(String(error ?? "Unknown Error"));
const payload: ErrorReportPayload = {
name: errObj.name || "Error",
message: errObj.message || "An unknown error occurred",
stack: errObj.stack,
context: {
extensionVersion: this.extensionVersion,
browserInfo: this.browserInfo,
...additionalContext,
},
timestamp: new Date().toISOString(),
};

try {
await this.client.send(payload);
} catch (sendErr) {
console.error("Failed to deliver error report payload", sendErr);
}

return payload;
}
}

/** Global background worker error reporter instance */
export const backgroundErrorReporter = new ErrorReporter();
9 changes: 6 additions & 3 deletions apps/extension/lib/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ function respond(payload: ResponsePayload, revokeGrant?: boolean): RouteDecision
return revokeGrant ? { kind: "respond", payload, revokeGrant } : { kind: "respond", payload };
}

import { sanitizeString } from "./sanitization";

export function routeProviderRequest(
request: ProviderRequest,
rawOrigin: string,
Expand All @@ -34,6 +36,7 @@ export function routeProviderRequest(
if (!origin) {
return respond(errorPayload("invalid_request", "Requests from this origin are not supported"));
}
const cleanOrigin = sanitizeString(origin);

// Pairing is the one method that must work while nothing is paired yet.
// Always requires explicit popup approval (origin + wallet shown), and the
Expand All @@ -47,7 +50,7 @@ export function routeProviderRequest(
errorPayload("unauthorized", "This origin is not permitted to pair the Vellar extension"),
);
}
return { kind: "needs-approval", origin };
return { kind: "needs-approval", origin: cleanOrigin };
}

// Status probe: no approval, but only confirms an address+network the
Expand Down Expand Up @@ -83,7 +86,7 @@ export function routeProviderRequest(
result: { address: wallet.address, network: wallet.network },
});
}
return { kind: "needs-approval", origin };
return { kind: "needs-approval", origin: cleanOrigin };
}

case "get_address": {
Expand All @@ -102,7 +105,7 @@ export function routeProviderRequest(
}
// Every transaction requires explicit approval — a grant only allows
// the origin to ASK (§5.3 no silent signing).
return { kind: "needs-approval", origin };
return { kind: "needs-approval", origin: cleanOrigin };
}

case "disconnect": {
Expand Down
86 changes: 86 additions & 0 deletions apps/extension/lib/sanitization.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { describe, it, expect } from "vitest";
import {
escapeHtml,
sanitizeUrl,
sanitizeString,
sanitizeDAppMetadata,
} from "./sanitization";

describe("input sanitization helpers (#312)", () => {
describe("escapeHtml", () => {
it("escapes script tags and special HTML characters", () => {
expect(escapeHtml("<script>alert('xss')</script>")).toBe(
"&lt;script&gt;alert(&#x27;xss&#x27;)&lt;&#x2F;script&gt;",
);
expect(escapeHtml('Hello "World" & <Friends>')).toBe(
"Hello &quot;World&quot; &amp; &lt;Friends&gt;",
);
});
});

describe("sanitizeUrl", () => {
it("blocks javascript: and data: URIs", () => {
expect(sanitizeUrl("javascript:alert(1)")).toBe("");
expect(sanitizeUrl("JAVASCRIPT:alert(1)")).toBe("");
expect(sanitizeUrl("data:text/html,<script>alert(1)</script>")).toBe("");
expect(sanitizeUrl("vbscript:msgbox(1)")).toBe("");
});

it("allows valid http and https URLs", () => {
expect(sanitizeUrl("https://example.com/icon.png")).toBe(
"https:&#x2F;&#x2F;example.com&#x2F;icon.png",
);
});

it("handles undefined or null inputs", () => {
expect(sanitizeUrl(undefined)).toBe("");
expect(sanitizeUrl("")).toBe("");
});
});

describe("sanitizeString", () => {
it("strips HTML tags and removes control characters", () => {
expect(sanitizeString("<img src=x onerror=alert(1)>Malicious")).toBe(
"Malicious",
);
expect(sanitizeString("Clean\x00Name")).toBe("CleanName");
});

it("truncates string to specified max length", () => {
const longInput = "a".repeat(200);
expect(sanitizeString(longInput, 50).length).toBe(50);
});

it("handles non-string or malformed inputs safely", () => {
expect(sanitizeString(12345)).toBe("12345");
expect(sanitizeString({ invalid: "object" })).toBe("[object Object]");
expect(sanitizeString(null)).toBe("");
expect(sanitizeString(undefined)).toBe("");
});
});

describe("sanitizeDAppMetadata", () => {
it("sanitizes full dApp metadata payload containing script injection attempts", () => {
const payload = {
name: "<script>eval('bad')</script>DApp Exchange",
description: "Best DEX <iframe src='http://evil.com'></iframe> for tokens",
iconUrl: "javascript:void(0)",
origin: "https://dapp.example.com",
};

const result = sanitizeDAppMetadata(payload);

expect(result.name).not.toContain("<script>");
expect(result.name).toContain("DApp Exchange");
expect(result.description).not.toContain("<iframe");
expect(result.description).toContain("Best DEX for tokens");
expect(result.iconUrl).toBe("");
expect(result.origin).toBe("https:&#x2F;&#x2F;dapp.example.com");
});

it("provides fallback for missing or empty name", () => {
const result = sanitizeDAppMetadata({ name: "" });
expect(result.name).toBe("Unknown dApp");
});
});
});
Loading