From 4d14de52a2459b68b3ad176133a432a48d8ec07a Mon Sep 17 00:00:00 2001 From: Arjun Porwal <65214523+ap211unitech@users.noreply.github.com> Date: Mon, 23 Jun 2025 19:02:45 +0530 Subject: [PATCH] Implement rate limiting for sign requests (#1562) * feat: implement rate limiting for sign requests to prevent abuse * feat: add tests for rate limit handling for signing requests --- .../src/background/handlers/Extension.spec.ts | 22 +++++++++++++++ .../src/background/handlers/State.ts | 28 +++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/packages/extension-base/src/background/handlers/Extension.spec.ts b/packages/extension-base/src/background/handlers/Extension.spec.ts index 2cd5189a..bd101c81 100644 --- a/packages/extension-base/src/background/handlers/Extension.spec.ts +++ b/packages/extension-base/src/background/handlers/Extension.spec.ts @@ -22,6 +22,8 @@ import Extension from './Extension.js'; import State from './State.js'; import Tabs from './Tabs.js'; +const sleep = () => new Promise((resolve) => setTimeout(resolve, 3000)); + describe('Extension', () => { let extension: Extension; let state: State; @@ -228,7 +230,24 @@ describe('Extension', () => { expect(res).toEqual(true); }); + it('should throw error for rate-limited signing requests', async () => { + const registry = new TypeRegistry(); + + registry.setSignedExtensions(payload.signedExtensions); + + await expect( + tabs.handle( + '1615191860871.5', + 'pub(extrinsic.sign)', + payload, + 'http://localhost:3000', + {} as chrome.runtime.Port + ) + ).rejects.toThrow('Rate limit exceeded. Try again later.'); + }); + it('signs with default signed extensions - ethereum', async () => { + await sleep(); const ethAddress = await createAccount('ethereum'); const ethPair = keyring.getPair(ethAddress); @@ -279,6 +298,7 @@ describe('Extension', () => { }); it('signs with user extensions, known types', async () => { + await sleep(); const types = {} as unknown as Record; const userExtensions = { @@ -345,6 +365,7 @@ describe('Extension', () => { }); it('override default signed extension', async () => { + await sleep(); const types = { FeeExchangeV1: { assetId: 'Compact', @@ -405,6 +426,7 @@ describe('Extension', () => { }); it('signs with user extensions, additional types', async () => { + await sleep(); const types = { myCustomType: { feeExchange: 'Compact', diff --git a/packages/extension-base/src/background/handlers/State.ts b/packages/extension-base/src/background/handlers/State.ts index e1ae8752..a06c1897 100644 --- a/packages/extension-base/src/background/handlers/State.ts +++ b/packages/extension-base/src/background/handlers/State.ts @@ -128,6 +128,10 @@ async function extractMetadata (store: MetadataStore): Promise { export default class State { #authUrls = new Map(); + #lastRequestTimestamps = new Map(); + #maxEntries = 10; + #rateLimitInterval = 3000; // 3 seconds + readonly #authRequests: Record = {}; readonly #metaStore = new MetadataStore(); @@ -615,9 +619,33 @@ export default class State { return true; } + private handleSignRequest (origin: string) { + const now = Date.now(); + const lastTime = this.#lastRequestTimestamps.get(origin) || 0; + + if (now - lastTime < this.#rateLimitInterval) { + throw new Error('Rate limit exceeded. Try again later.'); + } + + // If we're about to exceed max entries, evict the oldest + if (!this.#lastRequestTimestamps.has(origin) && this.#lastRequestTimestamps.size >= this.#maxEntries) { + const oldestKey = this.#lastRequestTimestamps.keys().next().value; + + oldestKey && this.#lastRequestTimestamps.delete(oldestKey); + } + + this.#lastRequestTimestamps.set(origin, now); + } + public sign (url: string, request: RequestSign, account: AccountJson): Promise { const id = getId(); + try { + this.handleSignRequest(url); + } catch (error) { + return Promise.reject(error); + } + return new Promise((resolve, reject): void => { this.#signRequests[id] = { ...this.signComplete(id, resolve, reject),