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:
parent
92fde65d60
commit
bc810d34e4
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in New Issue