diff --git a/modules/rwallet/components/folk-wallet-viewer.ts b/modules/rwallet/components/folk-wallet-viewer.ts index 683333e..dc20a4f 100644 --- a/modules/rwallet/components/folk-wallet-viewer.ts +++ b/modules/rwallet/components/folk-wallet-viewer.ts @@ -315,6 +315,14 @@ class FolkWalletViewer extends HTMLElement { // ── EIP-6963 Provider Discovery ── + private sanitizeIconUri(uri: string): string { + if (!uri) return ""; + // Allow https: URLs and safe data: image types (no SVG — can contain scripts) + if (/^https:\/\//i.test(uri)) return uri; + if (/^data:image\/(png|jpeg|gif|webp);base64,/i.test(uri)) return uri; + return ""; // Block javascript:, data:image/svg+xml, etc. + } + private startProviderDiscovery() { this.discoveredProviders = []; this.showProviderPicker = true; @@ -329,7 +337,7 @@ class FolkWalletViewer extends HTMLElement { this.discoveredProviders.push({ uuid: detail.info.uuid, name: detail.info.name, - icon: detail.info.icon, + icon: this.sanitizeIconUri(detail.info.icon || ""), rdns: detail.info.rdns, }); this.render(); diff --git a/modules/rwallet/mod.ts b/modules/rwallet/mod.ts index dfa17f8..89127a2 100644 --- a/modules/rwallet/mod.ts +++ b/modules/rwallet/mod.ts @@ -13,10 +13,21 @@ import { renderLanding } from "./landing"; const routes = new Hono(); +// ── Address validation (prevents SSRF via path traversal) ── +const VALID_ETH_ADDR = /^0x[0-9a-fA-F]{40}$/; +function validateAddress(c: any): string | null { + const address = c.req.param("address"); + if (!VALID_ETH_ADDR.test(address)) { + return null; + } + return address; +} + // ── Proxy Safe Global API (avoid CORS issues from browser) ── routes.get("/api/safe/:chainId/:address/balances", async (c) => { const chainId = c.req.param("chainId"); - const address = c.req.param("address"); + const address = validateAddress(c); + if (!address) return c.json({ error: "Invalid Ethereum address" }, 400); const chainPrefix = getSafePrefix(chainId); if (!chainPrefix) return c.json({ error: "Unsupported chain" }, 400); @@ -38,7 +49,8 @@ routes.get("/api/safe/:chainId/:address/balances", async (c) => { routes.get("/api/safe/:chainId/:address/transfers", async (c) => { const chainId = c.req.param("chainId"); - const address = c.req.param("address"); + const address = validateAddress(c); + if (!address) return c.json({ error: "Invalid Ethereum address" }, 400); const chainPrefix = getSafePrefix(chainId); if (!chainPrefix) return c.json({ error: "Unsupported chain" }, 400); @@ -50,7 +62,8 @@ routes.get("/api/safe/:chainId/:address/transfers", async (c) => { routes.get("/api/safe/:chainId/:address/info", async (c) => { const chainId = c.req.param("chainId"); - const address = c.req.param("address"); + const address = validateAddress(c); + if (!address) return c.json({ error: "Invalid Ethereum address" }, 400); const chainPrefix = getSafePrefix(chainId); if (!chainPrefix) return c.json({ error: "Unsupported chain" }, 400); @@ -63,7 +76,8 @@ routes.get("/api/safe/:chainId/:address/info", async (c) => { // Detect which chains have a Safe for this address routes.get("/api/safe/detect/:address", async (c) => { - const address = c.req.param("address"); + const address = validateAddress(c); + if (!address) return c.json({ error: "Invalid Ethereum address" }, 400); const includeTestnets = c.req.query("testnets") === "true"; const chains = getChains(includeTestnets); const results: Array<{ chainId: string; name: string; prefix: string }> = []; @@ -195,7 +209,8 @@ routes.post("/api/safe/:chainId/:address/propose", async (c) => { } const chainId = c.req.param("chainId"); - const address = c.req.param("address"); + const address = validateAddress(c); + if (!address) return c.json({ error: "Invalid Safe address" }, 400); const chainPrefix = getSafePrefix(chainId); if (!chainPrefix) return c.json({ error: "Unsupported chain" }, 400); @@ -205,6 +220,9 @@ routes.post("/api/safe/:chainId/:address/propose", async (c) => { if (!to || !signature || !sender) { return c.json({ error: "Missing required fields: to, signature, sender" }, 400); } + if (!VALID_ETH_ADDR.test(sender)) { + return c.json({ error: "Invalid sender address" }, 400); + } // Submit proposal to Safe Transaction Service const res = await fetch( @@ -295,7 +313,8 @@ routes.post("/api/safe/:chainId/:address/execute", async (c) => { } const chainId = c.req.param("chainId"); - const address = c.req.param("address"); + const address = validateAddress(c); + if (!address) return c.json({ error: "Invalid Safe address" }, 400); const chainPrefix = getSafePrefix(chainId); if (!chainPrefix) return c.json({ error: "Unsupported chain" }, 400); @@ -346,7 +365,8 @@ routes.post("/api/safe/:chainId/:address/execute", async (c) => { // Detect which chains have a non-zero native balance for any address routes.get("/api/eoa/detect/:address", async (c) => { - const address = c.req.param("address"); + const address = validateAddress(c); + if (!address) return c.json({ error: "Invalid Ethereum address" }, 400); const includeTestnets = c.req.query("testnets") === "true"; const results: Array<{ chainId: string; name: string; prefix: string; balance: string }> = []; @@ -378,7 +398,8 @@ routes.get("/api/eoa/detect/:address", async (c) => { // Get native token balance for an EOA on a specific chain routes.get("/api/eoa/:chainId/:address/balances", async (c) => { const chainId = c.req.param("chainId"); - const address = c.req.param("address"); + const address = validateAddress(c); + if (!address) return c.json({ error: "Invalid Ethereum address" }, 400); const rpcUrl = RPC_URLS[chainId]; if (!rpcUrl) return c.json({ error: "Unsupported chain" }, 400); @@ -423,7 +444,8 @@ routes.post("/api/safe/:chainId/:address/add-owner-proposal", async (c) => { } const chainId = c.req.param("chainId"); - const address = c.req.param("address"); + const address = validateAddress(c); + if (!address) return c.json({ error: "Invalid Safe address" }, 400); const chainPrefix = getSafePrefix(chainId); if (!chainPrefix) return c.json({ error: "Unsupported chain" }, 400); @@ -431,21 +453,39 @@ routes.post("/api/safe/:chainId/:address/add-owner-proposal", async (c) => { if (!newOwner || !signature || !sender) { return c.json({ error: "Missing required fields: newOwner, signature, sender" }, 400); } - if (!/^0x[0-9a-fA-F]{40}$/.test(newOwner)) { + if (!VALID_ETH_ADDR.test(newOwner)) { return c.json({ error: "Invalid newOwner address" }, 400); } + if (!VALID_ETH_ADDR.test(sender)) { + return c.json({ error: "Invalid sender address" }, 400); + } + + // H-4: Verify sender is the authenticated user's wallet address + // The JWT contains claims.eid.walletAddress (if set) from the user profile + const userWallet = claims.eid?.walletAddress; + if (!userWallet || sender.toLowerCase() !== userWallet.toLowerCase()) { + return c.json({ error: "Sender address does not match your authenticated wallet" }, 403); + } + + // Get Safe info (need nonce + owner count for threshold validation) + const infoRes = await fetch(`${safeApiBase(chainPrefix)}/safes/${address}/`); + if (!infoRes.ok) return c.json({ error: "Safe not found" }, 404); + const safeInfo = await infoRes.json() as { nonce?: number; owners?: string[]; threshold?: number }; + + // M-2: Validate threshold is a positive integer within safe bounds + const currentOwnerCount = safeInfo.owners?.length || 1; + const newOwnerCount = currentOwnerCount + 1; // adding an owner + const th = Number(threshold) || safeInfo.threshold || 1; + if (!Number.isInteger(th) || th < 1 || th > newOwnerCount) { + return c.json({ error: `Threshold must be between 1 and ${newOwnerCount} (current owners + new)` }, 400); + } // Encode addOwnerWithThreshold(address owner, uint256 _threshold) // Function selector: 0x0d582f13 const paddedOwner = newOwner.slice(2).toLowerCase().padStart(64, "0"); - const paddedThreshold = (threshold || 1).toString(16).padStart(64, "0"); + const paddedThreshold = th.toString(16).padStart(64, "0"); const data = `0x0d582f13${paddedOwner}${paddedThreshold}`; - // Get Safe nonce - const infoRes = await fetch(`${safeApiBase(chainPrefix)}/safes/${address}/`); - if (!infoRes.ok) return c.json({ error: "Safe not found" }, 404); - const safeInfo = await infoRes.json() as { nonce?: number }; - // Submit proposal to Safe Transaction Service const res = await fetch( `${safeApiBase(chainPrefix)}/safes/${address}/multisig-transactions/`, diff --git a/src/encryptid/server.ts b/src/encryptid/server.ts index 02a3554..d7b68aa 100644 --- a/src/encryptid/server.ts +++ b/src/encryptid/server.ts @@ -1450,6 +1450,7 @@ async function generateSessionToken(userId: string, username: string): Promise(); +function checkNonceRateLimit(userId: string): boolean { + const now = Date.now(); + const window = 5 * 60 * 1000; + const timestamps = (_nonceLimiter.get(userId) || []).filter(t => now - t < window); + if (timestamps.length >= 5) return false; + timestamps.push(now); + _nonceLimiter.set(userId, timestamps); + return true; +} + // POST /encryptid/api/wallet-link/nonce — Generate a nonce for SIWE signing. // Reuses the challenges table with type 'wallet_link'. app.post('/encryptid/api/wallet-link/nonce', async (c) => { const claims = await verifyTokenFromRequest(c.req.header('Authorization')); if (!claims) return c.json({ error: 'Authentication required' }, 401); + if (!checkNonceRateLimit(claims.sub)) { + return c.json({ error: 'Too many nonce requests. Try again in a few minutes.' }, 429); + } + const nonce = crypto.randomUUID().replace(/-/g, ''); await storeChallenge({ challenge: nonce,