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:
parent
45f5cea095
commit
92fde65d60
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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/`,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue