From 113b0ee33334bdb20e8fa7b06485d45a81948b8a Mon Sep 17 00:00:00 2001 From: defiQUG Date: Sun, 13 Sep 2026 20:50:10 -0700 Subject: [PATCH] Harden treasury API authorization signatures --- backend/src/index.ts | 14 +++++++++++++- frontend/lib/api/client.ts | 6 ++++-- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/backend/src/index.ts b/backend/src/index.ts index ffc25f0..86a3b40 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -19,10 +19,12 @@ const clientRpcUrl = const chainId = process.env.CHAIN_ID || "138"; const contractAddress = process.env.CONTRACT_ADDRESS || ""; const allowedOrigin = process.env.CORS_ORIGIN || "https://treasury.d-bis.org"; +const authDomain = process.env.AUTH_DOMAIN || new URL(allowedOrigin).host; const chain138 = defineChain({ id: 138, name: "Solace Chain 138", nativeCurrency: { name: "Ether", symbol: "ETH", decimals: 18 }, rpcUrls: { default: { http: [rpcUrl] } } }); const publicClient = createPublicClient({ chain: chain138, transport: viemHttp(rpcUrl) }); const ownerAbi = [{ type: "function", name: "isOwner", stateMutability: "view", inputs: [{ name: "owner", type: "address" }], outputs: [{ type: "bool" }] }] as const; const requestWindows = new Map(); +const usedAuthNonces = new Map(); const API_RATE_LIMIT = 120; for (const envVar of ["DATABASE_URL", "RPC_URL", "CHAIN_ID"] as const) { @@ -149,9 +151,17 @@ const server = http.createServer(async (req, res) => { const rows = await treasuryRouter.getById(treasuryId); const treasury = rows[0]; if (!treasury || typeof wallet !== "string" || typeof signature !== "string" || typeof message !== "string" || !isAddress(wallet) || !contractAddress || treasury.mainWallet !== contractAddress.toLowerCase()) { console.warn(JSON.stringify({ event: "treasury_authorization_denied", treasuryId, ip: req.socket.remoteAddress })); return null; } - const timestamp = Number(message.match(/\nTimestamp: (\d+)$/)?.[1]); + const expectedPath = `${url.pathname}${url.search}`; + const authMatch = message.match(/^Solace Treasury API authorization\nDomain: ([^\n]+)\nChain ID: (\d+)\nWallet: (0x[a-fA-F0-9]{40})\nMethod: ([A-Z]+)\nPath: ([^\n]+)\nTimestamp: (\d+)\nNonce: ([a-fA-F0-9-]+)$/); + if (!authMatch || authMatch[1] !== authDomain || Number(authMatch[2]) !== Number(chainId) || authMatch[3].toLowerCase() !== wallet.toLowerCase() || authMatch[4] !== req.method || authMatch[5] !== expectedPath) return null; + const timestamp = Number(authMatch[6]); + const nonce = authMatch[7]; if (!Number.isFinite(timestamp) || Math.abs(Date.now() - timestamp) > 300000) return null; + const nonceKey = `${wallet.toLowerCase()}:${nonce}`; + for (const [key, expires] of usedAuthNonces) if (expires < Date.now()) usedAuthNonces.delete(key); + if (usedAuthNonces.has(nonceKey)) return null; if (!await verifyMessage({ address: wallet, message, signature: signature as `0x${string}` })) return null; + usedAuthNonces.set(nonceKey, timestamp + 300000); const owner = await publicClient.readContract({ address: contractAddress as `0x${string}`, abi: ownerAbi, functionName: "isOwner", args: [wallet] }); return owner ? treasury : null; } @@ -167,6 +177,8 @@ const server = http.createServer(async (req, res) => { sendJson(res, 403, { error: "Wallet authorization required" }); return; } + const treasury = await treasuryRouter.getByWallet(wallet); + if (!treasury || !(await authorizedTreasury(treasury.id))) { sendJson(res, 403, { error: "Wallet authorization required" }); return; } const ledger = await fetchTreasuryLedger(wallet); sendJson(res, 200, { ledger }); return; diff --git a/frontend/lib/api/client.ts b/frontend/lib/api/client.ts index 73e50c4..98848d7 100644 --- a/frontend/lib/api/client.ts +++ b/frontend/lib/api/client.ts @@ -68,11 +68,13 @@ export interface LedgerEntry { blockNumber: number; } -async function signedHeaders(path: string, wallet?: string): Promise | undefined> { +async function signedHeaders(path: string, wallet?: string, method = "GET"): Promise | undefined> { if (!wallet || typeof window === "undefined") return undefined; const ethereum = (window as Window & { ethereum?: { request: (args: { method: string; params: string[] }) => Promise } }).ethereum; if (!ethereum) return undefined; - const message = `Solace Treasury API authorization\nWallet: ${wallet.toLowerCase()}\nPath: ${path}\nTimestamp: ${Date.now()}`; + const domain = window.location.host; + const nonce = crypto.randomUUID(); + const message = `Solace Treasury API authorization\nDomain: ${domain}\nChain ID: 138\nWallet: ${wallet.toLowerCase()}\nMethod: ${method}\nPath: ${path}\nTimestamp: ${Date.now()}\nNonce: ${nonce}`; const signature = await ethereum.request({ method: "personal_sign", params: [message, wallet] }); return { "X-Wallet-Address": wallet, "X-Wallet-Message": message, "X-Wallet-Signature": signature }; }