diff --git a/apps/extension/README.md b/apps/extension/README.md
index 7b7c3aa..3b9b005 100644
--- a/apps/extension/README.md
+++ b/apps/extension/README.md
@@ -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 (`")).toBe(
+ "<script>alert('xss')</script>",
+ );
+ expect(escapeHtml('Hello "World" & ')).toBe(
+ "Hello "World" & <Friends>",
+ );
+ });
+ });
+
+ 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,")).toBe("");
+ expect(sanitizeUrl("vbscript:msgbox(1)")).toBe("");
+ });
+
+ it("allows valid http and https URLs", () => {
+ expect(sanitizeUrl("https://example.com/icon.png")).toBe(
+ "https://example.com/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("
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: "DApp Exchange",
+ description: "Best DEX for tokens",
+ iconUrl: "javascript:void(0)",
+ origin: "https://dapp.example.com",
+ };
+
+ const result = sanitizeDAppMetadata(payload);
+
+ expect(result.name).not.toContain("