fix(security): rate limit, sender verification, icon sanitization, SSRF prevention

- H-3: Rate limit wallet-link nonce to 5 per user per 5 minutes (429)
- H-4: Verify sender address matches JWT walletAddress in add-owner-proposal;
  also include walletAddress in JWT eid claims
- M-1: Sanitize EIP-6963 provider icons — only allow https: and safe
  data:image/(png|jpeg|gif|webp), block SVG and javascript: URIs
- M-2: Validate threshold is a positive integer ≤ newOwnerCount, fetch
  actual Safe owner list for bounds checking
- M-3: Add VALID_ETH_ADDR regex validation to all 9 routes that accept
  address params (Safe proxy, EOA proxy, propose, confirm, execute,
  add-owner-proposal) to prevent SSRF via path traversal

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-09 17:51:18 -07:00
parent 45f5cea095
commit 92fde65d60
3 changed files with 82 additions and 17 deletions

View File

@ -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();

View File

@ -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/`,

View File

@ -1450,6 +1450,7 @@ async function generateSessionToken(userId: string, username: string): Promise<s
did: `did:key:${userId.slice(0, 32)}`,
eid: {
authLevel: 3, // ELEVATED (fresh WebAuthn)
walletAddress: profile?.walletAddress || null,
capabilities: {
encrypt: true,
sign: true,
@ -2759,12 +2760,28 @@ async function decryptWalletEntry(ciphertext: string, iv: string): Promise<strin
// LINKED WALLET ROUTES (SIWE-verified external wallet associations)
// ============================================================================
// Rate limiter for wallet-link nonce: max 5 requests per user per 5 minutes
const _nonceLimiter = new Map<string, number[]>();
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,