diff --git a/modules/rwallet/mod.ts b/modules/rwallet/mod.ts index 89127a2..5327074 100644 --- a/modules/rwallet/mod.ts +++ b/modules/rwallet/mod.ts @@ -54,7 +54,8 @@ routes.get("/api/safe/:chainId/:address/transfers", async (c) => { const chainPrefix = getSafePrefix(chainId); if (!chainPrefix) return c.json({ error: "Unsupported chain" }, 400); - const limit = c.req.query("limit") || "100"; + const rawLimit = parseInt(c.req.query("limit") || "100", 10); + const limit = isNaN(rawLimit) || rawLimit < 1 ? 100 : Math.min(rawLimit, 200); const res = await fetch(`${safeApiBase(chainPrefix)}/safes/${address}/all-transactions/?limit=${limit}&executed=true`); if (!res.ok) return c.json({ error: "Safe API error" }, res.status as any); return c.json(await res.json()); @@ -248,8 +249,8 @@ routes.post("/api/safe/:chainId/:address/propose", async (c) => { ); if (!res.ok) { - const err = await res.text(); - return c.json({ error: "Safe Transaction Service error", details: err }, res.status as any); + console.warn('rwallet: Safe propose error', res.status, await res.text()); + return c.json({ error: "Safe Transaction Service rejected the request" }, res.status as any); } return c.json(await res.json(), 201); @@ -286,8 +287,8 @@ routes.post("/api/safe/:chainId/:address/confirm", async (c) => { ); if (!res.ok) { - const err = await res.text(); - return c.json({ error: "Confirmation failed", details: err }, res.status as any); + console.warn('rwallet: Safe confirm error', res.status, await res.text()); + return c.json({ error: "Confirmation failed" }, res.status as any); } return c.json(await res.json()); @@ -510,8 +511,8 @@ routes.post("/api/safe/:chainId/:address/add-owner-proposal", async (c) => { ); if (!res.ok) { - const err = await res.text(); - return c.json({ error: "Safe Transaction Service error", details: err }, res.status as any); + console.warn('rwallet: Safe add-owner error', res.status, await res.text()); + return c.json({ error: "Safe Transaction Service rejected the request" }, res.status as any); } return c.json(await res.json(), 201); diff --git a/src/encryptid/external-signer.ts b/src/encryptid/external-signer.ts index 54a69cf..584c081 100644 --- a/src/encryptid/external-signer.ts +++ b/src/encryptid/external-signer.ts @@ -96,8 +96,14 @@ export class ExternalSigner { types: Record>, value: Record, ): Promise { + // Build EIP712Domain type array dynamically from domain fields + const domainType: Array<{ name: string; type: string }> = []; + if (domain.name !== undefined) domainType.push({ name: 'name', type: 'string' }); + if (domain.version !== undefined) domainType.push({ name: 'version', type: 'string' }); + if (domain.chainId !== undefined) domainType.push({ name: 'chainId', type: 'uint256' }); + if (domain.verifyingContract !== undefined) domainType.push({ name: 'verifyingContract', type: 'address' }); const data = { - types: { EIP712Domain: [], ...types }, + types: { EIP712Domain: domainType, ...types }, domain, primaryType: Object.keys(types).find(k => k !== 'EIP712Domain') || '', message: value, diff --git a/src/encryptid/server.ts b/src/encryptid/server.ts index d7b68aa..0bba64f 100644 --- a/src/encryptid/server.ts +++ b/src/encryptid/server.ts @@ -351,6 +351,13 @@ const app = new Hono(); // Middleware app.use('*', logger()); +// Security headers +app.use('*', async (c, next) => { + await next(); + c.header('X-Content-Type-Options', 'nosniff'); + c.header('X-Frame-Options', 'DENY'); + c.header('Referrer-Policy', 'strict-origin-when-cross-origin'); +}); app.use('*', cors({ origin: (origin) => { // Allow all *.rspace.online subdomains dynamically (any canvas slug) @@ -2764,8 +2771,11 @@ async function decryptWalletEntry(ciphertext: string, iv: 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); + const windowMs = 5 * 60 * 1000; + const timestamps = (_nonceLimiter.get(userId) || []).filter(t => now - t < windowMs); + if (timestamps.length === 0) { + _nonceLimiter.delete(userId); // evict stale keys + } if (timestamps.length >= 5) return false; timestamps.push(now); _nonceLimiter.set(userId, timestamps); @@ -2805,11 +2815,20 @@ app.post('/encryptid/api/wallet-link/verify', async (c) => { return c.json({ error: 'Elevated authentication required to link wallets' }, 403); } - const { message, signature, addressHash, walletType, entry } = await c.req.json(); + const body = await c.req.json(); + const { message, signature, addressHash, walletType, entry } = body; if (!message || !signature || !addressHash) { return c.json({ error: 'Missing required fields: message, signature, addressHash' }, 400); } + // Input length and type guards + if (typeof message !== 'string' || message.length > 4096) return c.json({ error: 'Invalid message' }, 400); + if (typeof signature !== 'string' || signature.length > 520) return c.json({ error: 'Invalid signature' }, 400); + if (typeof addressHash !== 'string' || addressHash.length > 128) return c.json({ error: 'Invalid addressHash' }, 400); + if (entry && typeof entry === 'object') { + const json = JSON.stringify(entry); + if (json.length > 2048) return c.json({ error: 'Entry data too large' }, 400); + } // Parse and verify SIWE message using viem/siwe try { @@ -2852,8 +2871,13 @@ app.post('/encryptid/api/wallet-link/verify', async (c) => { return c.json({ error: 'Signature verification failed' }, 400); } + // Recompute addressHash server-side (don't trust client-supplied hash) + const addrNormalized = claims.sub + ':' + (parsed.address as string).toLowerCase(); + const addrHashBuf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(addrNormalized)); + const serverAddressHash = Buffer.from(addrHashBuf).toString('base64url'); + // Check for duplicate - const exists = await linkedWalletExists(claims.sub, addressHash); + const exists = await linkedWalletExists(claims.sub, serverAddressHash); if (exists) { return c.json({ error: 'This wallet is already linked to your account' }, 409); } @@ -2871,14 +2895,14 @@ app.post('/encryptid/api/wallet-link/verify', async (c) => { id, ciphertext: encrypted.ciphertext, iv: encrypted.iv, - addressHash, + addressHash: serverAddressHash, source: source as 'external-eoa' | 'external-safe', }); return c.json({ id: stored.id, linked: true }, 201); } catch (err: any) { console.error('EncryptID: SIWE verification error', err); - return c.json({ error: 'SIWE verification failed', details: err?.message }, 400); + return c.json({ error: 'SIWE verification failed' }, 400); } }); diff --git a/src/encryptid/session.ts b/src/encryptid/session.ts index 4392ad4..26b39cd 100644 --- a/src/encryptid/session.ts +++ b/src/encryptid/session.ts @@ -6,6 +6,7 @@ */ import { AuthenticationResult, bufferToBase64url } from './webauthn'; +import { resetLinkedWalletStore } from './linked-wallets'; // ============================================================================ // TYPES @@ -338,6 +339,9 @@ export class SessionManager { this.refreshTimer = null; } + // Clear linked wallet cache so it doesn't survive across user sessions + resetLinkedWalletStore(); + try { localStorage.removeItem(SESSION_STORAGE_KEY); } catch {