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

View File

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

View File

@ -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<strin
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);
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);
}
});

View File

@ -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 {