fix(security): low-severity hardening and security headers

- L-1: Remove internal error details from SIWE verify response
- L-2: Stop forwarding raw Safe API error bodies to clients (log server-side)
- L-3: Evict stale keys from nonce rate limiter to prevent memory leak
- L-4: Add input length/type guards on wallet-link verify body fields
- L-5: Sanitize and cap limit query param on Safe transfers route (max 200)
- L-6: Server recomputes addressHash from SIWE address instead of trusting
  client-supplied value for dedup
- L-7: Reset LinkedWalletStore singleton on logout to clear cached keys
- I-1: Add X-Content-Type-Options, X-Frame-Options, Referrer-Policy headers
- I-9: Build EIP712Domain type array dynamically from domain fields in
  ExternalSigner.signTypedData (was hardcoded to empty, dropping fields)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-09 18:06:07 -07:00
parent 92fde65d60
commit bc810d34e4
4 changed files with 49 additions and 14 deletions

View File

@ -54,7 +54,8 @@ routes.get("/api/safe/:chainId/:address/transfers", async (c) => {
const chainPrefix = getSafePrefix(chainId); const chainPrefix = getSafePrefix(chainId);
if (!chainPrefix) return c.json({ error: "Unsupported chain" }, 400); 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`); 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); if (!res.ok) return c.json({ error: "Safe API error" }, res.status as any);
return c.json(await res.json()); return c.json(await res.json());
@ -248,8 +249,8 @@ routes.post("/api/safe/:chainId/:address/propose", async (c) => {
); );
if (!res.ok) { if (!res.ok) {
const err = await res.text(); console.warn('rwallet: Safe propose error', res.status, await res.text());
return c.json({ error: "Safe Transaction Service error", details: err }, res.status as any); return c.json({ error: "Safe Transaction Service rejected the request" }, res.status as any);
} }
return c.json(await res.json(), 201); return c.json(await res.json(), 201);
@ -286,8 +287,8 @@ routes.post("/api/safe/:chainId/:address/confirm", async (c) => {
); );
if (!res.ok) { if (!res.ok) {
const err = await res.text(); console.warn('rwallet: Safe confirm error', res.status, await res.text());
return c.json({ error: "Confirmation failed", details: err }, res.status as any); return c.json({ error: "Confirmation failed" }, res.status as any);
} }
return c.json(await res.json()); return c.json(await res.json());
@ -510,8 +511,8 @@ routes.post("/api/safe/:chainId/:address/add-owner-proposal", async (c) => {
); );
if (!res.ok) { if (!res.ok) {
const err = await res.text(); console.warn('rwallet: Safe add-owner error', res.status, await res.text());
return c.json({ error: "Safe Transaction Service error", details: err }, res.status as any); return c.json({ error: "Safe Transaction Service rejected the request" }, res.status as any);
} }
return c.json(await res.json(), 201); return c.json(await res.json(), 201);

View File

@ -96,8 +96,14 @@ export class ExternalSigner {
types: Record<string, Array<{ name: string; type: string }>>, types: Record<string, Array<{ name: string; type: string }>>,
value: Record<string, any>, value: Record<string, any>,
): Promise<string> { ): Promise<string> {
// 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 = { const data = {
types: { EIP712Domain: [], ...types }, types: { EIP712Domain: domainType, ...types },
domain, domain,
primaryType: Object.keys(types).find(k => k !== 'EIP712Domain') || '', primaryType: Object.keys(types).find(k => k !== 'EIP712Domain') || '',
message: value, message: value,

View File

@ -351,6 +351,13 @@ const app = new Hono();
// Middleware // Middleware
app.use('*', logger()); 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({ app.use('*', cors({
origin: (origin) => { origin: (origin) => {
// Allow all *.rspace.online subdomains dynamically (any canvas slug) // Allow all *.rspace.online subdomains dynamically (any canvas slug)
@ -2764,8 +2771,11 @@ async function decryptWalletEntry(ciphertext: string, iv: string): Promise<strin
const _nonceLimiter = new Map<string, number[]>(); const _nonceLimiter = new Map<string, number[]>();
function checkNonceRateLimit(userId: string): boolean { function checkNonceRateLimit(userId: string): boolean {
const now = Date.now(); const now = Date.now();
const window = 5 * 60 * 1000; const windowMs = 5 * 60 * 1000;
const timestamps = (_nonceLimiter.get(userId) || []).filter(t => now - t < window); 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; if (timestamps.length >= 5) return false;
timestamps.push(now); timestamps.push(now);
_nonceLimiter.set(userId, timestamps); _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); 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) { if (!message || !signature || !addressHash) {
return c.json({ error: 'Missing required fields: message, signature, addressHash' }, 400); 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 // Parse and verify SIWE message using viem/siwe
try { try {
@ -2852,8 +2871,13 @@ app.post('/encryptid/api/wallet-link/verify', async (c) => {
return c.json({ error: 'Signature verification failed' }, 400); 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 // Check for duplicate
const exists = await linkedWalletExists(claims.sub, addressHash); const exists = await linkedWalletExists(claims.sub, serverAddressHash);
if (exists) { if (exists) {
return c.json({ error: 'This wallet is already linked to your account' }, 409); 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, id,
ciphertext: encrypted.ciphertext, ciphertext: encrypted.ciphertext,
iv: encrypted.iv, iv: encrypted.iv,
addressHash, addressHash: serverAddressHash,
source: source as 'external-eoa' | 'external-safe', source: source as 'external-eoa' | 'external-safe',
}); });
return c.json({ id: stored.id, linked: true }, 201); return c.json({ id: stored.id, linked: true }, 201);
} catch (err: any) { } catch (err: any) {
console.error('EncryptID: SIWE verification error', err); 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);
} }
}); });

View File

@ -6,6 +6,7 @@
*/ */
import { AuthenticationResult, bufferToBase64url } from './webauthn'; import { AuthenticationResult, bufferToBase64url } from './webauthn';
import { resetLinkedWalletStore } from './linked-wallets';
// ============================================================================ // ============================================================================
// TYPES // TYPES
@ -338,6 +339,9 @@ export class SessionManager {
this.refreshTimer = null; this.refreshTimer = null;
} }
// Clear linked wallet cache so it doesn't survive across user sessions
resetLinkedWalletStore();
try { try {
localStorage.removeItem(SESSION_STORAGE_KEY); localStorage.removeItem(SESSION_STORAGE_KEY);
} catch { } catch {